processpi 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. processpi/__init__.py +1 -0
  2. processpi/calculations/__init__.py +3 -0
  3. processpi/calculations/base.py +55 -0
  4. processpi/calculations/engine.py +69 -0
  5. processpi/calculations/fluids/__init__.py +13 -0
  6. processpi/calculations/fluids/cool_brook_white.py +49 -0
  7. processpi/calculations/fluids/flow_type.py +37 -0
  8. processpi/calculations/fluids/optimium_pipe_dia.py +45 -0
  9. processpi/calculations/fluids/pressure_drop_darcy.py +25 -0
  10. processpi/calculations/fluids/pressure_drop_fanning.py +25 -0
  11. processpi/calculations/fluids/pump_power.py +34 -0
  12. processpi/calculations/fluids/reynolds_number.py +24 -0
  13. processpi/calculations/fluids/velocity.py +36 -0
  14. processpi/calculations/heat_transfer/__init__.py +4 -0
  15. processpi/calculations/heat_transfer/conduction_heat_loss.py +26 -0
  16. processpi/calculations/heat_transfer/convection_heat_loss.py +23 -0
  17. processpi/calculations/heat_transfer/heat_exchanger_area.py +26 -0
  18. processpi/calculations/mass_transfer/__init__.py +3 -0
  19. processpi/calculations/mass_transfer/distillation_stage_count.py +346 -0
  20. processpi/calculations/mass_transfer/drying_rate.py +132 -0
  21. processpi/calculations/reaction_engineering/__init__.py +4 -0
  22. processpi/calculations/reaction_engineering/catalyst_activity.py +109 -0
  23. processpi/calculations/reaction_engineering/reaction_rate.py +143 -0
  24. processpi/calculations/reaction_engineering/residence_time.py +80 -0
  25. processpi/calculations/thermodynamics/__init__.py +4 -0
  26. processpi/calculations/thermodynamics/enthalpy_change.py +23 -0
  27. processpi/calculations/thermodynamics/entropy_change.py +25 -0
  28. processpi/calculations/thermodynamics/heat_of_vaporization.py +20 -0
  29. processpi/components/__init__.py +5 -0
  30. processpi/components/aceticacid.py +19 -0
  31. processpi/components/acetone.py +20 -0
  32. processpi/components/acrylicacid.py +19 -0
  33. processpi/components/air.py +20 -0
  34. processpi/components/ammonia.py +19 -0
  35. processpi/components/base.py +85 -0
  36. processpi/components/benzene.py +40 -0
  37. processpi/components/benzoicacid.py +19 -0
  38. processpi/components/bromine.py +19 -0
  39. processpi/components/butane.py +19 -0
  40. processpi/components/carbondioxide.py +19 -0
  41. processpi/components/carbonmonoxide.py +17 -0
  42. processpi/components/carbontetrachloride.py +17 -0
  43. processpi/components/chlorine.py +17 -0
  44. processpi/components/chlorobenzene.py +17 -0
  45. processpi/components/chloroform.py +17 -0
  46. processpi/components/chloromethane.py +17 -0
  47. processpi/components/cyanogen.py +17 -0
  48. processpi/components/cyclohexene.py +17 -0
  49. processpi/components/ethane.py +17 -0
  50. processpi/components/ethanol.py +20 -0
  51. processpi/components/ethylacetate.py +17 -0
  52. processpi/components/methanol.py +20 -0
  53. processpi/components/toluene.py +22 -0
  54. processpi/components/water.py +24 -0
  55. processpi/pipelines/__init__.py +0 -0
  56. processpi/pipelines/base.py +76 -0
  57. processpi/pipelines/design_rules.py +91 -0
  58. processpi/pipelines/engine.py +718 -0
  59. processpi/pipelines/equipment.py +32 -0
  60. processpi/pipelines/fittings.py +76 -0
  61. processpi/pipelines/insulation.py +0 -0
  62. processpi/pipelines/materials.py +102 -0
  63. processpi/pipelines/network.py +277 -0
  64. processpi/pipelines/pipelineresults.py +95 -0
  65. processpi/pipelines/pipes.py +84 -0
  66. processpi/pipelines/pumps.py +83 -0
  67. processpi/pipelines/selection.py +89 -0
  68. processpi/pipelines/standards.py +279 -0
  69. processpi/pipelines/vessel.py +34 -0
  70. processpi/units/__init__.py +38 -0
  71. processpi/units/area.py +51 -0
  72. processpi/units/base.py +57 -0
  73. processpi/units/density.py +47 -0
  74. processpi/units/diameter.py +58 -0
  75. processpi/units/dimensionless.py +42 -0
  76. processpi/units/heat_flux.py +46 -0
  77. processpi/units/heat_of_vaporization.py +47 -0
  78. processpi/units/heat_transfer_coefficient.py +45 -0
  79. processpi/units/length.py +47 -0
  80. processpi/units/mass.py +50 -0
  81. processpi/units/mass_flowrate.py +50 -0
  82. processpi/units/pressure.py +57 -0
  83. processpi/units/specific_heat.py +47 -0
  84. processpi/units/strings.py +31 -0
  85. processpi/units/temperature.py +51 -0
  86. processpi/units/thermal_conductivity.py +45 -0
  87. processpi/units/thermal_resistance.py +46 -0
  88. processpi/units/velocity.py +45 -0
  89. processpi/units/viscosity.py +78 -0
  90. processpi/units/volume.py +50 -0
  91. processpi/units/volumetric_flowrate.py +52 -0
  92. processpi-0.0.0.dist-info/METADATA +5 -0
  93. processpi-0.0.0.dist-info/RECORD +96 -0
  94. processpi-0.0.0.dist-info/WHEEL +5 -0
  95. processpi-0.0.0.dist-info/licenses/LICENSE +21 -0
  96. processpi-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,718 @@
1
+ # processpi/pipelines/engine.py
2
+
3
+ from __future__ import annotations
4
+ from typing import Any, Dict, List, Optional, Tuple, Union
5
+
6
+ # Package-local imports expected in your project. Adjust if your paths differ.
7
+ from ..units import (
8
+ Diameter, Length, Pressure, Density, Viscosity, VolumetricFlowRate, Velocity
9
+ )
10
+ from .pipelineresults import PipelineResults # wrapper/DTO (assumed to exist in your project)
11
+ from ..components import Component # fluids with .density() / .viscosity() / optional .service_type
12
+ from ..calculations.fluids.optimium_pipe_dia import OptimumPipeDiameter
13
+ from ..pipelines.standards import get_recommended_velocity
14
+ from ..calculations.fluids import (
15
+ ReynoldsNumber,
16
+ ColebrookWhite,
17
+ PressureDropDarcy,
18
+ FluidVelocity,
19
+ )
20
+ from .pipes import Pipe
21
+ from .fittings import Fitting
22
+ from .network import PipelineNetwork
23
+
24
+ Number = Union[int, float]
25
+
26
+
27
+ class PipelineEngine:
28
+ """
29
+ Sklearn-like interface:
30
+
31
+ engine = PipelineEngine()
32
+ engine.fit(
33
+ flowrate=VolumetricFlowRate(...), # or mass_flowrate + density
34
+ mass_flowrate=..., density=..., viscosity=...,
35
+ velocity=Velocity(...), # optional (if missing, computed)
36
+ pipe=Pipe(...), # optional
37
+ diameter=Diameter(...) or number(mm), # optional
38
+ length=Length(...), # optional if pipe provided
39
+ fluid=Component(...), # optional if density & viscosity given
40
+ network=PipelineNetwork(...), # optional (else single-pipe mode)
41
+ flow_split={"Parallel-AB": [0.3, 0.7]}, # optional branch allocation
42
+ fittings=[Fitting(...), ...], # optional minor losses in single-pipe mode
43
+ inlet_pressure=Pressure(...), # optional
44
+ outlet_pressure=Pressure(...), # optional
45
+ available_dp=Pressure(...), # optional: if given & diameter missing, engine can size pipe
46
+ target_outlet_pressure=Pressure(...), # optional: compute residual dp for control valve
47
+ ).run()
48
+
49
+ - If diameter is missing (single-pipe), engine uses OptimumPipeDiameter based on flowrate & density.
50
+ - If flowrate missing, engine infers it from (mass_flowrate, density) or (velocity, diameter).
51
+ - Supports series/parallel PipelineNetwork with configurable flow splits.
52
+ - Computes residual DP (e.g., for CV) if inlet/outlet/available_dp are provided.
53
+ """
54
+
55
+ # -------------------- INIT / FIT --------------------
56
+ def __init__(self, **kwargs: Any) -> None:
57
+ self.data: Dict[str, Any] = {}
58
+ if kwargs:
59
+ self.fit(**kwargs)
60
+
61
+ def fit(self, **kwargs: Any) -> "PipelineEngine":
62
+ """
63
+ Accepts optional inputs; normalizes common aliases; stores raw config for run().
64
+ Returns self (sklearn-style).
65
+ """
66
+ # Shallow copy to preserve caller values
67
+ self.data = dict(kwargs)
68
+
69
+ # Normalize aliases without discarding originals
70
+ alias_map = {
71
+ "flowrate": ["flow_rate", "q", "Q"],
72
+ "mass_flowrate": ["mass_flow", "m_dot", "mdot"],
73
+ "velocity": ["v"],
74
+ "diameter": ["dia", "D", "nominal_diameter", "internal_diameter"],
75
+ "length": ["len", "L"],
76
+ "inlet_pressure": ["in_pressure", "pin", "p_in"],
77
+ "outlet_pressure": ["out_pressure", "pout", "p_out"],
78
+ "available_dp": ["available_pressure_drop", "dp_available"],
79
+ "target_outlet_pressure": ["target_pout"],
80
+ }
81
+ for canon, alts in alias_map.items():
82
+ if canon not in self.data:
83
+ for alt in alts:
84
+ if alt in self.data and self.data[alt] is not None:
85
+ self.data[canon] = self.data[alt]
86
+ break
87
+
88
+ # Basic defaults (non-destructive)
89
+ self.data.setdefault("flow_split", {}) # for parallel branches
90
+ self.data.setdefault("fittings", []) # minor-loss list in single-pipe mode
91
+ self.data.setdefault("assume_mm_for_numbers", True) # numeric diameter assumed mm
92
+
93
+ # Light validation
94
+ network = self.data.get("network")
95
+ if network is not None and not isinstance(network, PipelineNetwork):
96
+ raise TypeError("network must be a PipelineNetwork if provided.")
97
+
98
+ return self
99
+
100
+ # -------------------- Internal getters / inference ------------------------
101
+
102
+ def _get_density(self) -> Density:
103
+ if "density" in self.data and self.data["density"] is not None:
104
+ return self.data["density"]
105
+ if "fluid" in self.data and isinstance(self.data["fluid"], Component):
106
+ return self.data["fluid"].density()
107
+ raise ValueError("Provide density or a fluid Component (with .density()).")
108
+
109
+ def _get_viscosity(self) -> Viscosity:
110
+ if "viscosity" in self.data and self.data["viscosity"] is not None:
111
+ return self.data["viscosity"]
112
+ if "fluid" in self.data and isinstance(self.data["fluid"], Component):
113
+ return self.data["fluid"].viscosity()
114
+ raise ValueError("Provide viscosity or a fluid Component (with .viscosity()).")
115
+
116
+ def _infer_flowrate(self) -> VolumetricFlowRate:
117
+ """
118
+ Infer volumetric flowrate from:
119
+ 1) explicit flowrate
120
+ 2) mass_flowrate & density
121
+ 3) velocity & diameter
122
+ """
123
+ # 1) Direct
124
+ q = self.data.get("flowrate")
125
+ if q is not None:
126
+ return q
127
+
128
+ # 2) From mass flow & density
129
+ m = self.data.get("mass_flowrate")
130
+ if m is not None:
131
+ rho = self._get_density()
132
+ # Volumetric flow = mass flow / density
133
+ q = VolumetricFlowRate((m.value if hasattr(m, "value") else float(m)) /
134
+ (rho.value if hasattr(rho, "value") else float(rho)), "m^3/s")
135
+ self.data["flowrate"] = q
136
+ return q
137
+
138
+ # 3) From velocity & diameter
139
+ vel = self.data.get("velocity")
140
+ d = self.data.get("diameter")
141
+ if vel is not None and (d is not None or isinstance(self.data.get("pipe"), Pipe)):
142
+ if d is None:
143
+ d = self._internal_diameter_m(self.data.get("pipe"))
144
+ if not isinstance(d, Diameter):
145
+ if self.data.get("assume_mm_for_numbers", True):
146
+ d = Diameter(float(d), "mm")
147
+ else:
148
+ d = Diameter(float(d), "m")
149
+ q = FluidVelocity(volumetric_flow_rate=None, diameter=d, velocity=vel).invert_to_flowrate()
150
+ if not isinstance(q, VolumetricFlowRate):
151
+ # fallback via area
152
+ from math import pi
153
+ v_val = vel.value if hasattr(vel, "value") else float(vel)
154
+ d_m = d.to("m").value
155
+ q = VolumetricFlowRate(v_val * (pi * d_m * d_m / 4.0), "m^3/s")
156
+ self.data["flowrate"] = q
157
+ return q
158
+
159
+ raise ValueError("Unable to infer flowrate. Provide flowrate, or (mass_flowrate & density), or (velocity & diameter).")
160
+
161
+ def _ensure_pipe(self) -> Pipe:
162
+ """Return a Pipe or construct one (single-pipe mode) if possible."""
163
+ p = self.data.get("pipe")
164
+ if isinstance(p, Pipe):
165
+ return p
166
+
167
+ # Build from diameter/length if provided
168
+ d = self.data.get("diameter")
169
+ L = self.data.get("length") or Length(1.0, "m")
170
+ if d is not None:
171
+ if not isinstance(d, Diameter):
172
+ if self.data.get("assume_mm_for_numbers", True):
173
+ d = Diameter(float(d), "mm")
174
+ else:
175
+ d = Diameter(float(d), "m")
176
+ p = Pipe(internal_diameter=d, length=L)
177
+ self.data["pipe"] = p
178
+ return p
179
+
180
+ # Else compute optimum diameter (single-pipe only)
181
+ if "network" not in self.data or self.data["network"] is None:
182
+ q = self._infer_flowrate()
183
+ calc = OptimumPipeDiameter(flow_rate=q, density=self._get_density())
184
+ d_opt = calc.calculate()
185
+ p = Pipe(nominal_diameter=d_opt, length=L)
186
+ self.data["pipe"] = p
187
+ return p
188
+
189
+ # In network mode, each element should define its own geometry.
190
+ raise ValueError("No pipe/diameter provided. In network mode, supply pipe elements inside the network.")
191
+
192
+ def _internal_diameter_m(self, pipe: Optional[Pipe]) -> Diameter:
193
+ if pipe:
194
+ if getattr(pipe, "internal_diameter", None) is not None:
195
+ return pipe.internal_diameter
196
+ if getattr(pipe, "nominal_diameter", None) is not None:
197
+ return pipe.nominal_diameter
198
+ d = self.data.get("diameter")
199
+ if d is not None:
200
+ return d if isinstance(d, Diameter) else Diameter(float(d), "mm")
201
+ # As a last resort, compute optimum (single-pipe)
202
+ q = self._infer_flowrate()
203
+ return OptimumPipeDiameter(flow_rate=q, density=self._get_density()).calculate()
204
+
205
+ def _maybe_velocity(self, pipe: Pipe) -> Velocity:
206
+ v = self.data.get("velocity")
207
+ if v is not None:
208
+ self._apply_recommended_velocity(pipe, given_velocity=v)
209
+ return v
210
+ q = self._infer_flowrate()
211
+ v = FluidVelocity(volumetric_flow_rate=q, diameter=self._internal_diameter_m(pipe)).calculate()
212
+ self._apply_recommended_velocity(pipe, flowrate=q, computed_velocity=v)
213
+ return v
214
+
215
+ def _apply_recommended_velocity(
216
+ self,
217
+ pipe: Pipe,
218
+ flowrate: Optional[VolumetricFlowRate] = None,
219
+ computed_velocity: Optional[Velocity] = None,
220
+ given_velocity: Optional[Velocity] = None,
221
+ ) -> None:
222
+ """If fluid.service_type has a recommended velocity, optionally resize pipe to hit midpoint."""
223
+ service = getattr(self.data.get("fluid"), "service_type", None)
224
+ if not service:
225
+ return
226
+ rec = get_recommended_velocity(service)
227
+ if not rec:
228
+ return
229
+
230
+ v_curr = given_velocity or computed_velocity
231
+ if v_curr is None and flowrate is not None:
232
+ v_curr = FluidVelocity(volumetric_flow_rate=flowrate, diameter=self._internal_diameter_m(pipe)).calculate()
233
+ if v_curr is None:
234
+ return
235
+
236
+ if isinstance(rec, tuple):
237
+ v_min, v_max = rec
238
+ v_val = v_curr.value if hasattr(v_curr, "value") else float(v_curr)
239
+ if v_val >= v_min and v_val <= v_max:
240
+ return
241
+ target_v = 0.5 * (v_min + v_max)
242
+ else:
243
+ target_v = rec
244
+ v_val = v_curr.value if hasattr(v_curr, "value") else float(v_curr)
245
+ if abs(v_val - target_v) / target_v <= 0.10:
246
+ return
247
+
248
+ # resize internal diameter to hit target velocity (kept in mm)
249
+ q = flowrate or self._infer_flowrate()
250
+ from math import pi, sqrt
251
+ A_new = q.value / target_v
252
+ D_new = sqrt(4.0 * A_new / pi) # meters
253
+ pipe.internal_diameter = Diameter(D_new * 1000.0, "mm")
254
+
255
+ # --- Unit helpers (inside PipelineEngine) ---
256
+ def _as_pressure(self, maybe_pressure, default_unit="Pa"):
257
+ """Accept Pressure, float, or None and return a Pressure (or None)."""
258
+ if maybe_pressure is None:
259
+ return None
260
+ if isinstance(maybe_pressure, Pressure):
261
+ return maybe_pressure
262
+ # assume float in default_unit
263
+ return Pressure(float(maybe_pressure), default_unit)
264
+
265
+ def _pump_gain_pa(self, pump) -> Pressure:
266
+ """
267
+ Convert your Pump (head in m or pressures) to a pressure 'gain' (Pa).
268
+ Priority:
269
+ - If both inlet/outlet pressures exist -> use their difference
270
+ - Else if head exists -> rho*g*H
271
+ - Else -> 0 Pa
272
+ """
273
+ rho = getattr(pump, "density", None) or self._get_density().value
274
+ g = 9.81
275
+ pin = getattr(pump, "inlet_pressure", None)
276
+ pout = getattr(pump, "outlet_pressure", None)
277
+ if pin is not None and pout is not None:
278
+ return (self._as_pressure(pout).to("Pa") - self._as_pressure(pin).to("Pa"))
279
+ head = getattr(pump, "head", 0.0) or 0.0
280
+ return Pressure(rho * g * float(head), "Pa")
281
+
282
+ def _equipment_dp_pa(self, eq) -> Pressure:
283
+ """
284
+ Your Equipment stores pressure_drop in 'bar' (float).
285
+ Convert to Pressure in Pa.
286
+ """
287
+ dp_bar = getattr(eq, "pressure_drop", 0.0) or 0.0
288
+ return Pressure(float(dp_bar), "bar").to("Pa")
289
+
290
+ def _fitting_dp_pa(self, fitting, v: Velocity, f: Optional[float], d: Diameter) -> Pressure:
291
+ """
292
+ Compute minor loss from your Fitting using total_K() or equivalent_length().
293
+ """
294
+ rho = self._get_density().value
295
+ v_val = v.value if hasattr(v, "value") else float(v)
296
+ # Try K-method first
297
+ Ktot = None
298
+ try:
299
+ Ktot = fitting.total_K()
300
+ except Exception:
301
+ Ktot = None
302
+ if Ktot is not None:
303
+ return Pressure(0.5 * rho * v_val * v_val * float(Ktot), "Pa")
304
+
305
+ # Fallback: equivalent length method
306
+ Le = None
307
+ try:
308
+ Le = fitting.equivalent_length()
309
+ except Exception:
310
+ Le = None
311
+ if Le is not None:
312
+ # We need friction factor for Le/D method. If not passed, recompute here.
313
+ if f is None:
314
+ # compute f using pipe diameter 'd'
315
+ Re = self._reynolds(v, type("Tmp", (), {"roughness": 0.0, "length": Length(1.0, "m")})()) # roughness ignored here
316
+ f_val = self._friction_factor(Re, type("Tmp", (), {"roughness": 0.0})())
317
+ else:
318
+ f_val = f
319
+ d_m = d.to("m").value
320
+ return Pressure(float(f_val) * (float(Le) / d_m) * 0.5 * rho * v_val * v_val, "Pa")
321
+
322
+ # Neither K nor Le available => no minor loss accounted
323
+ return Pressure(0.0, "Pa")
324
+
325
+
326
+ # -------------------- Primitive calcs ------------------------------------
327
+
328
+ def _reynolds(self, v: Velocity, pipe: Pipe):
329
+ return ReynoldsNumber(
330
+ density=self._get_density(),
331
+ velocity=v,
332
+ diameter=self._internal_diameter_m(pipe),
333
+ viscosity=self._get_viscosity(),
334
+ ).calculate()
335
+
336
+ def _friction_factor(self, Re: float, pipe: Pipe):
337
+ return ColebrookWhite(
338
+ reynolds_number=Re,
339
+ roughness=float(pipe.roughness),
340
+ diameter=self._internal_diameter_m(pipe),
341
+ ).calculate()
342
+
343
+ def _major_loss_dp(self, f: float, v: Velocity, pipe: Pipe):
344
+ return PressureDropDarcy(
345
+ friction_factor=f,
346
+ length=pipe.length,
347
+ diameter=self._internal_diameter_m(pipe),
348
+ density=self._get_density(),
349
+ velocity=v,
350
+ ).calculate()
351
+
352
+ def _minor_loss_dp(self, fitting: Fitting, v: Velocity, f: Optional[float] = None, d: Optional[Diameter] = None) -> Pressure:
353
+ """Compute minor loss (supports K or equivalent length Le)."""
354
+ rho = self._get_density()
355
+ v_val = v.value if hasattr(v, "value") else float(v)
356
+ rho_val = rho.value if hasattr(rho, "value") else float(rho)
357
+ d_val = d.value if hasattr(d, "value") else (d if d is not None else None)
358
+ f_val = f.value if hasattr(f, "value") else (f if f is not None else None)
359
+
360
+ if getattr(fitting, "K", None) is not None:
361
+ return Pressure(float(fitting.K) * 0.5 * rho_val * v_val * v_val, "Pa")
362
+ if getattr(fitting, "Le", None) is not None:
363
+ if f_val is None or d_val is None:
364
+ raise ValueError("Equivalent length method needs friction factor and diameter.")
365
+ return Pressure(f_val * (fitting.Le / d_val) * 0.5 * rho_val * v_val * v_val, "Pa")
366
+ return Pressure(0.0, "Pa")
367
+
368
+ # -------------------- Series/Parallel evaluation -------------------------
369
+
370
+ def _pipe_calculation(self, pipe: Pipe, flow_rate: Optional[VolumetricFlowRate]) -> Dict[str, Any]:
371
+ """Compute velocity, Re, f, major DP for a single pipe."""
372
+ # get velocity (if engine has a fixed velocity, use it; else compute from provided flow_rate)
373
+ if self.data.get("velocity") is not None:
374
+ v = self.data["velocity"]
375
+ else:
376
+ q = flow_rate or self._infer_flowrate()
377
+ v = FluidVelocity(volumetric_flow_rate=q, diameter=self._internal_diameter_m(pipe)).calculate()
378
+
379
+ Re = self._reynolds(v, pipe)
380
+ f = self._friction_factor(Re, pipe)
381
+ dp_major = self._major_loss_dp(f, v, pipe)
382
+
383
+ # Handle any inlined fittings array in the pipe (if your Pipe stores them) OR engine-level single-pipe fittings
384
+ dp_minor = Pressure(0.0, "Pa")
385
+ d = self._internal_diameter_m(pipe)
386
+ fittings: List[Fitting] = []
387
+ # prefer element fittings; else engine-level list in single-pipe mode
388
+ if hasattr(pipe, "fittings") and isinstance(pipe.fittings, list):
389
+ fittings = pipe.fittings
390
+ elif "network" not in self.data and isinstance(self.data.get("fittings"), list):
391
+ fittings = self.data["fittings"]
392
+
393
+ for ft in fittings:
394
+ dp_minor += self._minor_loss_dp(ft, v, f=f, d=d)
395
+
396
+ return {
397
+ "velocity": v,
398
+ "reynolds": Re,
399
+ "friction_factor": f,
400
+ "pressure_drop": dp_major + dp_minor,
401
+ "major_dp": dp_major,
402
+ "minor_dp": dp_minor,
403
+ }
404
+
405
+ def _compute_series(self, series_list, flow_rate, fluid):
406
+ results = []
407
+ dp_total = Pressure(0, "Pa")
408
+
409
+ # Precompute a working velocity/diameter if there is at least one pipe
410
+ # (needed for fittings). We update this on the fly for each pipe.
411
+ current_d: Optional[Diameter] = None
412
+ current_v: Optional[Velocity] = None
413
+ current_f: Optional[float] = None
414
+
415
+ for element in series_list:
416
+ element_name = getattr(element, "name", getattr(element, "fitting_type", element.__class__.__name__.lower()))
417
+
418
+ # --- Pipe ---
419
+ if isinstance(element, Pipe):
420
+ calc = self._pipe_calculation(element, flow_rate)
421
+ dp_total += calc["pressure_drop"]
422
+ current_d = self._internal_diameter_m(element)
423
+ current_v = calc["velocity"]
424
+ # friction factor for this pipe (useful for subsequent fittings via Le/D)
425
+ current_f = calc["friction_factor"]
426
+
427
+ results.append({
428
+ "type": "pipe",
429
+ "name": element_name,
430
+ "length": element.length,
431
+ "diameter": current_d,
432
+ "pressure_drop_Pa": calc["pressure_drop"],
433
+ "reynolds": calc["reynolds"],
434
+ "friction_factor": current_f,
435
+ "velocity": current_v,
436
+ })
437
+
438
+ # --- Pump (your Pump class) ---
439
+ elif element.__class__.__name__ == "Pump":
440
+ pump_gain = self._pump_gain_pa(element) # Pa
441
+ dp_total -= pump_gain # pump adds energy
442
+ results.append({
443
+ "type": "pump",
444
+ "name": element_name,
445
+ "head_gain_Pa": pump_gain,
446
+ "head_m": getattr(element, "head", None),
447
+ "efficiency": getattr(element, "efficiency", None),
448
+ })
449
+
450
+ # --- Equipment (your Equipment class with pressure_drop in bar) ---
451
+ elif element.__class__.__name__ == "Equipment":
452
+ eq_dp = self._equipment_dp_pa(element)
453
+ dp_total += eq_dp
454
+ results.append({
455
+ "type": "equipment",
456
+ "name": getattr(element, "name", element_name),
457
+ "pressure_drop_Pa": eq_dp,
458
+ })
459
+
460
+ # --- Fitting (minor loss) ---
461
+ elif isinstance(element, Fitting):
462
+ # We need a velocity & diameter context. If none yet, try to derive from engine-level flowrate.
463
+ if current_d is None or current_v is None:
464
+ # try derive from any pipe dimension on fitting or engine diameter
465
+ d_for_fit = None
466
+ if hasattr(element, "diameter") and element.diameter:
467
+ d_for_fit = element.diameter if isinstance(element.diameter, Diameter) else Diameter(float(element.diameter), "mm")
468
+ else:
469
+ d_for_fit = self._internal_diameter_m() # could compute via optimum if single-line
470
+ q = flow_rate or self._maybe_flowrate()
471
+ current_v = FluidVelocity(volumetric_flow_rate=q, diameter=d_for_fit).calculate()
472
+ current_d = d_for_fit
473
+ # friction factor unknown; pass None (method will recompute if needed)
474
+ current_f = None
475
+
476
+ dp_fit = self._fitting_dp_pa(element, current_v, current_f, current_d)
477
+ dp_total += dp_fit
478
+ results.append({
479
+ "type": "fitting",
480
+ "name": element_name,
481
+ "pressure_drop_Pa": dp_fit,
482
+ "using": "K" if element.total_K() is not None else ("Le" if element.equivalent_length() is not None else "none"),
483
+ "quantity": getattr(element, "quantity", 1),
484
+ })
485
+
486
+ # --- Vessel (no default ΔP; acts as connection/boundary unless later extended) ---
487
+ elif element.__class__.__name__ == "Vessel":
488
+ # treat as zero-ΔP pass-through unless you decide to add a vessel ΔP later
489
+ results.append({
490
+ "type": "vessel",
491
+ "name": getattr(element, "name", element_name),
492
+ "pressure_drop_Pa": Pressure(0.0, "Pa"),
493
+ "note": "No default ΔP; boundary/holdup element.",
494
+ })
495
+
496
+ # --- Nested Networks (unchanged) ---
497
+ elif isinstance(element, PipelineNetwork):
498
+ # Handled by _compute_network (this path normally won't show up here)
499
+ raise TypeError("Nested networks should be handled in _compute_network, not _compute_series.")
500
+
501
+ else:
502
+ raise TypeError(f"Unsupported element type: {type(element).__name__}")
503
+
504
+ # --- Optional inlet/outlet pressures on any element (duck-typed) ---
505
+ inlet_p = getattr(element, "inlet_pressure", None)
506
+ outlet_p = getattr(element, "outlet_pressure", None)
507
+ if inlet_p is not None and outlet_p is not None:
508
+ dp_element = self._as_pressure(inlet_p).to("Pa") - self._as_pressure(outlet_p).to("Pa")
509
+ dp_total += dp_element
510
+ results[-1].update({
511
+ "inlet_pressure_Pa": self._as_pressure(inlet_p).to("Pa"),
512
+ "outlet_pressure_Pa": self._as_pressure(outlet_p).to("Pa"),
513
+ "net_dp_from_defined_pressures_Pa": dp_element
514
+ })
515
+
516
+ return dp_total, results
517
+
518
+ def _resolve_parallel_flows(self, net: PipelineNetwork, q_m3s: VolumetricFlowRate, branches: List[Any]) -> List[float]:
519
+ split_cfg = (self.data.get("flow_split") or {}).get(net.name)
520
+ n = len(branches)
521
+ if split_cfg is None:
522
+ return [q_m3s.value / n] * n
523
+ vals = [float(x) for x in split_cfg]
524
+ if sum(vals) > 1.5 * q_m3s.value: # treat as absolute flows
525
+ return vals
526
+ s = sum(vals)
527
+ return [q_m3s.value * (v / s) for v in vals]
528
+
529
+ def _compute_network(self, net: PipelineNetwork, q_m3s: Optional[VolumetricFlowRate]) -> Tuple[Pressure, List[Dict[str, Any]], List[Dict[str, Any]]]:
530
+ """
531
+ Returns:
532
+ dp_across: Pressure
533
+ element_results: list
534
+ branch_summaries: list
535
+ """
536
+ if net.connection_type in (None, "series"):
537
+ return (*self._compute_series(net.elements, q_m3s), [])
538
+
539
+ # parallel
540
+ branches = net.elements
541
+ q_branches = self._resolve_parallel_flows(net, q_m3s, branches if isinstance(branches, list) else [])
542
+ branch_summaries: List[Dict[str, Any]] = []
543
+ all_results: List[Dict[str, Any]] = []
544
+ dp_across = Pressure(0.0, "Pa")
545
+
546
+ for idx, (child, qb) in enumerate(zip(branches, q_branches), start=1):
547
+ q_child = VolumetricFlowRate(qb, "m^3/s")
548
+ if isinstance(child, PipelineNetwork):
549
+ dp_b, elems_b, subs = self._compute_network(child, q_child)
550
+ all_results.extend(elems_b)
551
+ branch_summaries.extend(subs)
552
+ else:
553
+ dp_b, elems_b = self._compute_series([child], q_child)
554
+ all_results.extend(elems_b)
555
+
556
+ dp_across = max(dp_across, dp_b)
557
+ branch_summaries.append({
558
+ "parallel_name": net.name,
559
+ "branch_index": idx,
560
+ "flow_m3_s": qb,
561
+ "pressure_drop_Pa": dp_b
562
+ })
563
+
564
+ return dp_across, all_results, branch_summaries
565
+
566
+ # -------------------- RUN / SUMMARY --------------------------------------
567
+
568
+ def run(self) -> PipelineResults:
569
+ """
570
+ Execute the configured calculation and return PipelineResults.
571
+ Also computes residual DP if inlet/outlet pressures and/or available_dp are provided.
572
+ """
573
+ results: Dict[str, Any] = {}
574
+ network = self.data.get("network")
575
+
576
+ # Determine inlet flow (for network and diagnostics)
577
+ q_in = None
578
+ try:
579
+ q_in = self._infer_flowrate()
580
+ except Exception:
581
+ pass # For some pure-pressure driven cases user may not provide flow; but most sizing needs it.
582
+
583
+ # NETWORK MODE
584
+ if isinstance(network, PipelineNetwork):
585
+ if q_in is None:
586
+ raise ValueError("Network mode requires flowrate (or sufficient info to infer it).")
587
+ total_dp, element_results, branch_summaries = self._compute_network(network, q_in)
588
+ results["mode"] = "network"
589
+ results["summary"] = {"inlet_flow_m3_s": q_in, "total_pressure_drop_Pa": total_dp}
590
+ results["elements"] = element_results
591
+ if branch_summaries:
592
+ results["parallel_sections"] = branch_summaries
593
+ if hasattr(network, "schematic"):
594
+ results["schematic"] = network.schematic()
595
+
596
+ # SINGLE-PIPE MODE
597
+ else:
598
+ pipe = self._ensure_pipe()
599
+ v = self._maybe_velocity(pipe)
600
+ Re = self._reynolds(v, pipe)
601
+ f = self._friction_factor(Re, pipe)
602
+ dp_major = self._major_loss_dp(f, v, pipe)
603
+
604
+ # Minor losses (engine-level in single-pipe mode)
605
+ dp_minor = Pressure(0.0, "Pa")
606
+ for ft in self.data.get("fittings", []):
607
+ dp_minor += self._minor_loss_dp(ft, v, f=f, d=self._internal_diameter_m(pipe))
608
+
609
+ dp_total = dp_major + dp_minor
610
+
611
+ results["mode"] = "single"
612
+ results["pipe"] = {
613
+ "internal_diameter": self._internal_diameter_m(pipe),
614
+ "length": pipe.length,
615
+ }
616
+ results["velocity_m_s"] = v
617
+ results["reynolds_number"] = Re
618
+ results["friction_factor"] = f
619
+ results["pressure_drop_Pa"] = dp_total
620
+ results["major_dp_Pa"] = dp_major
621
+ results["minor_dp_Pa"] = dp_minor
622
+
623
+ # Residual / control-valve DP logic (works for both modes)
624
+ inlet_p = self.data.get("inlet_pressure")
625
+ outlet_p_target = self.data.get("target_outlet_pressure") or self.data.get("outlet_pressure")
626
+ available_dp = self.data.get("available_dp")
627
+
628
+ if inlet_p is not None and outlet_p_target is not None:
629
+ required_system_dp = inlet_p.to("Pa") - outlet_p_target.to("Pa")
630
+ calc_dp = results["summary"]["total_pressure_drop_Pa"] if results["mode"] == "network" else results["pressure_drop_Pa"]
631
+ residual_dp = required_system_dp - calc_dp
632
+ results["residual_dp_Pa"] = residual_dp
633
+
634
+ if available_dp is not None:
635
+ calc_dp = results["summary"]["total_pressure_drop_Pa"] if results["mode"] == "network" else results["pressure_drop_Pa"]
636
+ results["unused_available_dp_Pa"] = available_dp.to("Pa") - calc_dp
637
+
638
+ self._results = results
639
+ return PipelineResults(results)
640
+
641
+ def summary(self) -> None:
642
+ """Pretty print a concise human-readable summary of the latest run()."""
643
+ if not hasattr(self, "_results") or not self._results:
644
+ raise ValueError("No results. Call run() first.")
645
+
646
+ r = self._results
647
+ print("\n=== Pipeline Summary ===")
648
+
649
+ if r.get("mode") == "network":
650
+ s = r.get("summary", {})
651
+ q = s.get("inlet_flow_m3_s")
652
+ dp = s.get("total_pressure_drop_Pa")
653
+ if q: print(f"Inlet Flow: {q.value if hasattr(q,'value') else q:.6f} m³/s")
654
+ if dp: print(f"Total Pressure Drop: {dp.value if hasattr(dp,'value') else dp:.2f} Pa")
655
+
656
+ if "parallel_sections" in r:
657
+ print("\n--- Parallel Sections ---")
658
+ for b in r["parallel_sections"]:
659
+ dpb = b["pressure_drop_Pa"]
660
+ print(f" {b['parallel_name']} | Branch {b['branch_index']}: "
661
+ f"Flow={b['flow_m3_s']:.6f} m³/s, ΔP={dpb.value if hasattr(dpb,'value') else dpb:.2f} Pa")
662
+
663
+ if "elements" in r:
664
+ print("\n--- Element Details ---")
665
+ for e in r["elements"]:
666
+ t = e.get("type", "element")
667
+ name = e.get("name", t)
668
+ print(f" {t.capitalize()}: {name}")
669
+ if t == "pipe":
670
+ d = e["diameter"]; v = e["velocity"]; Re = e["reynolds"]; f = e["friction_factor"]; dp = e["pressure_drop_Pa"]
671
+ print(f" D: {d.value if hasattr(d,'value') else d:.2f} mm, "
672
+ f"V: {v.value if hasattr(v,'value') else v:.6f} m/s, "
673
+ f"Re: {Re.value if hasattr(Re,'value') else Re:.0f}, "
674
+ f"f: {f.value if hasattr(f,'value') else f:.6f}, "
675
+ f"ΔP: {dp.value if hasattr(dp,'value') else dp:.2f} Pa")
676
+ elif t == "pump":
677
+ gain = e["head_gain_Pa"]
678
+ print(f" Head Gain: {gain.value if hasattr(gain,'value') else gain:.2f} Pa")
679
+ else:
680
+ dp = e.get("pressure_drop_Pa")
681
+ if dp:
682
+ print(f" ΔP: {dp.value if hasattr(dp,'value') else dp:.2f} Pa")
683
+
684
+ if "schematic" in r:
685
+ print("\n=== Schematic ===")
686
+ print(r["schematic"])
687
+
688
+ else:
689
+ # Single mode
690
+ p = r.get("pipe", {})
691
+ d = p.get("internal_diameter")
692
+ L = p.get("length")
693
+ v = r.get("velocity_m_s")
694
+ Re = r.get("reynolds_number")
695
+ f = r.get("friction_factor")
696
+ dp = r.get("pressure_drop_Pa")
697
+ dpM = r.get("major_dp_Pa")
698
+ dpm = r.get("minor_dp_Pa")
699
+
700
+ print("\n--- Single Pipe ---")
701
+ if d: print(f"Diameter: {d.value if hasattr(d,'value') else d:.2f} mm")
702
+ if L: print(f"Length: {L.value if hasattr(L,'value') else L:.2f} m")
703
+ if v: print(f"Velocity: {v.value if hasattr(v,'value') else v:.6f} m/s")
704
+ if Re is not None:
705
+ print(f"Reynolds: {Re.value if hasattr(Re,'value') else Re:.0f}")
706
+ if f is not None:
707
+ print(f"f (CW): {f.value if hasattr(f,'value') else f:.6f}")
708
+ if dp:
709
+ print(f"ΔP total: {dp.value if hasattr(dp,'value') else dp:.2f} Pa"
710
+ + (f" (Major {dpM.value if hasattr(dpM,'value') else dpM:.2f}, Minor {dpm.value if hasattr(dpm,'value') else dpm:.2f})" if dpM and dpm else ""))
711
+
712
+ # Residuals
713
+ resid = r.get("residual_dp_Pa")
714
+ if resid is not None:
715
+ print(f"\nResidual ΔP (e.g., for control valve): {resid.value if hasattr(resid,'value') else resid:.2f} Pa")
716
+ unused = r.get("unused_available_dp_Pa")
717
+ if unused is not None:
718
+ print(f"Unused Available ΔP: {unused.value if hasattr(unused,'value') else unused:.2f} Pa")