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.
- processpi/__init__.py +1 -0
- processpi/calculations/__init__.py +3 -0
- processpi/calculations/base.py +55 -0
- processpi/calculations/engine.py +69 -0
- processpi/calculations/fluids/__init__.py +13 -0
- processpi/calculations/fluids/cool_brook_white.py +49 -0
- processpi/calculations/fluids/flow_type.py +37 -0
- processpi/calculations/fluids/optimium_pipe_dia.py +45 -0
- processpi/calculations/fluids/pressure_drop_darcy.py +25 -0
- processpi/calculations/fluids/pressure_drop_fanning.py +25 -0
- processpi/calculations/fluids/pump_power.py +34 -0
- processpi/calculations/fluids/reynolds_number.py +24 -0
- processpi/calculations/fluids/velocity.py +36 -0
- processpi/calculations/heat_transfer/__init__.py +4 -0
- processpi/calculations/heat_transfer/conduction_heat_loss.py +26 -0
- processpi/calculations/heat_transfer/convection_heat_loss.py +23 -0
- processpi/calculations/heat_transfer/heat_exchanger_area.py +26 -0
- processpi/calculations/mass_transfer/__init__.py +3 -0
- processpi/calculations/mass_transfer/distillation_stage_count.py +346 -0
- processpi/calculations/mass_transfer/drying_rate.py +132 -0
- processpi/calculations/reaction_engineering/__init__.py +4 -0
- processpi/calculations/reaction_engineering/catalyst_activity.py +109 -0
- processpi/calculations/reaction_engineering/reaction_rate.py +143 -0
- processpi/calculations/reaction_engineering/residence_time.py +80 -0
- processpi/calculations/thermodynamics/__init__.py +4 -0
- processpi/calculations/thermodynamics/enthalpy_change.py +23 -0
- processpi/calculations/thermodynamics/entropy_change.py +25 -0
- processpi/calculations/thermodynamics/heat_of_vaporization.py +20 -0
- processpi/components/__init__.py +5 -0
- processpi/components/aceticacid.py +19 -0
- processpi/components/acetone.py +20 -0
- processpi/components/acrylicacid.py +19 -0
- processpi/components/air.py +20 -0
- processpi/components/ammonia.py +19 -0
- processpi/components/base.py +85 -0
- processpi/components/benzene.py +40 -0
- processpi/components/benzoicacid.py +19 -0
- processpi/components/bromine.py +19 -0
- processpi/components/butane.py +19 -0
- processpi/components/carbondioxide.py +19 -0
- processpi/components/carbonmonoxide.py +17 -0
- processpi/components/carbontetrachloride.py +17 -0
- processpi/components/chlorine.py +17 -0
- processpi/components/chlorobenzene.py +17 -0
- processpi/components/chloroform.py +17 -0
- processpi/components/chloromethane.py +17 -0
- processpi/components/cyanogen.py +17 -0
- processpi/components/cyclohexene.py +17 -0
- processpi/components/ethane.py +17 -0
- processpi/components/ethanol.py +20 -0
- processpi/components/ethylacetate.py +17 -0
- processpi/components/methanol.py +20 -0
- processpi/components/toluene.py +22 -0
- processpi/components/water.py +24 -0
- processpi/pipelines/__init__.py +0 -0
- processpi/pipelines/base.py +76 -0
- processpi/pipelines/design_rules.py +91 -0
- processpi/pipelines/engine.py +718 -0
- processpi/pipelines/equipment.py +32 -0
- processpi/pipelines/fittings.py +76 -0
- processpi/pipelines/insulation.py +0 -0
- processpi/pipelines/materials.py +102 -0
- processpi/pipelines/network.py +277 -0
- processpi/pipelines/pipelineresults.py +95 -0
- processpi/pipelines/pipes.py +84 -0
- processpi/pipelines/pumps.py +83 -0
- processpi/pipelines/selection.py +89 -0
- processpi/pipelines/standards.py +279 -0
- processpi/pipelines/vessel.py +34 -0
- processpi/units/__init__.py +38 -0
- processpi/units/area.py +51 -0
- processpi/units/base.py +57 -0
- processpi/units/density.py +47 -0
- processpi/units/diameter.py +58 -0
- processpi/units/dimensionless.py +42 -0
- processpi/units/heat_flux.py +46 -0
- processpi/units/heat_of_vaporization.py +47 -0
- processpi/units/heat_transfer_coefficient.py +45 -0
- processpi/units/length.py +47 -0
- processpi/units/mass.py +50 -0
- processpi/units/mass_flowrate.py +50 -0
- processpi/units/pressure.py +57 -0
- processpi/units/specific_heat.py +47 -0
- processpi/units/strings.py +31 -0
- processpi/units/temperature.py +51 -0
- processpi/units/thermal_conductivity.py +45 -0
- processpi/units/thermal_resistance.py +46 -0
- processpi/units/velocity.py +45 -0
- processpi/units/viscosity.py +78 -0
- processpi/units/volume.py +50 -0
- processpi/units/volumetric_flowrate.py +52 -0
- processpi-0.0.0.dist-info/METADATA +5 -0
- processpi-0.0.0.dist-info/RECORD +96 -0
- processpi-0.0.0.dist-info/WHEEL +5 -0
- processpi-0.0.0.dist-info/licenses/LICENSE +21 -0
- processpi-0.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
|
|
2
|
+
class Equipment:
|
|
3
|
+
"""
|
|
4
|
+
Generic inline process equipment with inlet, outlet, and pressure drop.
|
|
5
|
+
Can represent filters, heat exchangers, valves, or other unit operations.
|
|
6
|
+
"""
|
|
7
|
+
def __init__(self, name: str, pressure_drop: float = 0.0, description: str = ""):
|
|
8
|
+
"""
|
|
9
|
+
Initialize Equipment.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
name (str): Equipment name.
|
|
13
|
+
pressure_drop (float): Pressure drop across equipment (bar).
|
|
14
|
+
description (str): Optional description (e.g., 'Heat Exchanger', 'Filter').
|
|
15
|
+
"""
|
|
16
|
+
self.name = name
|
|
17
|
+
self.pressure_drop = pressure_drop
|
|
18
|
+
self.description = description
|
|
19
|
+
self.inlet_nodes = []
|
|
20
|
+
self.outlet_nodes = []
|
|
21
|
+
|
|
22
|
+
def add_inlet(self, node):
|
|
23
|
+
"""Attach an inlet node to the equipment."""
|
|
24
|
+
self.inlet_nodes.append(node)
|
|
25
|
+
|
|
26
|
+
def add_outlet(self, node):
|
|
27
|
+
"""Attach an outlet node to the equipment."""
|
|
28
|
+
self.outlet_nodes.append(node)
|
|
29
|
+
|
|
30
|
+
def __repr__(self):
|
|
31
|
+
return (f"Equipment({self.name}, ΔP={self.pressure_drop} bar, "
|
|
32
|
+
f"Desc='{self.description}')")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# processpi/pipelines/fittings.py
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Dict, Union
|
|
4
|
+
from .base import PipelineBase
|
|
5
|
+
from ..units import *
|
|
6
|
+
from .standards import EQUIVALENT_LENGTHS, K_FACTORS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Fitting(PipelineBase):
|
|
10
|
+
"""
|
|
11
|
+
Represents a pipeline fitting or valve with either equivalent length or K-factor.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
fitting_type (str): Type of fitting/valve (e.g., 'gate_valve_open', 'globe_valve').
|
|
15
|
+
diameter (float): Inside diameter of the pipe (mm).
|
|
16
|
+
quantity (int): Number of fittings of this type.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
fitting_type: str,
|
|
22
|
+
diameter: Diameter,
|
|
23
|
+
quantity: int = 1,
|
|
24
|
+
):
|
|
25
|
+
self.fitting_type = fitting_type
|
|
26
|
+
self.diameter = diameter
|
|
27
|
+
self.quantity = quantity
|
|
28
|
+
|
|
29
|
+
# Load equivalent length factor if available
|
|
30
|
+
self.le_factor = EQUIVALENT_LENGTHS.get(fitting_type, None)
|
|
31
|
+
|
|
32
|
+
# Load K-factor if available
|
|
33
|
+
self.K = K_FACTORS.get(fitting_type, None)
|
|
34
|
+
|
|
35
|
+
def equivalent_length(self) -> Optional[float]:
|
|
36
|
+
"""
|
|
37
|
+
Calculate equivalent length of the fitting(s) in meters.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
float: Equivalent length (m) or None if not defined.
|
|
41
|
+
"""
|
|
42
|
+
if self.le_factor is None:
|
|
43
|
+
return None
|
|
44
|
+
dj_m = self.diameter / 1000 # mm → m
|
|
45
|
+
return self.le_factor * dj_m * self.quantity
|
|
46
|
+
|
|
47
|
+
def total_K(self) -> Optional[float]:
|
|
48
|
+
"""
|
|
49
|
+
Calculate total K-factor for the fitting(s).
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
float: Total K-factor or None if not defined.
|
|
53
|
+
"""
|
|
54
|
+
if self.K is None:
|
|
55
|
+
return None
|
|
56
|
+
return self.K * self.quantity
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> Dict[str, Union[str, float, None]]:
|
|
59
|
+
"""
|
|
60
|
+
Export fitting properties as dictionary for reporting.
|
|
61
|
+
"""
|
|
62
|
+
return {
|
|
63
|
+
"fitting_type": self.fitting_type,
|
|
64
|
+
"diameter_mm": self.diameter,
|
|
65
|
+
"quantity": self.quantity,
|
|
66
|
+
"le_factor": self.le_factor,
|
|
67
|
+
"equivalent_length_m": self.equivalent_length(),
|
|
68
|
+
"K": self.K,
|
|
69
|
+
"total_K": self.total_K(),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def calculate(self):
|
|
73
|
+
"""
|
|
74
|
+
Perform the pipeline calculation (placeholder).
|
|
75
|
+
"""
|
|
76
|
+
return self.to_dict()
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# processpi/pipelines/materials.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Pipeline Materials Specifications and Properties
|
|
5
|
+
|
|
6
|
+
This module contains physical and mechanical properties of
|
|
7
|
+
pipeline materials commonly used in process industries.
|
|
8
|
+
|
|
9
|
+
All values are representative and may vary depending on
|
|
10
|
+
standards (ASME, ASTM, ISO) and manufacturer data.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import Dict, Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Dictionary of material properties
|
|
17
|
+
MATERIAL_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
|
18
|
+
"CS": { # Carbon Steel
|
|
19
|
+
"density": 7850, # kg/m³
|
|
20
|
+
"thermal_conductivity": 54, # W/m·K
|
|
21
|
+
"roughness": 0.045, # mm
|
|
22
|
+
"max_temp": 425, # °C (approximate)
|
|
23
|
+
"allowable_stress": 138, # MPa
|
|
24
|
+
"notes": "Standard carbon steel pipes, widely used in process piping."
|
|
25
|
+
},
|
|
26
|
+
"SS304": { # Stainless Steel 304
|
|
27
|
+
"density": 8000,
|
|
28
|
+
"thermal_conductivity": 16.2,
|
|
29
|
+
"roughness": 0.015,
|
|
30
|
+
"max_temp": 870,
|
|
31
|
+
"allowable_stress": 137,
|
|
32
|
+
"notes": "Good corrosion resistance, general chemical service."
|
|
33
|
+
},
|
|
34
|
+
"SS316": { # Stainless Steel 316
|
|
35
|
+
"density": 8000,
|
|
36
|
+
"thermal_conductivity": 16.3,
|
|
37
|
+
"roughness": 0.015,
|
|
38
|
+
"max_temp": 925,
|
|
39
|
+
"allowable_stress": 137,
|
|
40
|
+
"notes": "Better corrosion resistance than SS304, widely used in chemical plants."
|
|
41
|
+
},
|
|
42
|
+
"PVC": { # Polyvinyl Chloride
|
|
43
|
+
"density": 1380,
|
|
44
|
+
"thermal_conductivity": 0.19,
|
|
45
|
+
"roughness": 0.0015,
|
|
46
|
+
"max_temp": 60,
|
|
47
|
+
"allowable_stress": 13.8,
|
|
48
|
+
"notes": "Lightweight, used in water and low-pressure service."
|
|
49
|
+
},
|
|
50
|
+
"CPVC": { # Chlorinated Polyvinyl Chloride
|
|
51
|
+
"density": 1500,
|
|
52
|
+
"thermal_conductivity": 0.14,
|
|
53
|
+
"roughness": 0.0015,
|
|
54
|
+
"max_temp": 90,
|
|
55
|
+
"allowable_stress": 17,
|
|
56
|
+
"notes": "Higher temperature resistance than PVC."
|
|
57
|
+
},
|
|
58
|
+
"HDPE": { # High Density Polyethylene
|
|
59
|
+
"density": 950,
|
|
60
|
+
"thermal_conductivity": 0.42,
|
|
61
|
+
"roughness": 0.007,
|
|
62
|
+
"max_temp": 60,
|
|
63
|
+
"allowable_stress": 5,
|
|
64
|
+
"notes": "Corrosion resistant, flexible, low-pressure applications."
|
|
65
|
+
},
|
|
66
|
+
"Copper": {
|
|
67
|
+
"density": 8960,
|
|
68
|
+
"thermal_conductivity": 401,
|
|
69
|
+
"roughness": 0.0015,
|
|
70
|
+
"max_temp": 200,
|
|
71
|
+
"allowable_stress": 70,
|
|
72
|
+
"notes": "Excellent thermal conductivity, used in heat exchangers."
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_material_property(material: str, prop: str) -> Any:
|
|
78
|
+
"""
|
|
79
|
+
Get a specific property of a pipeline material.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
material (str): Material code (e.g., 'CS', 'SS316', 'PVC').
|
|
83
|
+
prop (str): Property name (e.g., 'density', 'roughness').
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Any: Property value if found, else None.
|
|
87
|
+
"""
|
|
88
|
+
return MATERIAL_PROPERTIES.get(material, {}).get(prop)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_material_data(material: str) -> Dict[str, Any]:
|
|
92
|
+
"""
|
|
93
|
+
Get all properties for a given pipeline material.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
material (str): Material code.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
dict: Dictionary of material properties.
|
|
100
|
+
"""
|
|
101
|
+
return MATERIAL_PROPERTIES.get(material, {})
|
|
102
|
+
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import List, Dict, Union, Optional, Any
|
|
3
|
+
|
|
4
|
+
from .pipes import Pipe
|
|
5
|
+
from .fittings import Fitting
|
|
6
|
+
from .vessel import Vessel
|
|
7
|
+
from .pump import Pump
|
|
8
|
+
from .equipment import Equipment
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Node:
|
|
12
|
+
"""Node represents a junction or endpoint in the pipeline network."""
|
|
13
|
+
def __init__(self, name: str, elevation: float = 0.0):
|
|
14
|
+
self.name = name
|
|
15
|
+
self.elevation = elevation
|
|
16
|
+
|
|
17
|
+
def __repr__(self):
|
|
18
|
+
return f"Node({self.name}, elevation={self.elevation})"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# A branch in a parallel block can be either a PipelineNetwork or a single element
|
|
22
|
+
Branch = Union["PipelineNetwork", Pipe, Fitting, Pump, Vessel, Equipment]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PipelineNetwork:
|
|
26
|
+
"""
|
|
27
|
+
Framework for defining a process pipeline network consisting of pipes,
|
|
28
|
+
fittings, pumps, vessels, equipment, and sub-networks.
|
|
29
|
+
|
|
30
|
+
This version supports both node/edge construction AND composable
|
|
31
|
+
series/parallel blocks compatible with the PipelineEngine:
|
|
32
|
+
- connection_type: "series" (default) or "parallel"
|
|
33
|
+
- elements: a list of elements or child PipelineNetwork objects
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# ---------------- Init ----------------
|
|
37
|
+
def __init__(self, name: str, connection_type: Optional[str] = "series"):
|
|
38
|
+
if connection_type not in (None, "series", "parallel"):
|
|
39
|
+
raise ValueError("connection_type must be 'series' or 'parallel' (or None which defaults to 'series').")
|
|
40
|
+
self.name = name
|
|
41
|
+
self.nodes: Dict[str, Node] = {}
|
|
42
|
+
self.elements: List[Branch] = []
|
|
43
|
+
self.connection_type: str = connection_type or "series"
|
|
44
|
+
|
|
45
|
+
# ---------------- Convenience constructors (engine-friendly) -------------
|
|
46
|
+
@staticmethod
|
|
47
|
+
def series(name: str, *elements: Branch) -> "PipelineNetwork":
|
|
48
|
+
"""Create a series block populated with elements."""
|
|
49
|
+
net = PipelineNetwork(name=name, connection_type="series")
|
|
50
|
+
net.elements.extend(elements)
|
|
51
|
+
return net
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def parallel(name: str, *branches: Branch) -> "PipelineNetwork":
|
|
55
|
+
"""Create a parallel block populated with branch elements or child networks."""
|
|
56
|
+
net = PipelineNetwork(name=name, connection_type="parallel")
|
|
57
|
+
net.elements.extend(branches)
|
|
58
|
+
return net
|
|
59
|
+
|
|
60
|
+
# ---------------- Composable builders ------------------------------------
|
|
61
|
+
def add(self, *elements: Branch) -> "PipelineNetwork":
|
|
62
|
+
"""Append one or more elements to the current block (series or parallel)."""
|
|
63
|
+
self.elements.extend(elements)
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def add_series(self, *elements: Branch) -> "PipelineNetwork":
|
|
67
|
+
"""
|
|
68
|
+
Add a series group. If the current block is series, append directly.
|
|
69
|
+
If current block is parallel, wrap as a child series network (a branch).
|
|
70
|
+
"""
|
|
71
|
+
if self.connection_type == "series":
|
|
72
|
+
self.elements.extend(elements)
|
|
73
|
+
return self
|
|
74
|
+
series_child = PipelineNetwork(name=f"{self.name}-series-{len(self.elements)+1}", connection_type="series")
|
|
75
|
+
series_child.elements.extend(elements)
|
|
76
|
+
self.elements.append(series_child)
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def add_parallel(self, *branches: Branch) -> "PipelineNetwork":
|
|
80
|
+
"""
|
|
81
|
+
Add a parallel group. If current block is parallel, append as new branches.
|
|
82
|
+
If current block is series, wrap as a child parallel network (one element).
|
|
83
|
+
"""
|
|
84
|
+
if self.connection_type == "parallel":
|
|
85
|
+
self.elements.extend(branches)
|
|
86
|
+
return self
|
|
87
|
+
par_child = PipelineNetwork(name=f"{self.name}-parallel-{len(self.elements)+1}", connection_type="parallel")
|
|
88
|
+
par_child.elements.extend(branches)
|
|
89
|
+
self.elements.append(par_child)
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
# ---------------- Node Management ----------------
|
|
93
|
+
def add_node(self, name: str, elevation: float = 0.0) -> Node:
|
|
94
|
+
"""Add a node (junction) to the network."""
|
|
95
|
+
if name in self.nodes:
|
|
96
|
+
raise ValueError(f"Node '{name}' already exists in the network.")
|
|
97
|
+
node = Node(name, elevation)
|
|
98
|
+
self.nodes[name] = node
|
|
99
|
+
return node
|
|
100
|
+
|
|
101
|
+
def get_node(self, name: str) -> Node:
|
|
102
|
+
"""Fetch an existing node by name."""
|
|
103
|
+
if name not in self.nodes:
|
|
104
|
+
raise KeyError(f"Node '{name}' does not exist in the network.")
|
|
105
|
+
return self.nodes[name]
|
|
106
|
+
|
|
107
|
+
# ---------------- Edge Management ----------------
|
|
108
|
+
def add_edge(self, component: Union[Pipe, Pump, Vessel, Equipment], start_node: str, end_node: str = None):
|
|
109
|
+
"""
|
|
110
|
+
Add a connection (edge) between nodes with strict validation.
|
|
111
|
+
Edges added here are automatically appended to this block's elements.
|
|
112
|
+
"""
|
|
113
|
+
if start_node not in self.nodes:
|
|
114
|
+
raise ValueError(f"Start node '{start_node}' not found in network.")
|
|
115
|
+
|
|
116
|
+
# Prevent start=end self-loops
|
|
117
|
+
if end_node and start_node == end_node:
|
|
118
|
+
raise ValueError(f"Cannot create self-loop on node '{start_node}'.")
|
|
119
|
+
|
|
120
|
+
if isinstance(component, (Pipe, Pump)):
|
|
121
|
+
if not end_node or end_node not in self.nodes:
|
|
122
|
+
raise ValueError(f"End node '{end_node}' not found in network.")
|
|
123
|
+
component.start_node = self.nodes[start_node]
|
|
124
|
+
component.end_node = self.nodes[end_node]
|
|
125
|
+
|
|
126
|
+
elif isinstance(component, (Vessel, Equipment)):
|
|
127
|
+
if not end_node or end_node not in self.nodes:
|
|
128
|
+
raise ValueError(f"Outlet node '{end_node}' not found in network.")
|
|
129
|
+
component.add_inlet(self.nodes[start_node])
|
|
130
|
+
component.add_outlet(self.nodes[end_node])
|
|
131
|
+
|
|
132
|
+
else:
|
|
133
|
+
raise TypeError(f"Unsupported component type '{type(component).__name__}'.")
|
|
134
|
+
|
|
135
|
+
self.elements.append(component)
|
|
136
|
+
|
|
137
|
+
# ---------------- Element Management ----------------
|
|
138
|
+
def add_fitting(self, fitting: Fitting, at_node: str):
|
|
139
|
+
"""Add a fitting at a specific node."""
|
|
140
|
+
if at_node not in self.nodes:
|
|
141
|
+
raise ValueError(f"Node '{at_node}' must exist in the network.")
|
|
142
|
+
fitting.node = self.nodes[at_node]
|
|
143
|
+
self.elements.append(fitting)
|
|
144
|
+
|
|
145
|
+
def add_subnetwork(self, subnetwork: "PipelineNetwork", connection_type: str):
|
|
146
|
+
"""
|
|
147
|
+
Add a subnetwork (series or parallel) as a child element.
|
|
148
|
+
This preserves compatibility with existing code that constructs subnetworks separately.
|
|
149
|
+
"""
|
|
150
|
+
if connection_type not in ["series", "parallel"]:
|
|
151
|
+
raise ValueError("connection_type must be 'series' or 'parallel'")
|
|
152
|
+
if subnetwork is self:
|
|
153
|
+
raise ValueError("Cannot add the network as a subnetwork of itself.")
|
|
154
|
+
subnetwork.connection_type = connection_type
|
|
155
|
+
self.elements.append(subnetwork)
|
|
156
|
+
|
|
157
|
+
# ---------------- Validation ----------------
|
|
158
|
+
def validate(self):
|
|
159
|
+
"""Check for common network errors and raise descriptive exceptions."""
|
|
160
|
+
errors = []
|
|
161
|
+
|
|
162
|
+
# Collect connected node names from elements in this block only
|
|
163
|
+
connected = set()
|
|
164
|
+
for elem in self.elements:
|
|
165
|
+
if isinstance(elem, (Pipe, Pump)):
|
|
166
|
+
if getattr(elem, "start_node", None) and getattr(elem, "end_node", None):
|
|
167
|
+
connected.update([elem.start_node.name, elem.end_node.name])
|
|
168
|
+
elif isinstance(elem, (Vessel, Equipment)):
|
|
169
|
+
for n in getattr(elem, "inlet_nodes", []) or []:
|
|
170
|
+
connected.add(n.name)
|
|
171
|
+
for n in getattr(elem, "outlet_nodes", []) or []:
|
|
172
|
+
connected.add(n.name)
|
|
173
|
+
elif isinstance(elem, PipelineNetwork):
|
|
174
|
+
# recurse into child blocks for validation too
|
|
175
|
+
try:
|
|
176
|
+
elem.validate()
|
|
177
|
+
except ValueError as e:
|
|
178
|
+
errors.append(f"In subnetwork '{elem.name}': {e}")
|
|
179
|
+
|
|
180
|
+
for node_name in self.nodes:
|
|
181
|
+
if node_name not in connected:
|
|
182
|
+
errors.append(f"Node '{node_name}' is not connected to any component.")
|
|
183
|
+
|
|
184
|
+
# in PipelineNetwork.validate()
|
|
185
|
+
for elem in self.elements:
|
|
186
|
+
if isinstance(elem, Fitting):
|
|
187
|
+
if not isinstance(elem.diameter, Diameter):
|
|
188
|
+
errors.append(f"Fitting '{elem.fitting_type}' requires 'diameter' as Diameter unit.")
|
|
189
|
+
if elem.total_K() is None and elem.equivalent_length() is None:
|
|
190
|
+
errors.append(f"Fitting '{elem.fitting_type}' has neither K nor equivalent length in standards.")
|
|
191
|
+
if elem.__class__.__name__ == "Pump":
|
|
192
|
+
if (getattr(elem, "head", 0) in (None, 0)) and not (getattr(elem, "inlet_pressure", None) and getattr(elem, "outlet_pressure", None)):
|
|
193
|
+
errors.append("Pump requires either (inlet & outlet pressures) or a non-zero head.")
|
|
194
|
+
if elem.__class__.__name__ == "Equipment":
|
|
195
|
+
if getattr(elem, "pressure_drop", 0.0) is None:
|
|
196
|
+
errors.append(f"Equipment '{getattr(elem, 'name', 'equipment')}' requires 'pressure_drop' (bar).")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if errors:
|
|
200
|
+
raise ValueError("Network validation failed:\n" + "\n".join(errors))
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
# ---------------- Description ----------------
|
|
204
|
+
def describe(self, level: int = 0) -> str:
|
|
205
|
+
"""Return a string representation of the network hierarchy."""
|
|
206
|
+
indent = " " * level
|
|
207
|
+
desc = f"{indent}Network: {self.name} (connection: {self.connection_type})\n"
|
|
208
|
+
for node in self.nodes.values():
|
|
209
|
+
desc += f"{indent} Node: {node.name}, Elevation={node.elevation} m\n"
|
|
210
|
+
for element in self.elements:
|
|
211
|
+
if isinstance(element, Pipe):
|
|
212
|
+
desc += (
|
|
213
|
+
f"{indent} Pipe: {getattr(element, 'nominal_diameter', 'NA')} mm, "
|
|
214
|
+
f"{getattr(element, 'length', 'NA')} m, {getattr(element, 'material', 'NA')}, "
|
|
215
|
+
f"from {element.start_node.name} → {element.end_node.name}\n"
|
|
216
|
+
)
|
|
217
|
+
elif isinstance(element, Pump):
|
|
218
|
+
desc += (
|
|
219
|
+
f"{indent} Pump: {getattr(element, 'pump_type', 'Generic')}, "
|
|
220
|
+
f"from {element.start_node.name} → {element.end_node.name}, "
|
|
221
|
+
f"Head={getattr(element, 'head', 'NA')} m, Power={getattr(element, 'power', 'NA')} kW\n"
|
|
222
|
+
)
|
|
223
|
+
elif isinstance(element, Vessel):
|
|
224
|
+
inlets = ", ".join([n.name for n in getattr(element, "inlet_nodes", [])])
|
|
225
|
+
outlets = ", ".join([n.name for n in getattr(element, "outlet_nodes", [])])
|
|
226
|
+
desc += f"{indent} Vessel: {getattr(element, 'name', 'Vessel')}, Volume={getattr(element, 'volume', 'NA')} m³, P={getattr(element, 'pressure', 'NA')} bar, T={getattr(element, 'temperature', 'NA')}°C\n"
|
|
227
|
+
desc += f"{indent} Inlets: {inlets or '—'}\n"
|
|
228
|
+
desc += f"{indent} Outlets: {outlets or '—'}\n"
|
|
229
|
+
elif isinstance(element, Equipment):
|
|
230
|
+
inlets = ", ".join([n.name for n in getattr(element, "inlet_nodes", [])])
|
|
231
|
+
outlets = ", ".join([n.name for n in getattr(element, "outlet_nodes", [])])
|
|
232
|
+
desc += (f"{indent} Equipment: {getattr(element, 'name', 'Equipment')}, "
|
|
233
|
+
f"ΔP={getattr(element, 'pressure_drop', 'NA')} bar, "
|
|
234
|
+
f"Type={getattr(element, 'description', 'Generic')}\n")
|
|
235
|
+
desc += f"{indent} Inlet(s): {inlets or '—'}\n"
|
|
236
|
+
desc += f"{indent} Outlet(s): {outlets or '—'}\n"
|
|
237
|
+
elif isinstance(element, Fitting):
|
|
238
|
+
where = getattr(element, 'node', None)
|
|
239
|
+
at = where.name if where else "unknown"
|
|
240
|
+
desc += f"{indent} Fitting: {getattr(element, 'fitting_type', 'Fitting')}, at {at}\n"
|
|
241
|
+
elif isinstance(element, PipelineNetwork):
|
|
242
|
+
desc += element.describe(level + 1)
|
|
243
|
+
return desc
|
|
244
|
+
|
|
245
|
+
# ---------------- ASCII Schematic (engine-friendly) ----------------------
|
|
246
|
+
def _schematic_lines(self, prefix: str = "") -> List[str]:
|
|
247
|
+
lines: List[str] = []
|
|
248
|
+
header = f"{prefix}{self.name} [{self.connection_type}]"
|
|
249
|
+
lines.append(header)
|
|
250
|
+
child_prefix = prefix + " "
|
|
251
|
+
if self.connection_type == "series":
|
|
252
|
+
for i, el in enumerate(self.elements, start=1):
|
|
253
|
+
if isinstance(el, PipelineNetwork):
|
|
254
|
+
lines.extend(el._schematic_lines(child_prefix))
|
|
255
|
+
else:
|
|
256
|
+
label = getattr(el, "name", el.__class__.__name__)
|
|
257
|
+
lines.append(f"{child_prefix}{i}. {label}")
|
|
258
|
+
else: # parallel
|
|
259
|
+
for i, br in enumerate(self.elements, start=1):
|
|
260
|
+
if isinstance(br, PipelineNetwork):
|
|
261
|
+
lines.append(f"{child_prefix}(branch {i})")
|
|
262
|
+
lines.extend(br._schematic_lines(child_prefix + " "))
|
|
263
|
+
else:
|
|
264
|
+
label = getattr(br, "name", br.__class__.__name__)
|
|
265
|
+
lines.append(f"{child_prefix}├─(branch {i}) {label}")
|
|
266
|
+
return lines
|
|
267
|
+
|
|
268
|
+
def schematic(self, level: int = 0) -> str:
|
|
269
|
+
"""
|
|
270
|
+
Generate ASCII schematic representation of the pipeline network.
|
|
271
|
+
Kept compatible with the PipelineEngine which calls `network.schematic()`.
|
|
272
|
+
"""
|
|
273
|
+
return "\n".join(self._schematic_lines())
|
|
274
|
+
|
|
275
|
+
# ---------------- Python niceties ----------------------------------------
|
|
276
|
+
def __repr__(self) -> str:
|
|
277
|
+
return f"PipelineNetwork(name={self.name!r}, type={self.connection_type!r}, elements={len(self.elements)})"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# processpi/pipelines/pipelineresults.py
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from ..units import Diameter, Length, Pressure
|
|
5
|
+
|
|
6
|
+
class PipelineResults:
|
|
7
|
+
def __init__(self, results: dict):
|
|
8
|
+
self.results = results
|
|
9
|
+
|
|
10
|
+
def summary(self):
|
|
11
|
+
"""Print a clean, human-readable summary of the last run."""
|
|
12
|
+
results = self.results
|
|
13
|
+
if not results:
|
|
14
|
+
print("No results available. Run the engine first.")
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
print("\n=== Pipeline Summary ===")
|
|
18
|
+
|
|
19
|
+
# -------------------- Network summary --------------------
|
|
20
|
+
summary = results.get("summary", {})
|
|
21
|
+
if summary:
|
|
22
|
+
inlet_flow = summary.get("inlet_flow_m3_s")
|
|
23
|
+
total_dp = summary.get("total_pressure_drop_Pa")
|
|
24
|
+
if inlet_flow:
|
|
25
|
+
fval = inlet_flow.value if hasattr(inlet_flow, "value") else inlet_flow
|
|
26
|
+
print(f"Inlet Flow: {fval:.3f} m³/s")
|
|
27
|
+
if total_dp:
|
|
28
|
+
fval = total_dp.value if hasattr(total_dp, "value") else total_dp
|
|
29
|
+
print(f"Total Pressure Drop: {fval:.2f} Pa")
|
|
30
|
+
|
|
31
|
+
# -------------------- Single Pipe Mode --------------------
|
|
32
|
+
if results.get("mode") == "single":
|
|
33
|
+
pipe_data = results.get("pipe", {})
|
|
34
|
+
d = pipe_data.get("internal_diameter")
|
|
35
|
+
l = pipe_data.get("length")
|
|
36
|
+
v = results.get("velocity_m_s")
|
|
37
|
+
Re = results.get("reynolds_number")
|
|
38
|
+
f = results.get("friction_factor")
|
|
39
|
+
dp = results.get("pressure_drop_Pa")
|
|
40
|
+
|
|
41
|
+
print("\n--- Single Pipe ---")
|
|
42
|
+
if d:
|
|
43
|
+
print(f"Pipe Internal Diameter: {d.value:.2f} mm")
|
|
44
|
+
if l:
|
|
45
|
+
print(f"Pipe Length: {l.value:.2f} m")
|
|
46
|
+
if v:
|
|
47
|
+
print(f"Velocity: {v.value:.3f} m/s")
|
|
48
|
+
if Re is not None:
|
|
49
|
+
re_val = Re.value if hasattr(Re, "value") else Re
|
|
50
|
+
print(f"Reynolds Number: {re_val:.0f}")
|
|
51
|
+
if f is not None:
|
|
52
|
+
fval = f.value if hasattr(f, "value") else f
|
|
53
|
+
print(f"Friction Factor: {fval:.4f}")
|
|
54
|
+
if dp:
|
|
55
|
+
print(f"Pressure Drop: {dp.value:.2f} Pa")
|
|
56
|
+
|
|
57
|
+
# -------------------- Network / Multiple Pipes --------------------
|
|
58
|
+
pipes = results.get("pipes", [])
|
|
59
|
+
if pipes:
|
|
60
|
+
print("\n--- Pipe & Fitting Details ---")
|
|
61
|
+
for p in pipes:
|
|
62
|
+
if p["type"] == "pipe":
|
|
63
|
+
d = p.get("diameter_m")
|
|
64
|
+
v = p.get("velocity_m_s")
|
|
65
|
+
Re = p.get("reynolds_number")
|
|
66
|
+
f = p.get("friction_factor")
|
|
67
|
+
dp = p.get("pressure_drop_Pa")
|
|
68
|
+
print(f"\nPipe: {p.get('name','pipe')}")
|
|
69
|
+
if d:
|
|
70
|
+
d_val = d.value if hasattr(d, "value") else d
|
|
71
|
+
print(f" Diameter: {d_val:.2f} mm")
|
|
72
|
+
if v:
|
|
73
|
+
v_val = v.value if hasattr(v, "value") else v
|
|
74
|
+
print(f" Velocity: {v_val:.3f} m/s")
|
|
75
|
+
if Re is not None:
|
|
76
|
+
re_val = Re.value if hasattr(Re, "value") else Re
|
|
77
|
+
print(f" Reynolds #: {re_val:.0f}")
|
|
78
|
+
if f is not None:
|
|
79
|
+
f_val = f.value if hasattr(f, "value") else f
|
|
80
|
+
print(f" Friction Factor: {f_val:.4f}")
|
|
81
|
+
if dp:
|
|
82
|
+
dp_val = dp.value if hasattr(dp, "value") else dp
|
|
83
|
+
print(f" Pressure Drop: {dp_val:.2f} Pa")
|
|
84
|
+
elif p["type"] == "fitting":
|
|
85
|
+
dp = p.get("pressure_drop_Pa")
|
|
86
|
+
print(f"\nFitting: {p.get('name','fitting')}")
|
|
87
|
+
print(f" K-factor: {p.get('K')}")
|
|
88
|
+
if dp:
|
|
89
|
+
dp_val = dp.value if hasattr(dp, "value") else dp
|
|
90
|
+
print(f" Pressure Drop: {dp_val:.2f} Pa")
|
|
91
|
+
|
|
92
|
+
# -------------------- Schematic --------------------
|
|
93
|
+
if "schematic" in results:
|
|
94
|
+
print("\n=== Schematic ===")
|
|
95
|
+
print(results["schematic"])
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# processpi/pipelines/pipes.py
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Dict
|
|
4
|
+
from .base import PipelineBase
|
|
5
|
+
from ..units import *
|
|
6
|
+
from .standards import get_roughness, get_internal_diameter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Pipe(PipelineBase):
|
|
10
|
+
"""
|
|
11
|
+
Represents a process pipeline element with geometric and material properties.
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
nominal_diameter (Diameter): Nominal diameter of the pipe (mm or inch).
|
|
15
|
+
schedule (str): Pipe schedule (e.g., '40', '80', 'STD', 'XS').
|
|
16
|
+
material (str): Pipe material (e.g., 'CS', 'SS316', 'PVC').
|
|
17
|
+
length (Length): Length of the pipe.
|
|
18
|
+
roughness (float): Absolute roughness of the pipe (mm).
|
|
19
|
+
internal_diameter (Diameter): Internal diameter based on nominal diameter and schedule.
|
|
20
|
+
inlet_pressure (Pressure): Optional inlet pressure for the pipe.
|
|
21
|
+
outlet_pressure (Pressure): Optional outlet pressure for the pipe.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
nominal_diameter: Diameter,
|
|
27
|
+
schedule: str = "40",
|
|
28
|
+
material: str = "CS",
|
|
29
|
+
length: Optional[Length] = None,
|
|
30
|
+
inlet_pressure: Optional[Pressure] = None,
|
|
31
|
+
outlet_pressure: Optional[Pressure] = None,
|
|
32
|
+
):
|
|
33
|
+
self.nominal_diameter = nominal_diameter
|
|
34
|
+
self.schedule = schedule
|
|
35
|
+
self.material = material
|
|
36
|
+
self.length = length or Length(1.0, "m") # default 1 meter
|
|
37
|
+
self.roughness = get_roughness(self.material)
|
|
38
|
+
self.internal_diameter = get_internal_diameter(self.nominal_diameter, self.schedule)
|
|
39
|
+
self.inlet_pressure = inlet_pressure # user can set this explicitly
|
|
40
|
+
self.outlet_pressure = outlet_pressure # user can set this explicitly
|
|
41
|
+
|
|
42
|
+
def cross_sectional_area(self) -> float:
|
|
43
|
+
"""
|
|
44
|
+
Calculate cross-sectional flow area (m²).
|
|
45
|
+
"""
|
|
46
|
+
d_m = self.internal_diameter.to("m").value
|
|
47
|
+
return 3.14159 * (d_m / 2) ** 2
|
|
48
|
+
|
|
49
|
+
def surface_area(self) -> float:
|
|
50
|
+
"""
|
|
51
|
+
Calculate external surface area (m²) for heat loss calculations.
|
|
52
|
+
"""
|
|
53
|
+
d_m = self.nominal_diameter.to("m").value
|
|
54
|
+
return 3.14159 * d_m * self.length.to("m").value
|
|
55
|
+
|
|
56
|
+
def pressure_difference(self) -> Optional[Pressure]:
|
|
57
|
+
"""
|
|
58
|
+
Calculate the pressure difference (inlet - outlet) if both are defined.
|
|
59
|
+
"""
|
|
60
|
+
if self.inlet_pressure and self.outlet_pressure:
|
|
61
|
+
return self.inlet_pressure - self.outlet_pressure
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> Dict[str, Optional[float]]:
|
|
65
|
+
"""
|
|
66
|
+
Export pipe properties as dictionary for reporting.
|
|
67
|
+
"""
|
|
68
|
+
return {
|
|
69
|
+
"nominal_diameter_mm": self.nominal_diameter.value,
|
|
70
|
+
"schedule": self.schedule,
|
|
71
|
+
"material": self.material,
|
|
72
|
+
"length_m": self.length.to("m").value,
|
|
73
|
+
"roughness_mm": self.roughness,
|
|
74
|
+
"internal_diameter_mm": self.internal_diameter.value if self.internal_diameter else None,
|
|
75
|
+
"inlet_pressure_Pa": self.inlet_pressure.to("Pa").value if self.inlet_pressure else None,
|
|
76
|
+
"outlet_pressure_Pa": self.outlet_pressure.to("Pa").value if self.outlet_pressure else None,
|
|
77
|
+
"pressure_difference_Pa": self.pressure_difference().to("Pa").value if self.pressure_difference() else None,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def calculate(self):
|
|
81
|
+
"""
|
|
82
|
+
Placeholder for pipe-specific calculations.
|
|
83
|
+
"""
|
|
84
|
+
pass
|