diff --git a/src/libecalc/presentation/yaml/mappers/pump_process_simulation_mapper.py b/src/libecalc/presentation/yaml/mappers/pump_process_simulation_mapper.py new file mode 100644 index 0000000000..1f57281716 --- /dev/null +++ b/src/libecalc/presentation/yaml/mappers/pump_process_simulation_mapper.py @@ -0,0 +1,164 @@ +from libecalc.common.errors.ecalc_validation_error import EcalcValidationException +from libecalc.common.errors.exceptions import InvalidResourceException +from libecalc.common.time_utils import Period +from libecalc.common.variables import ExpressionEvaluator +from libecalc.domain.process.value_objects.chart.chart import ChartData +from libecalc.domain.regularity import Regularity +from libecalc.domain.resource import Resources +from libecalc.presentation.yaml.domain.expression_time_series_flow_rate import ExpressionTimeSeriesFlowRate +from libecalc.presentation.yaml.domain.expression_time_series_fluid_density import ExpressionTimeSeriesFluidDensity +from libecalc.presentation.yaml.domain.expression_time_series_pressure import ExpressionTimeSeriesPressure +from libecalc.presentation.yaml.domain.reference_service import ReferenceService +from libecalc.presentation.yaml.domain.time_series_expression import TimeSeriesExpression +from libecalc.presentation.yaml.mappers.charts.user_defined_chart_data import UserDefinedChartData +from libecalc.presentation.yaml.yaml_types.facility_model.yaml_facility_model import ( + YamlPumpChartSingleSpeed, +) +from libecalc.presentation.yaml.yaml_types.process.yaml_process_simulation import YamlPumpProcessSimulation +from libecalc.process.pump.liquid_stream import LiquidStream +from libecalc.process.pump.pump import Pump +from libecalc.process.pump.pump_process_simulation import ( + PumpOperatingInput, + PumpProcessSimulation, +) + + +class PumpProcessSimulationMapper: + def __init__( + self, + expression_evaluator: ExpressionEvaluator, + reference_service: ReferenceService, + resources: Resources, + process_simulation_period: Period, + ): + self._expression_evaluator = expression_evaluator.get_subset_for_period(process_simulation_period) + self._reference_service = reference_service + self._resources = resources + + def map( + self, + yaml_process_simulation: YamlPumpProcessSimulation, + ) -> tuple[PumpProcessSimulation, list[PumpOperatingInput], list[Period]]: + chart_data = self._get_chart_data(yaml_process_simulation.pump_model.chart) + pump = Pump( + pump_chart=chart_data, + minimum_flow_rate_m3_per_hour=yaml_process_simulation.pump_model.minimum_flow_rate, + ) + simulation = PumpProcessSimulation( + pump=pump, + name=yaml_process_simulation.name, + ) + + regularity = Regularity( + expression_evaluator=self._expression_evaluator, + target_period=self._expression_evaluator.get_period(), + ) + rate = ExpressionTimeSeriesFlowRate( + time_series_expression=TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, + expression=yaml_process_simulation.inlet.rate, + ), + regularity=regularity, + ) + rate_values = rate.get_stream_day_values() + suction_pressure = ExpressionTimeSeriesPressure( + time_series_expression=TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, + expression=yaml_process_simulation.inlet.pressure, + ), + ) + discharge_pressure = ExpressionTimeSeriesPressure( + time_series_expression=TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, + expression=yaml_process_simulation.required_discharge_pressure, + ), + ) + density = ExpressionTimeSeriesFluidDensity( + time_series_expression=TimeSeriesExpression( + expression_evaluator=self._expression_evaluator, + expression=yaml_process_simulation.inlet.density, + ) + ) + + suction_values = suction_pressure.get_values() + discharge_values = discharge_pressure.get_values() + density_values = density.get_values() + + # The domain is time-agnostic: build one physical input per period and keep the period + # vector here in the presentation layer, paired with the inputs (and the results) by index. + periods = list(rate.get_periods()) + operating_inputs = [ + self._to_operating_input( + rate_value=rate_value, + suction_pressure_bara=suction, + required_discharge_pressure_bara=discharge, + density_kg_per_m3=density_value, + ) + for rate_value, suction, discharge, density_value in zip( + rate_values, + suction_values, + discharge_values, + density_values, + strict=True, + ) + ] + if len(periods) != len(operating_inputs): + raise ValueError("Pump period vector and input vector length mismatch.") + return simulation, operating_inputs, periods + + def _to_operating_input( + self, + rate_value: float, + suction_pressure_bara: float, + required_discharge_pressure_bara: float, + density_kg_per_m3: float, + ) -> PumpOperatingInput: + # Suction pressure and density describe the physical inlet fluid; they must be positive + # whether or not the pump runs. + self._require_positive(suction_pressure_bara, "suction pressure [bara]") + self._require_positive(density_kg_per_m3, "inlet density [kg/m3]") + inlet_stream = LiquidStream.from_volumetric_rate( + volumetric_rate_m3_per_day=rate_value, + pressure_bara=suction_pressure_bara, + density_kg_per_m3=density_kg_per_m3, + ) + + if rate_value > 0: + self._require_positive(required_discharge_pressure_bara, "required discharge pressure [bara]") + discharge = required_discharge_pressure_bara + else: + # Pump off (zero rate): the required discharge is a meaningless duty target. Keep the + # user's value when it is a valid absolute pressure, otherwise fall back to the inlet + # pressure (the pump delivers no head, so the outlet equals the inlet). + discharge = ( + required_discharge_pressure_bara if required_discharge_pressure_bara > 0 else suction_pressure_bara + ) + + return PumpOperatingInput( + inlet_stream=inlet_stream, + required_discharge_pressure_bara=discharge, + ) + + @staticmethod + def _require_positive(value: float, subject: str) -> None: + if value <= 0: + raise EcalcValidationException(f"Pump {subject} must be greater than 0; got {value}.") + + def _get_chart_data(self, reference: str) -> ChartData: + model = self._reference_service.get_pump_model(reference) + if model.head_margin != 0.0: + raise EcalcValidationException( + "HEAD_MARGIN is not supported by the new pump process domain " + "(points above the maximum head are flagged infeasible instead of snapped to it)." + ) + resource = self._resources.get(model.file) + if resource is None: + raise EcalcValidationException(f"Pump chart resource '{model.file}' was not found.") + try: + return UserDefinedChartData.from_resource( + resource, + units=model.units, + is_single_speed=isinstance(model, YamlPumpChartSingleSpeed), + ) + except InvalidResourceException as error: + raise EcalcValidationException(str(error)) from error diff --git a/src/libecalc/presentation/yaml/model.py b/src/libecalc/presentation/yaml/model.py index fff5355de0..190ffbb212 100644 --- a/src/libecalc/presentation/yaml/model.py +++ b/src/libecalc/presentation/yaml/model.py @@ -45,6 +45,7 @@ from libecalc.presentation.yaml.domain.time_series_resource import TimeSeriesResource from libecalc.presentation.yaml.mappers.component_mapper import EcalcModelMapper from libecalc.presentation.yaml.mappers.process_simulation_mapper import ProcessSimulationMapper +from libecalc.presentation.yaml.mappers.pump_process_simulation_mapper import PumpProcessSimulationMapper from libecalc.presentation.yaml.mappers.variables_mapper import map_yaml_to_variables from libecalc.presentation.yaml.mappers.variables_mapper.get_global_time_vector import ( InvalidEndDate, @@ -68,6 +69,10 @@ YamlModelValidationContextNames, ) from libecalc.process.process_pipeline.process_pipeline import ProcessPipeline +from libecalc.process.pump.pump_process_simulation import ( + PumpOperatingInput, + PumpProcessSimulation, +) DEFAULT_START_TIME = datetime(1900, 1, 1) @@ -165,6 +170,22 @@ def get_process_simulations(self) -> tuple[list[ProcessPipeline], list[ProcessSi return process_pipelines, process_simulations + def get_pump_process_simulations( + self, + ) -> list[tuple[PumpProcessSimulation, list[PumpOperatingInput], list[Period]]]: + self.validate_for_run() + facility_resources, _ = self._resource_service.get_facility_resources() + mapper = PumpProcessSimulationMapper( + expression_evaluator=self.get_expression_evaluator(), + process_simulation_period=self.period, + resources=facility_resources, + reference_service=self._get_reference_service(), + ) + return [ + mapper.map(yaml_pump_process_simulation) + for yaml_pump_process_simulation in self._configuration.pump_process_simulations + ] + def get_periods(self) -> list[Period]: """ Get the global timevector for this model diff --git a/src/libecalc/presentation/yaml/yaml_models/pyyaml_yaml_model.py b/src/libecalc/presentation/yaml/yaml_models/pyyaml_yaml_model.py index 9573b725f1..804b16f3a3 100644 --- a/src/libecalc/presentation/yaml/yaml_models/pyyaml_yaml_model.py +++ b/src/libecalc/presentation/yaml/yaml_models/pyyaml_yaml_model.py @@ -36,6 +36,7 @@ YamlEcalcEvent, YamlProcessEvent, YamlProcessSimulation, + YamlPumpProcessSimulation, ) from libecalc.presentation.yaml.yaml_types.process.yaml_process_units import YamlProcessUnit from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream @@ -52,6 +53,7 @@ _PROCESS_SIMULATIONS_KEY = "PROCESS_SIMULATIONS" _ECALC_EVENTS_KEY = "ECALC_EVENTS" _PROCESS_EVENTS_KEY = "PROCESS_EVENTS" +_PUMP_PROCESS_SIMULATIONS_KEY = "PUMP_PROCESS_SIMULATIONS" _NEW_SECTIONS_WITH_FILE_REFS: tuple[str, ...] = ( "PROCESS_UNITS", "PROCESS_PIPELINES", @@ -506,6 +508,17 @@ def process_events(self) -> list[YamlProcessEvent]: pass return process_events + @property + def pump_process_simulations(self) -> list[YamlPumpProcessSimulation]: + pump_process_simulations: list[YamlPumpProcessSimulation] = [] + adapter = TypeAdapter(YamlPumpProcessSimulation) + for pump_process_simulation in self._get_yaml_list_or_empty(_PUMP_PROCESS_SIMULATIONS_KEY): + try: + pump_process_simulations.append(adapter.validate_python(pump_process_simulation)) + except PydanticValidationError: + pass + return pump_process_simulations + @property def start(self) -> datetime.datetime | None: start_value = self._internal_datamodel.get(EcalcYamlKeywords.start) diff --git a/src/libecalc/presentation/yaml/yaml_models/yaml_model.py b/src/libecalc/presentation/yaml/yaml_models/yaml_model.py index 6c959f32d0..6d664d4548 100644 --- a/src/libecalc/presentation/yaml/yaml_models/yaml_model.py +++ b/src/libecalc/presentation/yaml/yaml_models/yaml_model.py @@ -21,6 +21,7 @@ YamlEcalcEvent, YamlProcessEvent, YamlProcessSimulation, + YamlPumpProcessSimulation, ) from libecalc.presentation.yaml.yaml_types.process.yaml_process_units import YamlProcessUnit from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream @@ -112,6 +113,11 @@ def ecalc_events(self) -> list[YamlEcalcEvent]: def process_events(self) -> list[YamlProcessEvent]: pass + @property + @abc.abstractmethod + def pump_process_simulations(self) -> Iterable[YamlPumpProcessSimulation]: + pass + @property @abc.abstractmethod def installations(self) -> Iterable[YamlInstallation]: diff --git a/src/libecalc/presentation/yaml/yaml_reference_service.py b/src/libecalc/presentation/yaml/yaml_reference_service.py index ece6213e1b..29d1b56ff6 100644 --- a/src/libecalc/presentation/yaml/yaml_reference_service.py +++ b/src/libecalc/presentation/yaml/yaml_reference_service.py @@ -26,7 +26,10 @@ ) from libecalc.presentation.yaml.yaml_types.models.yaml_enums import YamlModelType from libecalc.presentation.yaml.yaml_types.process.yaml_process_pipeline import YamlProcessPipeline -from libecalc.presentation.yaml.yaml_types.process.yaml_process_simulation import YamlProcessSimulation +from libecalc.presentation.yaml.yaml_types.process.yaml_process_simulation import ( + YamlProcessSimulation, + YamlPumpProcessSimulation, +) from libecalc.presentation.yaml.yaml_types.process.yaml_process_units import YamlProcessUnit from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream @@ -35,7 +38,13 @@ YamlModel = YamlConsumerModel | YamlFacilityModel ReferenceType = ( - YamlModel | YamlFuelType | YamlInletStream | YamlProcessPipeline | YamlProcessSimulation | YamlProcessUnit + YamlModel + | YamlFuelType + | YamlInletStream + | YamlProcessPipeline + | YamlProcessSimulation + | YamlPumpProcessSimulation + | YamlProcessUnit ) # Some models are referenced by other models, for example a compressor model will reference compressor chart models @@ -122,6 +131,12 @@ def __init__( references[process_simulation.name] = process_simulation reference_yaml_context[process_simulation.name] = process_simulation_path + pump_process_simulations_path = YamlPath(keys=("PUMP_PROCESS_SIMULATIONS",)) + for pump_process_simulation_index, pump_process_simulation in enumerate(configuration.pump_process_simulations): + pump_process_simulation_path = pump_process_simulations_path.append(pump_process_simulation_index) + references[pump_process_simulation.name] = pump_process_simulation + reference_yaml_context[pump_process_simulation.name] = pump_process_simulation_path + fluid_models_path = YamlPath(keys=("FLUID_MODELS",)) for fluid_model_key, fluid_model in configuration.fluid_models.items(): diff --git a/src/libecalc/presentation/yaml/yaml_types/components/yaml_asset.py b/src/libecalc/presentation/yaml/yaml_types/components/yaml_asset.py index bd9985f61d..bc5950056f 100644 --- a/src/libecalc/presentation/yaml/yaml_types/components/yaml_asset.py +++ b/src/libecalc/presentation/yaml/yaml_types/components/yaml_asset.py @@ -12,6 +12,7 @@ YamlEcalcEvent, YamlProcessEvent, YamlProcessSimulation, + YamlPumpProcessSimulation, ) from libecalc.presentation.yaml.yaml_types.process.yaml_process_units import YamlProcessUnit from libecalc.presentation.yaml.yaml_types.streams.yaml_inlet_stream import YamlInletStream @@ -93,6 +94,11 @@ class YamlAsset(YamlBase): title="PROCESS_EVENTS", description="Defines process-specific events that reference global eCalc events.", ) + pump_process_simulations: list[YamlPumpProcessSimulation] = Field( + default_factory=list, + title="PUMP_PROCESS_SIMULATIONS", + description="Defines one or more liquid pump process simulations to be run.", + ) installations: list[YamlInstallation] = Field( ..., title="INSTALLATIONS", @@ -189,6 +195,10 @@ def validate_unique_references(self): for process_simulation in self.process_simulations: references.append(process_simulation.name) + if self.pump_process_simulations is not None: + for pump_process_simulation in self.pump_process_simulations: + references.append(pump_process_simulation.name) + # TODO: Add ecalc events references? if self.fluid_models is not None: diff --git a/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_references.py b/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_references.py index 228f0607c7..f902aa8f44 100644 --- a/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_references.py +++ b/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_references.py @@ -7,3 +7,4 @@ type ProcessUnitReference = str type EcalcEventReference = str type ProcessEventReference = str +type PumpChartReference = str diff --git a/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_simulation.py b/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_simulation.py index a637f62f12..9e55357c98 100644 --- a/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_simulation.py +++ b/src/libecalc/presentation/yaml/yaml_types/process/yaml_process_simulation.py @@ -1,5 +1,5 @@ from enum import StrEnum -from typing import Annotated +from typing import Annotated, Literal from pydantic import Field @@ -13,6 +13,7 @@ EcalcEventReference, ProcessPipelineReference, ProcessUnitReference, + PumpChartReference, ) from libecalc.presentation.yaml.yaml_types.process.yaml_stream_distribution import YamlStreamDistribution from libecalc.presentation.yaml.yaml_types.yaml_default_datetime import YamlDefaultDatetime @@ -138,3 +139,55 @@ class YamlProcessSimulation(YamlBase): description="Constraints per target. Key is pipeline name, value is list of constraints.", ), ] + + +class YamlPumpProcessModel(YamlBase): + chart: Annotated[ + PumpChartReference, + Field( + title="CHART", + description="Reference to a pump chart defined in FACILITY_INPUTS.", + ), + ] + minimum_flow_rate: Annotated[ + float | None, + Field( + title="MINIMUM_FLOW_RATE", + description="Minimum pump flow in m3/h. Defaults to the chart minimum.", + ), + ] = None + + +class YamlPumpProcessInlet(YamlBase): + rate: Annotated[ + YamlExpressionType, + Field(title="RATE", description="Requested liquid rate in m3/day."), + ] + pressure: Annotated[ + YamlExpressionType, + Field(title="PRESSURE", description="Pump suction pressure in bara."), + ] + density: Annotated[ + YamlExpressionType, + Field(title="DENSITY", description="Liquid density in kg/m3."), + ] + + +class YamlPumpProcessSimulation(YamlBase): + type: Literal["PUMP"] + name: str + pump_model: Annotated[ + YamlPumpProcessModel, + Field(title="PUMP_MODEL"), + ] + inlet: Annotated[ + YamlPumpProcessInlet, + Field(title="INLET"), + ] + required_discharge_pressure: Annotated[ + YamlExpressionType, + Field( + title="REQUIRED_DISCHARGE_PRESSURE", + description="Required pump discharge pressure in bara.", + ), + ] diff --git a/src/libecalc/process/pump/liquid_stream.py b/src/libecalc/process/pump/liquid_stream.py index e19b1c73d3..3c2888fd0f 100644 --- a/src/libecalc/process/pump/liquid_stream.py +++ b/src/libecalc/process/pump/liquid_stream.py @@ -63,7 +63,7 @@ def from_volumetric_rate( pressure_bara: float, density_kg_per_m3: float, ) -> LiquidStream: - """Create a stream from an actual volumetric flow rate [m3/day].""" + """Create a stream from a volumetric flow rate [m3/day].""" mass_rate_kg_per_h = volumetric_rate_m3_per_day * density_kg_per_m3 / UnitConstants.HOURS_PER_DAY return cls( pressure_bara=pressure_bara, diff --git a/src/libecalc/process/pump/pump.py b/src/libecalc/process/pump/pump.py index 4f576d414a..262d892512 100644 --- a/src/libecalc/process/pump/pump.py +++ b/src/libecalc/process/pump/pump.py @@ -32,8 +32,6 @@ class PumpFailureStatus(StrEnum): - """Feasibility outcome of a pump evaluation.""" - NO_FAILURE = "NO_FAILURE" ABOVE_MAXIMUM_PUMP_RATE = "ABOVE_MAXIMUM_PUMP_RATE" ABOVE_MAXIMUM_HEAD_AT_RATE = "ABOVE_MAXIMUM_HEAD_AT_RATE" @@ -41,19 +39,22 @@ class PumpFailureStatus(StrEnum): @value_object -class PumpResult: +class PumpEvaluationResult: """Result of a single pump evaluation. Attributes: inlet_stream: The liquid stream at suction conditions the pump was evaluated for (carries suction pressure, density and requested rate). - power_mw: Shaft power demand [MW]. + shaft_power_mw: Shaft power [MW]. + efficiency: Pump efficiency at the operating point, ``None`` when not running. + specific_shaft_work_joule_per_kg: Shaft work per unit mass at the operating point [J/kg], + ``None`` when not running. required_head_joule_per_kg: Head implied by the suction and required discharge pressures, ``(p_d - p_s) / rho`` [J/kg]. operational_head_joule_per_kg: Actual head the pump operates at [J/kg], after min-head choking. It exceeds the required head when the pump cannot deliver less head than its (minimum-speed) curve at the operating rate; otherwise it equals the required head. - operational_volumetric_rate_m3_per_hour: Actual volumetric rate the pump operates at + operational_volumetric_rate_m3_per_hour: Volumetric rate the pump operates at [m3/h], i.e. the requested rate raised to the minimum flow when recirculating. recirculation_rate_m3_per_hour: Internal recirculation [m3/h] to maintain the minimum flow = operational rate minus requested rate; zero when not recirculating. @@ -69,7 +70,9 @@ class PumpResult: """ inlet_stream: LiquidStream - power_mw: float + shaft_power_mw: float + efficiency: float | None + specific_shaft_work_joule_per_kg: float | None required_head_joule_per_kg: float operational_head_joule_per_kg: float operational_volumetric_rate_m3_per_hour: float @@ -99,21 +102,24 @@ class Pump(Entity[ProcessUnitId], LiquidStreamPropagator): Args: pump_chart: Chart data (rate/head/efficiency curves). A single curve gives a single-speed pump; multiple curves give a variable-speed pump. - minimum_flow_rate_m3_per_hour: Required minimum continuous flow [actual m3/h]. A fixed + minimum_flow_rate_m3_per_hour: Required minimum flow [m3/h]. A fixed vertical line in the rate-head plane; the operating rate is recirculated up to it. - Must be at least the chart's minimum rate. + Must be at least the chart's minimum rate. Defaults to the chart's minimum rate + when not provided. process_unit_id: Identity used to reference the pump; generated when not provided. """ def __init__( self, pump_chart: ChartData, - minimum_flow_rate_m3_per_hour: float, + minimum_flow_rate_m3_per_hour: float | None = None, process_unit_id: ProcessUnitId | None = None, ): self._id: Final[ProcessUnitId] = process_unit_id or Pump._create_id() - self._pump_chart = Chart(pump_chart) + self._pump_chart: Chart = Chart(pump_chart) self._validate_pump_chart_efficiency() + if minimum_flow_rate_m3_per_hour is None: + minimum_flow_rate_m3_per_hour = self._pump_chart.minimum_rate if minimum_flow_rate_m3_per_hour < self._pump_chart.minimum_rate: raise EcalcValidationException( f"Minimum flow rate ({minimum_flow_rate_m3_per_hour} m3/h) cannot be below the " @@ -136,7 +142,7 @@ def pump_chart(self) -> Chart: @property def minimum_flow_rate_m3_per_hour(self) -> float: - """The pump's minimum continuous flow [actual m3/h] - the fixed vertical line in the + """The pump's minimum flow [m3/h] - the fixed vertical line in the rate-head plane, for plotting the min-flow line on the chart.""" return self._minimum_flow_rate_m3_per_hour @@ -148,7 +154,7 @@ def set_discharge_pressure(self, discharge_pressure_bara: float) -> None: def propagate_stream(self, inlet_stream: LiquidStream) -> LiquidStream: """Propagate the inlet stream to the pump's delivered outlet stream. - The delivered stream is at the requested (demand) rate - recirculation is internal - at the + The delivered stream is at the requested rate - recirculation is internal - at the operational discharge pressure. For the full evaluation (power, heads, speed, feasibility), call ``evaluate``; it is closed-form and deterministic, so it reproduces this outlet exactly. """ @@ -157,7 +163,7 @@ def propagate_stream(self, inlet_stream: LiquidStream) -> LiquidStream: result = self.evaluate(inlet_stream, self._discharge_pressure_bara) return inlet_stream.with_pressure(result.operational_discharge_pressure_bara) - def evaluate(self, inlet_stream: LiquidStream, discharge_pressure_bara: float) -> PumpResult: + def evaluate(self, inlet_stream: LiquidStream, discharge_pressure_bara: float) -> PumpEvaluationResult: """Evaluate the pump for a given inlet liquid stream and required discharge pressure. Points that fall outside the chart envelope still produce a power value but are flagged via @@ -178,9 +184,11 @@ def evaluate(self, inlet_stream: LiquidStream, discharge_pressure_bara: float) - if rate_m3_per_hour <= 0: # Pump not running: no head produced, so the operational discharge equals the suction # pressure. The required side still reflects the requested duty. - return PumpResult( + return PumpEvaluationResult( inlet_stream=inlet_stream, - power_mw=0.0, + shaft_power_mw=0.0, + efficiency=None, + specific_shaft_work_joule_per_kg=None, required_head_joule_per_kg=required_head, operational_head_joule_per_kg=0.0, operational_volumetric_rate_m3_per_hour=0.0, @@ -205,37 +213,39 @@ def evaluate(self, inlet_stream: LiquidStream, discharge_pressure_bara: float) - minimum_head_at_rate = float(self._pump_chart.minimum_head_as_function_of_rate(operating_rate_m3_per_hour)) operational_head = max(required_head, minimum_head_at_rate) - maximum_head_at_rate = float(self._pump_chart.maximum_head_as_function_of_rate(operating_rate_m3_per_hour)) - - failure_status = self._determine_failure_status( - head=operational_head, - maximum_head_at_rate=maximum_head_at_rate, + efficiency = self._efficiency( rate_m3_per_hour=operating_rate_m3_per_hour, + head_joule_per_kg=operational_head, ) - - efficiency = self._efficiency(rate_m3_per_hour=operating_rate_m3_per_hour, head=operational_head) - power = self._calculate_power( - density=density, + speed_rpm = self._speed_at_operating_point( + rate_m3_per_hour=operating_rate_m3_per_hour, head_joule_per_kg=operational_head, - rate=operating_rate_m3_per_hour, - efficiency=efficiency, ) + specific_shaft_work = operational_head / efficiency + operating_mass_rate_kg_per_h = operating_rate_m3_per_hour * density + shaft_power_mw = operating_mass_rate_kg_per_h * specific_shaft_work / 3600.0 / 1_000_000.0 + operational_discharge_pressure_bara = inlet_stream.pressure_bara + Unit.PASCAL.to(Unit.BARA)( + operational_head * density + ) + maximum_head_at_rate = float(self._pump_chart.maximum_head_as_function_of_rate(operating_rate_m3_per_hour)) - return PumpResult( + return PumpEvaluationResult( inlet_stream=inlet_stream, - power_mw=power, + shaft_power_mw=shaft_power_mw, + efficiency=efficiency, + specific_shaft_work_joule_per_kg=specific_shaft_work, required_head_joule_per_kg=required_head, operational_head_joule_per_kg=operational_head, operational_volumetric_rate_m3_per_hour=operating_rate_m3_per_hour, recirculation_rate_m3_per_hour=operating_rate_m3_per_hour - rate_m3_per_hour, required_discharge_pressure_bara=discharge_pressure_bara, - operational_discharge_pressure_bara=self._discharge_pressure( - suction_pressure=inlet_stream.pressure_bara, + operational_discharge_pressure_bara=operational_discharge_pressure_bara, + speed_rpm=speed_rpm, + failure_status=self._determine_failure_status( head_joule_per_kg=operational_head, - density=density, + maximum_head_at_rate=maximum_head_at_rate, + rate_m3_per_hour=operating_rate_m3_per_hour, ), - speed_rpm=self._speed_at_operating_point(operating_rate_m3_per_hour, operational_head), - failure_status=failure_status, process_unit_id=self._id, ) @@ -248,80 +258,61 @@ def get_max_volumetric_rate_m3_per_day( ) return float(self._pump_chart.maximum_rate_as_function_of_head(head) * UnitConstants.HOURS_PER_DAY) - def _speed_at_operating_point(self, rate_m3_per_hour: float, head_joule_per_kg: float) -> float: - """Speed [rpm] of the speed curve through the operating point (rate, head). + @staticmethod + def _validate_discharge_pressure(discharge_pressure_bara: float) -> None: + if discharge_pressure_bara <= 0: + raise NonPositivePressureException(discharge_pressure_bara) - Single-speed chart: the single curve's speed. Variable-speed: linear interpolation between - the adjacent speed curves whose head at the operating rate brackets the operating head. - """ - curves = sorted(self._pump_chart.curves, key=lambda c: c.speed_rpm) + @staticmethod + def _calculate_head(suction_pressure: float, discharge_pressure: float, density: float) -> float: + """Head in joule per kg [J/kg].""" + return Unit.BARA.to(Unit.PASCAL)(discharge_pressure - suction_pressure) / density + + def _efficiency(self, rate_m3_per_hour: float, head_joule_per_kg: float) -> float: + if self._pump_chart.is_100_percent_efficient: + return 1.0 + return float( + self._pump_chart.efficiency_as_function_of_rate_and_head( + rates=np.asarray([rate_m3_per_hour]), + heads=np.asarray([head_joule_per_kg]), + )[0] + ) + + def _speed_at_operating_point(self, rate_m3_per_hour: float, head_joule_per_kg: float) -> float: + curves = sorted(self._pump_chart.curves, key=lambda curve: curve.speed_rpm) if len(curves) == 1: return float(curves[0].speed_rpm) - speeds = [float(c.speed_rpm) for c in curves] - heads = [float(c.head_as_function_of_rate(rate_m3_per_hour)) for c in curves] + speeds = [float(curve.speed_rpm) for curve in curves] + heads = [float(curve.head_as_function_of_rate(rate_m3_per_hour)) for curve in curves] if head_joule_per_kg <= heads[0]: return speeds[0] if head_joule_per_kg >= heads[-1]: return speeds[-1] - for i in range(len(curves) - 1): - head_low, head_high = heads[i], heads[i + 1] + for index in range(len(curves) - 1): + head_low, head_high = heads[index], heads[index + 1] if head_low <= head_joule_per_kg <= head_high: fraction = (head_joule_per_kg - head_low) / (head_high - head_low) if head_high != head_low else 0.0 - return speeds[i] + fraction * (speeds[i + 1] - speeds[i]) + return speeds[index] + fraction * (speeds[index + 1] - speeds[index]) return speeds[-1] - def _efficiency(self, rate_m3_per_hour: float, head: float) -> float: - if self._pump_chart.is_100_percent_efficient: - return 1.0 - return float( - self._pump_chart.efficiency_as_function_of_rate_and_head( - rates=np.asarray([rate_m3_per_hour]), - heads=np.asarray([head]), - )[0] - ) - def _determine_failure_status( - self, head: float, maximum_head_at_rate: float, rate_m3_per_hour: float + self, + head_joule_per_kg: float, + maximum_head_at_rate: float, + rate_m3_per_hour: float, ) -> PumpFailureStatus: - above_max_head = head > maximum_head_at_rate - above_max_rate = rate_m3_per_hour > float(self._pump_chart.maximum_rate) + above_maximum_head = head_joule_per_kg > maximum_head_at_rate + above_maximum_rate = rate_m3_per_hour > float(self._pump_chart.maximum_rate) - if above_max_head and above_max_rate: + if above_maximum_head and above_maximum_rate: return PumpFailureStatus.ABOVE_MAXIMUM_PUMP_RATE_AND_MAXIMUM_HEAD_AT_RATE - if above_max_head: + if above_maximum_head: return PumpFailureStatus.ABOVE_MAXIMUM_HEAD_AT_RATE - if above_max_rate: + if above_maximum_rate: return PumpFailureStatus.ABOVE_MAXIMUM_PUMP_RATE return PumpFailureStatus.NO_FAILURE - @staticmethod - def _validate_discharge_pressure(discharge_pressure_bara: float) -> None: - if discharge_pressure_bara <= 0: - raise NonPositivePressureException(discharge_pressure_bara) - def _validate_pump_chart_efficiency(self) -> None: if any(efficiency <= 0 for curve in self._pump_chart.curves for efficiency in curve.efficiency): raise EcalcValidationException("Pump efficiency must be greater than zero.") - - @staticmethod - def _calculate_head(suction_pressure: float, discharge_pressure: float, density: float) -> float: - """Head in joule per kg [J/kg].""" - return Unit.BARA.to(Unit.PASCAL)(discharge_pressure - suction_pressure) / density - - @staticmethod - def _discharge_pressure(suction_pressure: float, head_joule_per_kg: float, density: float) -> float: - """Discharge pressure [bara] corresponding to a head at the given suction and density.""" - return suction_pressure + Unit.PASCAL.to(Unit.BARA)(head_joule_per_kg * density) - - @staticmethod - def _calculate_power(density: float, head_joule_per_kg: float, efficiency: float, rate: float) -> float: - """Pump power [MW] from density, head [J/kg], actual rate [m3/h] and efficiency.""" - return float( - density - * head_joule_per_kg - * rate - / UnitConstants.SECONDS_PER_HOUR - / UnitConstants.WATT_PER_MEGAWATT - / efficiency - ) diff --git a/src/libecalc/process/pump/pump_process_simulation.py b/src/libecalc/process/pump/pump_process_simulation.py new file mode 100644 index 0000000000..c9baed29af --- /dev/null +++ b/src/libecalc/process/pump/pump_process_simulation.py @@ -0,0 +1,255 @@ +"""Closed-form liquid pump process graph. + +A pump process simulation is a fixed, serial liquid topology built around a single closed-form +``Pump``: + + inlet -> recirc mixer -> pump -> recirc splitter -> choke -> outlet + ^ | + +--------- recycle ------+ + +It mirrors the compressor process-graph contract (typed units, connections, streams per +connection, a recirculation loop) so a consuming application can persist, reconstruct and +visualise it with a pattern analogous to the compressor - while staying liquid-typed and +solver-free. The mixer, splitter and choke are the faithful representation of minimum-flow +recirculation and downstream choking (identical in shape to a compressor anti-surge recycle and +downstream choke); their streams follow directly from the closed-form pump result, so there is no +iteration. +""" + +from collections.abc import Mapping, Sequence +from enum import StrEnum +from types import MappingProxyType +from typing import Final, NewType, Self +from uuid import UUID + +from libecalc.common.ddd import value_object +from libecalc.common.ddd.entity import Entity +from libecalc.common.utils.ecalc_uuid import ecalc_id_generator +from libecalc.domain.process.value_objects.chart.chart import Chart +from libecalc.process.process_pipeline.process_pipeline import ( + ProcessUnitConnection, + ProcessUnitConnectionId, +) +from libecalc.process.process_pipeline.process_unit import ProcessUnitId +from libecalc.process.pump.liquid_stream import LiquidStream +from libecalc.process.pump.pump import Pump, PumpEvaluationResult + +PumpProcessSimulationId = NewType("PumpProcessSimulationId", UUID) +LiquidRecirculationLoopId = NewType("LiquidRecirculationLoopId", UUID) + + +class LiquidProcessUnitType(StrEnum): + INLET = "INLET" + DIRECT_MIXER = "DIRECT_MIXER" + PUMP = "PUMP" + DIRECT_SPLITTER = "DIRECT_SPLITTER" + CHOKE = "CHOKE" + OUTLET = "OUTLET" + + +class LiquidProcessUnit(Entity[ProcessUnitId]): + """A typed node in the pump process graph, identified for persistence and visualisation.""" + + def __init__( + self, + unit_type: LiquidProcessUnitType, + name: str, + id: ProcessUnitId | None = None, + ): + self._id: Final[ProcessUnitId] = id or LiquidProcessUnit._create_id() + self._unit_type = unit_type + self._name = name + + def get_id(self) -> ProcessUnitId: + return self._id + + @property + def unit_type(self) -> LiquidProcessUnitType: + return self._unit_type + + @property + def name(self) -> str: + return self._name + + @classmethod + def _create_id(cls: type[Self]) -> ProcessUnitId: + return ProcessUnitId(ecalc_id_generator()) + + +class LiquidRecirculationLoop(Entity[LiquidRecirculationLoopId]): + """Minimum-flow recirculation routing recycle from the splitter back to the mixer. + + Mirrors the compressor recirculation-loop topology (``splitter_id`` + ``mixer_id``); like the + gas recirculation loop it is its own concept with its own identity, not a serial connection. + The recycle rate is not attributed to a node: it is a scalar on + ``PumpEvaluationResult.recirculation_rate_m3_per_hour`` and is also implied by the connection + streams (the operating-rate step between the ``inlet -> mixer`` and ``mixer -> pump`` edges). + """ + + def __init__( + self, + splitter_id: ProcessUnitId, + mixer_id: ProcessUnitId, + id: LiquidRecirculationLoopId | None = None, + ): + self._id: Final[LiquidRecirculationLoopId] = id or LiquidRecirculationLoop._create_id() + self._splitter_id = splitter_id + self._mixer_id = mixer_id + + def get_id(self) -> LiquidRecirculationLoopId: + return self._id + + @property + def splitter_id(self) -> ProcessUnitId: + return self._splitter_id + + @property + def mixer_id(self) -> ProcessUnitId: + return self._mixer_id + + @classmethod + def _create_id(cls: type[Self]) -> LiquidRecirculationLoopId: + return LiquidRecirculationLoopId(ecalc_id_generator()) + + +@value_object +class PumpOperatingInput: + """The physical input for a single pump evaluation. + + ``inlet_stream`` always carries a physical inlet (positive pressure and density); its rate may + be zero, which the pump treats as not running (zero power, outlet pressure equal to the inlet + pressure). ``required_discharge_pressure_bara`` is the duty target; it is only meaningful while + the pump runs. This domain is time-agnostic: any period/time mapping is kept by the caller. + """ + + inlet_stream: LiquidStream + required_discharge_pressure_bara: float + + +@value_object +class PumpOperatingResult: + """Result of one evaluation: the pump operating result plus the stream on every connection. + + An evaluation where the pump does not run (zero rate) still produces a result - zero power, the + outlet pressure equal to the inlet pressure, and zero-flow streams on every connection. + """ + + pump_result: PumpEvaluationResult + connection_streams: Mapping[ProcessUnitConnectionId, LiquidStream] + + +class PumpProcessSimulation(Entity[PumpProcessSimulationId]): + """A closed-form pump process graph that evaluates itself period by period. + + The graph structure (units, connections, recirculation loop) is fixed and built once. Each + period is evaluated by the closed-form ``Pump`` and projected onto the connection streams; + there is no solver. + + Args: + pump: The closed-form pump (encapsulates the chart, minimum flow and its id). + name: Human-readable name; also the name of the pump unit. + process_simulation_id: Identity of the simulation; generated when not provided. + """ + + def __init__( + self, + pump: Pump, + name: str = "pump", + process_simulation_id: PumpProcessSimulationId | None = None, + ): + self._id: Final[PumpProcessSimulationId] = process_simulation_id or PumpProcessSimulation._create_id() + self._name = name + self._pump = pump + + inlet = LiquidProcessUnit(LiquidProcessUnitType.INLET, "inlet") + mixer = LiquidProcessUnit(LiquidProcessUnitType.DIRECT_MIXER, "recirculation_mixer") + pump_unit = LiquidProcessUnit(LiquidProcessUnitType.PUMP, name, id=self._pump.get_id()) + splitter = LiquidProcessUnit(LiquidProcessUnitType.DIRECT_SPLITTER, "recirculation_splitter") + choke = LiquidProcessUnit(LiquidProcessUnitType.CHOKE, "choke") + outlet = LiquidProcessUnit(LiquidProcessUnitType.OUTLET, "outlet") + self._units: Final[tuple[LiquidProcessUnit, ...]] = (inlet, mixer, pump_unit, splitter, choke, outlet) + + # Serial connections, in flow order; connection[i] carries stream[i] (see _project_streams). + self._connections: Final[tuple[ProcessUnitConnection, ...]] = ( + ProcessUnitConnection(from_process_unit_id=inlet.get_id(), to_process_unit_id=mixer.get_id()), + ProcessUnitConnection(from_process_unit_id=mixer.get_id(), to_process_unit_id=pump_unit.get_id()), + ProcessUnitConnection(from_process_unit_id=pump_unit.get_id(), to_process_unit_id=splitter.get_id()), + ProcessUnitConnection(from_process_unit_id=splitter.get_id(), to_process_unit_id=choke.get_id()), + ProcessUnitConnection(from_process_unit_id=choke.get_id(), to_process_unit_id=outlet.get_id()), + ) + self._recirculation_loop: Final[LiquidRecirculationLoop] = LiquidRecirculationLoop( + splitter_id=splitter.get_id(), + mixer_id=mixer.get_id(), + ) + + def get_id(self) -> PumpProcessSimulationId: + return self._id + + def get_name(self) -> str: + return self._name + + def get_process_units(self) -> Sequence[LiquidProcessUnit]: + return self._units + + def get_process_unit_connections(self) -> Sequence[ProcessUnitConnection]: + return self._connections + + def get_recirculation_loop(self) -> LiquidRecirculationLoop: + return self._recirculation_loop + + def get_pump_chart(self) -> Chart: + """The pump node's chart, the static data that defines the pump unit (curves, envelope).""" + return self._pump.pump_chart + + def get_minimum_flow_rate_m3_per_hour(self) -> float: + """The pump node's minimum flow [m3/h], the fixed recirculation floor.""" + return self._pump.minimum_flow_rate_m3_per_hour + + @classmethod + def _create_id(cls: type[Self]) -> PumpProcessSimulationId: + return PumpProcessSimulationId(ecalc_id_generator()) + + def evaluate(self, operating_inputs: Sequence[PumpOperatingInput]) -> tuple[PumpOperatingResult, ...]: + return tuple(self._evaluate_one(operating_input) for operating_input in operating_inputs) + + def _evaluate_one(self, operating_input: PumpOperatingInput) -> PumpOperatingResult: + pump_result = self._pump.evaluate( + inlet_stream=operating_input.inlet_stream, + discharge_pressure_bara=operating_input.required_discharge_pressure_bara, + ) + return PumpOperatingResult( + pump_result=pump_result, + connection_streams=self._project_streams(pump_result), + ) + + def _project_streams(self, pump_result: PumpEvaluationResult) -> Mapping[ProcessUnitConnectionId, LiquidStream]: + """Project the closed-form pump result onto the stream of each serial connection. + + Recirculation raises the operating rate between the mixer and the splitter; the requested + rate is restored downstream. The choke drops the operating discharge pressure to + the required discharge pressure when the pump over-delivers head. + """ + requested_stream = pump_result.inlet_stream + operating_stream_at_suction = requested_stream.with_mass_rate( + pump_result.operational_volumetric_rate_m3_per_hour * requested_stream.density_kg_per_m3 + ) + operating_stream_after_pump = operating_stream_at_suction.with_pressure( + pump_result.operational_discharge_pressure_bara + ) + delivered_stream_before_choke = requested_stream.with_pressure(pump_result.operational_discharge_pressure_bara) + delivered_pressure_bara = min( + pump_result.operational_discharge_pressure_bara, + pump_result.required_discharge_pressure_bara, + ) + delivered_stream_after_choke = requested_stream.with_pressure(delivered_pressure_bara) + + streams = ( + requested_stream, + operating_stream_at_suction, + operating_stream_after_pump, + delivered_stream_before_choke, + delivered_stream_after_choke, + ) + return MappingProxyType( + {connection.get_id(): stream for connection, stream in zip(self._connections, streams, strict=True)} + ) diff --git a/tests/libecalc/presentation/yaml/mappers/test_pump_process_simulation_mapper.py b/tests/libecalc/presentation/yaml/mappers/test_pump_process_simulation_mapper.py new file mode 100644 index 0000000000..5c7813a9ed --- /dev/null +++ b/tests/libecalc/presentation/yaml/mappers/test_pump_process_simulation_mapper.py @@ -0,0 +1,97 @@ +from io import StringIO + +import pytest + +from ecalc_cli.infrastructure.file_resource_service import FileResourceService +from libecalc.common.errors.ecalc_validation_error import EcalcValidationException +from libecalc.presentation.yaml.model import YamlModel +from libecalc.presentation.yaml.yaml_entities import ResourceStream + + +def _pump_yaml_model( + simple_yaml, configuration_service_factory, *, rate=None, pressure="10", density="1010", discharge="200" +): + rate_expression = rate if rate is not None else "$var.produced_water_reinjection_total_system_rate_m3_per_day" + yaml_text = ( + simple_yaml.main_file.read() + + f""" + +PUMP_PROCESS_SIMULATIONS: + - TYPE: PUMP + NAME: produced_water_reinjection + PUMP_MODEL: + CHART: pump_chart + INLET: + RATE: {rate_expression} + PRESSURE: {pressure} + DENSITY: {density} + REQUIRED_DISCHARGE_PRESSURE: {discharge} +""" + ) + configuration = configuration_service_factory( + ResourceStream(name="pump_process.yaml", stream=StringIO(yaml_text)) + ).get_configuration() + return YamlModel( + configuration=configuration, + resource_service=FileResourceService( + working_directory=simple_yaml.main_file_path.parent, + configuration=configuration, + ), + ) + + +def test_pump_process_simulation_maps_and_evaluates(simple_yaml, configuration_service_factory): + yaml_model = _pump_yaml_model(simple_yaml, configuration_service_factory) + + mapped_simulations = yaml_model.get_pump_process_simulations() + + assert len(mapped_simulations) == 1 + simulation, operating_inputs, periods = mapped_simulations[0] + assert simulation.get_name() == "produced_water_reinjection" + + results = simulation.evaluate(operating_inputs) + + assert len(results) == len(operating_inputs) == len(periods) + # Every evaluation carries a stream on every serial connection (off periods carry zero-flow streams). + all_connections = {connection.get_id() for connection in simulation.get_process_unit_connections()} + for result in results: + assert set(result.connection_streams) == all_connections + + +@pytest.mark.parametrize( + "rate, pressure, density, expected_subject", + [ + ("100", "0", "1010", "suction pressure"), # running period, invalid suction + ("0", "0", "1010", "suction pressure"), # suction is required even when the pump is off + ("100", "10", "0", "inlet density"), # density is always required + ], +) +def test_pump_requires_positive_suction_and_density( + simple_yaml, configuration_service_factory, rate, pressure, density, expected_subject +): + yaml_model = _pump_yaml_model( + simple_yaml, configuration_service_factory, rate=rate, pressure=pressure, density=density + ) + + with pytest.raises(EcalcValidationException, match=f"Pump {expected_subject} .* must be greater than 0"): + yaml_model.get_pump_process_simulations() + + +def test_pump_rejects_non_positive_discharge_in_running_periods(simple_yaml, configuration_service_factory): + yaml_model = _pump_yaml_model(simple_yaml, configuration_service_factory, rate="100", discharge="0") + + with pytest.raises(EcalcValidationException, match="Pump required discharge pressure .* must be greater than 0"): + yaml_model.get_pump_process_simulations() + + +def test_pump_off_periods_allow_invalid_discharge(simple_yaml, configuration_service_factory): + # Rate 0 -> pump off. A zero required discharge is tolerated; the outlet takes the inlet pressure. + yaml_model = _pump_yaml_model(simple_yaml, configuration_service_factory, rate="0", pressure="3", discharge="0") + + simulation, operating_inputs, _periods = yaml_model.get_pump_process_simulations()[0] + results = simulation.evaluate(operating_inputs) + + assert len(results) > 0 + for result in results: + assert result.pump_result.shaft_power_mw == 0.0 + assert result.pump_result.operational_discharge_pressure_bara == pytest.approx(3.0) diff --git a/tests/libecalc/process/pump/test_pump.py b/tests/libecalc/process/pump/test_pump.py index 4bd289499c..2074dd2e8e 100644 --- a/tests/libecalc/process/pump/test_pump.py +++ b/tests/libecalc/process/pump/test_pump.py @@ -89,7 +89,7 @@ def test_power_matches_legacy(request, chart_name, rate_m3h, suction, discharge) rate=rate_m3h * 24.0, suction_pressure=suction, discharge_pressure=discharge, fluid_density=DENSITY ) result = pump.evaluate(_inlet(rate_m3h, suction), discharge_pressure_bara=discharge) - assert result.power_mw == pytest.approx(legacy_power) + assert result.shaft_power_mw == pytest.approx(legacy_power) def test_recirculation_up_to_minimum_flow(single_speed_chart): @@ -97,6 +97,14 @@ def test_recirculation_up_to_minimum_flow(single_speed_chart): result = pump.evaluate(_inlet(400, suction_pressure=5.0), discharge_pressure_bara=90.0) assert result.operational_volumetric_rate_m3_per_hour == pytest.approx(600.0) assert result.recirculation_rate_m3_per_hour == pytest.approx(200.0) + assert result.efficiency is not None + assert result.specific_shaft_work_joule_per_kg is not None + operating_mass_rate_kg_per_h = result.operational_volumetric_rate_m3_per_hour * DENSITY + specific_shaft_work_joule_per_kg = result.specific_shaft_work_joule_per_kg + assert specific_shaft_work_joule_per_kg is not None + assert result.shaft_power_mw == pytest.approx( + operating_mass_rate_kg_per_h * specific_shaft_work_joule_per_kg / 3600.0 / 1_000_000.0 + ) def test_over_delivery_exposes_operating_vs_required_and_choke(single_speed_chart): @@ -118,6 +126,13 @@ def test_variable_speed_in_band_sits_on_target_with_interpolated_speed(variable_ assert result.choke_pressure_drop_bara == pytest.approx(0.0) assert result.speed_rpm is not None assert 2650.0 < result.speed_rpm < 3425.0 + assert result.efficiency is not None + specific_shaft_work_joule_per_kg = result.specific_shaft_work_joule_per_kg + assert specific_shaft_work_joule_per_kg is not None + assert specific_shaft_work_joule_per_kg == pytest.approx(result.operational_head_joule_per_kg / result.efficiency) + assert result.shaft_power_mw == pytest.approx( + result.inlet_stream.mass_rate_kg_per_h * specific_shaft_work_joule_per_kg / 3600.0 / 1_000_000.0 + ) @pytest.mark.parametrize( @@ -139,7 +154,9 @@ def test_feasibility_status(request, chart_name, rate_m3h, discharge, expected_s def test_not_running_when_rate_is_zero(single_speed_chart): pump = Pump(single_speed_chart, minimum_flow_rate_m3_per_hour=CHART_MIN_FLOW) result = pump.evaluate(_inlet(0.0, suction_pressure=5.0), discharge_pressure_bara=100.0) - assert result.power_mw == 0.0 + assert result.shaft_power_mw == 0.0 + assert result.efficiency is None + assert result.specific_shaft_work_joule_per_kg is None assert result.speed_rpm is None assert result.is_valid @@ -174,7 +191,7 @@ def test_rejects_zero_efficiency_pump_chart(chart_data_factory): Pump(chart, minimum_flow_rate_m3_per_hour=100.0) -def test_propagate_stream_delivers_operating_pressure_at_demand_rate(single_speed_chart): +def test_propagate_stream_delivers_operating_pressure_at_requested_rate(single_speed_chart): inlet = _inlet(600, suction_pressure=5.0) pump = Pump(single_speed_chart, minimum_flow_rate_m3_per_hour=CHART_MIN_FLOW) pump.set_discharge_pressure(50.0) @@ -184,12 +201,6 @@ def test_propagate_stream_delivers_operating_pressure_at_demand_rate(single_spee assert outlet.mass_rate_kg_per_h == inlet.mass_rate_kg_per_h # recirculation is internal -def test_pump_identity(single_speed_chart): - provided_id = ProcessUnitId(ecalc_id_generator()) - assert Pump(single_speed_chart, CHART_MIN_FLOW, process_unit_id=provided_id).get_id() == provided_id - assert Pump(single_speed_chart, CHART_MIN_FLOW).get_id() != Pump(single_speed_chart, CHART_MIN_FLOW).get_id() - - def test_result_carries_process_unit_id(single_speed_chart): provided_id = ProcessUnitId(ecalc_id_generator()) pump = Pump(single_speed_chart, CHART_MIN_FLOW, process_unit_id=provided_id) diff --git a/tests/libecalc/process/pump/test_pump_process_simulation.py b/tests/libecalc/process/pump/test_pump_process_simulation.py new file mode 100644 index 0000000000..559942501e --- /dev/null +++ b/tests/libecalc/process/pump/test_pump_process_simulation.py @@ -0,0 +1,139 @@ +import pytest + +from libecalc.domain.process.value_objects.chart.chart import ChartCurve +from libecalc.process.pump.liquid_stream import LiquidStream +from libecalc.process.pump.pump import Pump +from libecalc.process.pump.pump_process_simulation import ( + LiquidProcessUnitType, + PumpOperatingInput, + PumpProcessSimulation, +) + +DENSITY = 1021.0 +CHART_MIN_FLOW = 277.0 +MINIMUM_FLOW = 400.0 + + +@pytest.fixture +def single_speed_chart(chart_data_factory): + return chart_data_factory.from_curves( + curves=[ + ChartCurve( + speed_rpm=1, + rate_actual_m3_hour=[277.0, 524.0, 666.0, 832.0, 927.0], + polytropic_head_joule_per_kg=[10415.277, 9845.316, 9254.754, 8308.089, 7605.693], + efficiency_fraction=[0.4759, 0.6426, 0.6871, 0.7052, 0.6908], + ) + ] + ) + + +@pytest.fixture +def simulation(single_speed_chart): + return PumpProcessSimulation( + pump=Pump(pump_chart=single_speed_chart, minimum_flow_rate_m3_per_hour=MINIMUM_FLOW), + name="water_injection", + ) + + +def _operating_input(rate_m3_per_hour, *, discharge=100.0): + return PumpOperatingInput( + inlet_stream=LiquidStream.from_volumetric_rate( + volumetric_rate_m3_per_day=rate_m3_per_hour * 24.0, + pressure_bara=5.0, + density_kg_per_m3=DENSITY, + ), + required_discharge_pressure_bara=discharge, + ) + + +def test_exposes_pump_static_data(simulation, single_speed_chart): + # A consumer persists/renders the pump node from the simulation's static data. + assert simulation.get_pump_chart().chart_data is single_speed_chart + assert simulation.get_minimum_flow_rate_m3_per_hour() == MINIMUM_FLOW + + +def test_graph_structure(simulation): + units = simulation.get_process_units() + assert [unit.unit_type for unit in units] == [ + LiquidProcessUnitType.INLET, + LiquidProcessUnitType.DIRECT_MIXER, + LiquidProcessUnitType.PUMP, + LiquidProcessUnitType.DIRECT_SPLITTER, + LiquidProcessUnitType.CHOKE, + LiquidProcessUnitType.OUTLET, + ] + + # Connections form a serial chain: each connection starts at the previous unit's end. + connections = simulation.get_process_unit_connections() + assert len(connections) == 5 + for previous, current in zip(connections, connections[1:], strict=False): + assert previous.get_to_process_unit_id() == current.get_from_process_unit_id() + assert connections[0].get_from_process_unit_id() == units[0].get_id() + assert connections[-1].get_to_process_unit_id() == units[-1].get_id() + + # The recirculation loop routes the splitter back to the mixer. + loop = simulation.get_recirculation_loop() + splitter = next(unit for unit in units if unit.unit_type is LiquidProcessUnitType.DIRECT_SPLITTER) + mixer = next(unit for unit in units if unit.unit_type is LiquidProcessUnitType.DIRECT_MIXER) + assert loop.splitter_id == splitter.get_id() + assert loop.mixer_id == mixer.get_id() + + +def test_streams_projected_onto_every_connection(simulation): + (result,) = simulation.evaluate([_operating_input(600.0)]) + + connections = simulation.get_process_unit_connections() + assert set(result.connection_streams) == {connection.get_id() for connection in connections} + + inlet_to_mixer = result.connection_streams[connections[0].get_id()] + mixer_to_pump = result.connection_streams[connections[1].get_id()] + pump_to_splitter = result.connection_streams[connections[2].get_id()] + choke_to_outlet = result.connection_streams[connections[4].get_id()] + + pump_result = result.pump_result + # Requested rate at suction on the inlet edge; operating rate at suction into the pump. + assert inlet_to_mixer.volumetric_rate_m3_per_hour == pytest.approx(600.0) + assert mixer_to_pump.volumetric_rate_m3_per_hour == pytest.approx( + pump_result.operational_volumetric_rate_m3_per_hour + ) + # The pump raises pressure; the choke drops it back toward the required discharge. + assert pump_to_splitter.pressure_bara == pytest.approx(pump_result.operational_discharge_pressure_bara) + assert choke_to_outlet.pressure_bara <= pump_to_splitter.pressure_bara + assert choke_to_outlet.pressure_bara == pytest.approx( + min(pump_result.operational_discharge_pressure_bara, pump_result.required_discharge_pressure_bara) + ) + + +def test_recirculation_raises_operating_flow_between_mixer_and_splitter(simulation): + # Requested rate below the minimum flow -> the pump recirculates up to the minimum. + (result,) = simulation.evaluate([_operating_input(300.0)]) + + connections = simulation.get_process_unit_connections() + inlet_to_mixer = result.connection_streams[connections[0].get_id()] + mixer_to_pump = result.connection_streams[connections[1].get_id()] + + assert result.pump_result.recirculation_rate_m3_per_hour > 0.0 + assert mixer_to_pump.mass_rate_kg_per_h > inlet_to_mixer.mass_rate_kg_per_h + + +def test_zero_rate_produces_zero_result_at_inlet_pressure(simulation): + # Pump off (rate 0): still a full result - zero power, outlet = inlet pressure, zero-flow streams. + (result,) = simulation.evaluate([_operating_input(0.0, discharge=200.0)]) + + assert result.pump_result.shaft_power_mw == 0.0 + assert result.pump_result.operational_discharge_pressure_bara == pytest.approx(5.0) + + connections = simulation.get_process_unit_connections() + assert set(result.connection_streams) == {connection.get_id() for connection in connections} + for stream in result.connection_streams.values(): + assert stream.mass_rate_kg_per_h == pytest.approx(0.0) + + +def test_evaluate_preserves_input_order(simulation): + # The domain is time-agnostic: results come back in the same order as the inputs. + results = simulation.evaluate([_operating_input(600.0), _operating_input(300.0), _operating_input(0.0)]) + assert len(results) == 3 + assert results[0].pump_result.recirculation_rate_m3_per_hour == pytest.approx(0.0) + assert results[1].pump_result.recirculation_rate_m3_per_hour > 0.0 + assert results[2].pump_result.shaft_power_mw == 0.0