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,346 @@
|
|
|
1
|
+
# processpi/calculations/mass_transfer/distillation_stage_count.py
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Tuple, Callable, Optional
|
|
4
|
+
from ..base import CalculationBase
|
|
5
|
+
from ...units import *
|
|
6
|
+
|
|
7
|
+
class DistillationStageCount(CalculationBase):
|
|
8
|
+
"""
|
|
9
|
+
Detailed distillation stage calculations.
|
|
10
|
+
|
|
11
|
+
Supports two workflows:
|
|
12
|
+
1) Fenske minimum stages (binary):
|
|
13
|
+
- Inputs:
|
|
14
|
+
alpha_avg: float # Average relative volatility (LK relative to HK), alpha > 1
|
|
15
|
+
xD: float # LK mole fraction in distillate
|
|
16
|
+
xB: float # LK mole fraction in bottoms
|
|
17
|
+
- Output:
|
|
18
|
+
{"Nmin_theoretical": float}
|
|
19
|
+
|
|
20
|
+
2) McCabe–Thiele graphical stepping (binary):
|
|
21
|
+
- Inputs:
|
|
22
|
+
xD: float # LK in distillate
|
|
23
|
+
xB: float # LK in bottoms
|
|
24
|
+
zF: float # LK in feed
|
|
25
|
+
R: float # Reflux ratio (L/D)
|
|
26
|
+
q: float # Feed thermal condition (q-line slope param)
|
|
27
|
+
eq_curve: List[Tuple[float,float]] or callable x->y_eq
|
|
28
|
+
# Equilibrium data: y = f(x). You can pass:
|
|
29
|
+
# - list of (x,y) pairs (monotonic in x), OR
|
|
30
|
+
# - a callable y_eq(x)
|
|
31
|
+
total_condenser: bool = True # If True, add total condenser stage
|
|
32
|
+
partial_reboiler: bool = True # If True, count reboiler as a stage
|
|
33
|
+
max_stages: int = 300 # Safety limit for stepping
|
|
34
|
+
tol: float = 1e-6 # Numeric tolerance
|
|
35
|
+
- Assumptions:
|
|
36
|
+
* Binary system (LK/HK).
|
|
37
|
+
* Constant molar overflow (straight rectifying/stripping lines).
|
|
38
|
+
* Feed plate determined by q-line intersection.
|
|
39
|
+
- Output:
|
|
40
|
+
{
|
|
41
|
+
"N_theoretical": int,
|
|
42
|
+
"rectifying_stages": int,
|
|
43
|
+
"stripping_stages": int,
|
|
44
|
+
"includes_total_condenser": bool,
|
|
45
|
+
"includes_partial_reboiler": bool
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Notes
|
|
49
|
+
-----
|
|
50
|
+
* The McCabe–Thiele stepper uses piecewise linear interpolation of eq data if provided as (x,y) pairs.
|
|
51
|
+
* Rectifying operating line: y = (R/(R+1)) x + xD/(R+1)
|
|
52
|
+
* q-line (feed): y = (q/(q-1)) x - zF/(q-1) (q != 1 case)
|
|
53
|
+
Special cases:
|
|
54
|
+
- q = 1 (saturated liquid): vertical line at x = zF
|
|
55
|
+
- q = 0 (saturated vapor): line slope 0 through y = zF
|
|
56
|
+
* Stripping line is constructed through feed intersection and (xB, xB) pinch.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# ------------- Interface -------------
|
|
60
|
+
def validate_inputs(self):
|
|
61
|
+
mode = self.inputs.get("mode", "fenske").lower()
|
|
62
|
+
if mode not in ("fenske", "mccabe_thiele"):
|
|
63
|
+
raise ValueError("mode must be either 'fenske' or 'mccabe_thiele'.")
|
|
64
|
+
|
|
65
|
+
if mode == "fenske":
|
|
66
|
+
for k in ("alpha_avg", "xD", "xB"):
|
|
67
|
+
if k not in self.inputs:
|
|
68
|
+
raise ValueError(f"Missing required input for Fenske: {k}")
|
|
69
|
+
alpha = float(self.inputs["alpha_avg"])
|
|
70
|
+
xD = float(self.inputs["xD"])
|
|
71
|
+
xB = float(self.inputs["xB"])
|
|
72
|
+
if alpha <= 1.0:
|
|
73
|
+
raise ValueError("alpha_avg must be > 1 for binary Fenske.")
|
|
74
|
+
for x in (xD, xB):
|
|
75
|
+
if not (0.0 < x < 1.0):
|
|
76
|
+
raise ValueError("xD and xB must be in (0,1).")
|
|
77
|
+
|
|
78
|
+
if mode == "mccabe_thiele":
|
|
79
|
+
required = ("xD", "xB", "zF", "R", "q", "eq_curve")
|
|
80
|
+
for k in required:
|
|
81
|
+
if k not in self.inputs:
|
|
82
|
+
raise ValueError(f"Missing required input for McCabe–Thiele: {k}")
|
|
83
|
+
|
|
84
|
+
xD = float(self.inputs["xD"])
|
|
85
|
+
xB = float(self.inputs["xB"])
|
|
86
|
+
zF = float(self.inputs["zF"])
|
|
87
|
+
R = float(self.inputs["R"])
|
|
88
|
+
q = float(self.inputs["q"])
|
|
89
|
+
|
|
90
|
+
for x in (xD, xB, zF):
|
|
91
|
+
if not (0.0 < x < 1.0):
|
|
92
|
+
raise ValueError("Compositions xD, xB, zF must be in (0,1).")
|
|
93
|
+
if not (xB < zF < xD):
|
|
94
|
+
raise ValueError("Must satisfy xB < zF < xD (LK is more volatile).")
|
|
95
|
+
if R <= 0:
|
|
96
|
+
raise ValueError("Reflux ratio R must be > 0.")
|
|
97
|
+
# eq_curve can be callable or list of tuples
|
|
98
|
+
eq_curve = self.inputs["eq_curve"]
|
|
99
|
+
if not (callable(eq_curve) or (isinstance(eq_curve, list) and all(len(t) == 2 for t in eq_curve))):
|
|
100
|
+
raise ValueError("eq_curve must be a callable y_eq(x) or a list of (x,y) pairs.")
|
|
101
|
+
|
|
102
|
+
def calculate(self) -> Dict:
|
|
103
|
+
mode = self.inputs.get("mode", "fenske").lower()
|
|
104
|
+
if mode == "fenske":
|
|
105
|
+
return self._calc_fenske_minimum_stages()
|
|
106
|
+
else:
|
|
107
|
+
return self._calc_mccabe_thiele()
|
|
108
|
+
|
|
109
|
+
# ------------- Fenske minimum stages -------------
|
|
110
|
+
def _calc_fenske_minimum_stages(self) -> Dict:
|
|
111
|
+
alpha = float(self.inputs["alpha_avg"])
|
|
112
|
+
xD = float(self.inputs["xD"]) # LK in distillate
|
|
113
|
+
xB = float(self.inputs["xB"]) # LK in bottoms
|
|
114
|
+
|
|
115
|
+
# Fenske equation (binary; min stages at total reflux):
|
|
116
|
+
# N_min = ln[(xD/(1-xD)) * ((1-xB)/xB)] / ln(alpha)
|
|
117
|
+
import math
|
|
118
|
+
term_top = xD / (1.0 - xD)
|
|
119
|
+
term_bottom = (1.0 - xB) / xB
|
|
120
|
+
Nmin = math.log(term_top * term_bottom) / math.log(alpha)
|
|
121
|
+
|
|
122
|
+
return {"Nmin_theoretical": Nmin}
|
|
123
|
+
|
|
124
|
+
# ------------- McCabe–Thiele stepping -------------
|
|
125
|
+
def _calc_mccabe_thiele(self) -> Dict:
|
|
126
|
+
xD = float(self.inputs["xD"])
|
|
127
|
+
xB = float(self.inputs["xB"])
|
|
128
|
+
zF = float(self.inputs["zF"])
|
|
129
|
+
R = float(self.inputs["R"])
|
|
130
|
+
q = float(self.inputs["q"])
|
|
131
|
+
total_condenser = bool(self.inputs.get("total_condenser", True))
|
|
132
|
+
partial_reboiler = bool(self.inputs.get("partial_reboiler", True))
|
|
133
|
+
max_stages = int(self.inputs.get("max_stages", 300))
|
|
134
|
+
tol = float(self.inputs.get("tol", 1e-6))
|
|
135
|
+
|
|
136
|
+
# Build y_eq(x)
|
|
137
|
+
y_eq = self._make_y_eq(self.inputs["eq_curve"])
|
|
138
|
+
|
|
139
|
+
# Rectifying operating line
|
|
140
|
+
# y = (R/(R+1))*x + xD/(R+1)
|
|
141
|
+
def y_rect(x: float) -> float:
|
|
142
|
+
return (R / (R + 1.0)) * x + xD / (R + 1.0)
|
|
143
|
+
|
|
144
|
+
# q-line
|
|
145
|
+
q_is_one = abs(q - 1.0) < 1e-12
|
|
146
|
+
q_is_zero = abs(q) < 1e-12
|
|
147
|
+
if q_is_one:
|
|
148
|
+
# vertical line at x = zF
|
|
149
|
+
q_line = ("vertical", zF)
|
|
150
|
+
elif q_is_zero:
|
|
151
|
+
# horizontal line at y = zF
|
|
152
|
+
q_line = ("horizontal", zF)
|
|
153
|
+
else:
|
|
154
|
+
# general form y = m_q * x + b_q, with slope q/(q-1)
|
|
155
|
+
m_q = q / (q - 1.0)
|
|
156
|
+
b_q = -zF / (q - 1.0)
|
|
157
|
+
q_line = ("line", (m_q, b_q))
|
|
158
|
+
|
|
159
|
+
# Find intersection of rectifying line with q-line -> (xF_int, yF_int)
|
|
160
|
+
xF_int, yF_int = self._intersect_rectifying_with_qline(y_rect, q_line)
|
|
161
|
+
|
|
162
|
+
# Stripping line through intersection and (xB, xB)
|
|
163
|
+
# y = m_s * x + b_s
|
|
164
|
+
m_s = (yF_int - xB) / (xF_int - xB)
|
|
165
|
+
b_s = xB - m_s * xB
|
|
166
|
+
|
|
167
|
+
def y_strip(x: float) -> float:
|
|
168
|
+
return m_s * x + b_s
|
|
169
|
+
|
|
170
|
+
# McCabe–Thiele stepping:
|
|
171
|
+
# start at (xD, y = xD) for total condenser; otherwise start at (xD, y_rect(xD))
|
|
172
|
+
stages = 0
|
|
173
|
+
rect_stages = 0
|
|
174
|
+
strip_stages = 0
|
|
175
|
+
|
|
176
|
+
# Starting point:
|
|
177
|
+
x_cur = xD
|
|
178
|
+
y_cur = xD if total_condenser else y_rect(xD) # horizontal to equilibrium first if total condenser
|
|
179
|
+
|
|
180
|
+
# Stage loop
|
|
181
|
+
while stages < max_stages and x_cur - xB > tol:
|
|
182
|
+
# 1) Horizontal to equilibrium curve (constant y)
|
|
183
|
+
# Find x_eq such that y_eq(x_eq) = y_cur
|
|
184
|
+
x_eq = self._x_from_y_on_eq(y_cur, y_eq, x_min=xB, x_max=xD)
|
|
185
|
+
if x_eq is None:
|
|
186
|
+
# If it fails, try clipping
|
|
187
|
+
x_eq = max(min(x_cur, xD), xB)
|
|
188
|
+
|
|
189
|
+
# 2) Vertical to operating line:
|
|
190
|
+
# if x_eq >= xF_int -> rectifying, else stripping
|
|
191
|
+
if x_eq >= xF_int:
|
|
192
|
+
# rectifying
|
|
193
|
+
y_next = y_rect(x_eq)
|
|
194
|
+
rect_stages += 1
|
|
195
|
+
else:
|
|
196
|
+
# stripping
|
|
197
|
+
y_next = y_strip(x_eq)
|
|
198
|
+
strip_stages += 1
|
|
199
|
+
|
|
200
|
+
stages += 1
|
|
201
|
+
x_cur, y_cur = x_eq, y_next
|
|
202
|
+
|
|
203
|
+
# stopping if we reached the bottoms composition roughly
|
|
204
|
+
if abs(x_cur - xB) <= tol:
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
# Add reboiler stage if required
|
|
208
|
+
if partial_reboiler:
|
|
209
|
+
stages += 1
|
|
210
|
+
strip_stages += 1
|
|
211
|
+
|
|
212
|
+
# Add condenser stage if total condenser and at least one rectifying stage
|
|
213
|
+
if total_condenser and rect_stages > 0:
|
|
214
|
+
# condenser stage already inherently counted by starting at y=xD then stepping horizontal to eq,
|
|
215
|
+
# so we don't add an extra here. (Many conventions treat this first horizontal as the condenser stage.)
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"N_theoretical": stages,
|
|
220
|
+
"rectifying_stages": rect_stages,
|
|
221
|
+
"stripping_stages": strip_stages,
|
|
222
|
+
"includes_total_condenser": total_condenser,
|
|
223
|
+
"includes_partial_reboiler": partial_reboiler,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# ------------- Helpers -------------
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _make_y_eq(eq_curve: "Callable[[float], float] | List[Tuple[float,float]]") -> Callable[[float], float]:
|
|
229
|
+
"""
|
|
230
|
+
Return y_eq(x) callable from either a function or list of (x,y) data (piecewise-linear).
|
|
231
|
+
"""
|
|
232
|
+
if callable(eq_curve):
|
|
233
|
+
return eq_curve
|
|
234
|
+
|
|
235
|
+
# Build linear interpolator on sorted x
|
|
236
|
+
data = sorted(eq_curve, key=lambda t: t[0])
|
|
237
|
+
xs = [t[0] for t in data]
|
|
238
|
+
ys = [t[1] for t in data]
|
|
239
|
+
|
|
240
|
+
def interp(x: float) -> float:
|
|
241
|
+
if x <= xs[0]:
|
|
242
|
+
return ys[0]
|
|
243
|
+
if x >= xs[-1]:
|
|
244
|
+
return ys[-1]
|
|
245
|
+
# find bracket
|
|
246
|
+
import bisect
|
|
247
|
+
i = bisect.bisect_right(xs, x) - 1
|
|
248
|
+
x0, y0 = xs[i], ys[i]
|
|
249
|
+
x1, y1 = xs[i + 1], ys[i + 1]
|
|
250
|
+
# linear interpolation
|
|
251
|
+
t = (x - x0) / (x1 - x0)
|
|
252
|
+
return y0 + t * (y1 - y0)
|
|
253
|
+
|
|
254
|
+
return interp
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def _intersect_rectifying_with_qline(y_rect: Callable[[float], float],
|
|
258
|
+
q_line_spec) -> Tuple[float, float]:
|
|
259
|
+
"""
|
|
260
|
+
Compute intersection point (x*, y*) between rectifying line and q-line spec.
|
|
261
|
+
q_line_spec is either:
|
|
262
|
+
("vertical", x0), ("horizontal", y0), or ("line", (m, b)) with y = m x + b
|
|
263
|
+
"""
|
|
264
|
+
mode = q_line_spec[0]
|
|
265
|
+
if mode == "vertical":
|
|
266
|
+
x0 = q_line_spec[1]
|
|
267
|
+
return x0, y_rect(x0)
|
|
268
|
+
elif mode == "horizontal":
|
|
269
|
+
y0 = q_line_spec[1]
|
|
270
|
+
# solve y_rect(x) = y0 -> (R/(R+1))x + xD/(R+1) = y0
|
|
271
|
+
# general invert via simple 1D solve
|
|
272
|
+
def f(x): return y_rect(x) - y0
|
|
273
|
+
x = DistillationStageCount._solve_scalar(f, 0.0, 1.0)
|
|
274
|
+
return x, y0
|
|
275
|
+
else:
|
|
276
|
+
m, b = q_line_spec[1]
|
|
277
|
+
# solve y_rect(x) = m x + b
|
|
278
|
+
# r(x) = a x + c; set a x + c = m x + b -> (a - m)x = b - c
|
|
279
|
+
# where a = R/(R+1), c = xD/(R+1). We don't know R & xD here, but y_rect contains them.
|
|
280
|
+
# Use numeric solve:
|
|
281
|
+
def f(x): return y_rect(x) - (m * x + b)
|
|
282
|
+
x = DistillationStageCount._solve_scalar(f, 0.0, 1.0)
|
|
283
|
+
return x, y_rect(x)
|
|
284
|
+
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _x_from_y_on_eq(y_target: float, y_eq: Callable[[float], float],
|
|
287
|
+
x_min: float, x_max: float, tol: float = 1e-8) -> Optional[float]:
|
|
288
|
+
"""
|
|
289
|
+
Given y_target, solve y_eq(x) = y_target on [x_min, x_max] by bisection.
|
|
290
|
+
Returns None if not bracketed (shouldn’t happen with monotone eq data).
|
|
291
|
+
"""
|
|
292
|
+
f_min = y_eq(x_min) - y_target
|
|
293
|
+
f_max = y_eq(x_max) - y_target
|
|
294
|
+
if f_min * f_max > 0:
|
|
295
|
+
# No bracket
|
|
296
|
+
return None
|
|
297
|
+
# Bisection
|
|
298
|
+
a, b = x_min, x_max
|
|
299
|
+
for _ in range(200):
|
|
300
|
+
c = 0.5 * (a + b)
|
|
301
|
+
fc = y_eq(c) - y_target
|
|
302
|
+
if abs(fc) < tol:
|
|
303
|
+
return c
|
|
304
|
+
if f_min * fc <= 0:
|
|
305
|
+
b = c
|
|
306
|
+
f_max = fc
|
|
307
|
+
else:
|
|
308
|
+
a = c
|
|
309
|
+
f_min = fc
|
|
310
|
+
return 0.5 * (a + b)
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _solve_scalar(f: Callable[[float], float], lo: float, hi: float,
|
|
314
|
+
tol: float = 1e-10, maxit: int = 200) -> float:
|
|
315
|
+
"""
|
|
316
|
+
Robust bisection for continuous root on [lo, hi].
|
|
317
|
+
If no sign change, will expand interval inwardly and try secant fallback.
|
|
318
|
+
"""
|
|
319
|
+
flo, fhi = f(lo), f(hi)
|
|
320
|
+
# If no bracket, try a simple secant fallback
|
|
321
|
+
if flo * fhi > 0:
|
|
322
|
+
x0, x1 = lo, hi
|
|
323
|
+
f0, f1 = flo, fhi
|
|
324
|
+
for _ in range(maxit):
|
|
325
|
+
if abs(f1 - f0) < 1e-16:
|
|
326
|
+
break
|
|
327
|
+
x2 = x1 - f1 * (x1 - x0) / (f1 - f0)
|
|
328
|
+
f2 = f(x2)
|
|
329
|
+
if abs(f2) < tol:
|
|
330
|
+
return x2
|
|
331
|
+
x0, f0 = x1, f1
|
|
332
|
+
x1, f1 = x2, f2
|
|
333
|
+
# fallback mid
|
|
334
|
+
return 0.5 * (lo + hi)
|
|
335
|
+
|
|
336
|
+
a, b = lo, hi
|
|
337
|
+
for _ in range(maxit):
|
|
338
|
+
c = 0.5 * (a + b)
|
|
339
|
+
fc = f(c)
|
|
340
|
+
if abs(fc) < tol:
|
|
341
|
+
return c
|
|
342
|
+
if flo * fc <= 0:
|
|
343
|
+
b, fhi = c, fc
|
|
344
|
+
else:
|
|
345
|
+
a, flo = c, fc
|
|
346
|
+
return 0.5 * (a + b)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# processpi/calculations/mass_transfer/drying_rate.py
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from ..base import CalculationBase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DryingRate(CalculationBase):
|
|
8
|
+
"""
|
|
9
|
+
Detailed batch drying time and flux calculations across
|
|
10
|
+
constant-rate and falling-rate periods.
|
|
11
|
+
|
|
12
|
+
Two workflows:
|
|
13
|
+
|
|
14
|
+
(A) Use direct constant-rate flux + falling-rate coefficient
|
|
15
|
+
Inputs:
|
|
16
|
+
M_dry: float # Dry solid mass (kg dry basis)
|
|
17
|
+
A: float # Exposed drying area (m^2)
|
|
18
|
+
X_i: float # Initial moisture content (kg water/kg dry solid)
|
|
19
|
+
X_f: float # Final moisture content (kg water/kg dry solid)
|
|
20
|
+
X_c: float # Critical moisture content (kg/kg dry solid)
|
|
21
|
+
X_star: float # Equilibrium moisture content (kg/kg dry solid)
|
|
22
|
+
N_c: float # Constant-rate flux (kg/m^2/s), e.g., measured or from HT/MT analysis
|
|
23
|
+
k_f: float # Falling-rate coefficient (1/s) with linear-w.r.t-free-moisture assumption
|
|
24
|
+
Output:
|
|
25
|
+
{
|
|
26
|
+
"t_constant_s": float,
|
|
27
|
+
"t_falling_s": float,
|
|
28
|
+
"t_total_s": float,
|
|
29
|
+
"avg_flux_kg_m2_s": float
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Model:
|
|
33
|
+
* Constant-rate period:
|
|
34
|
+
t_c = (M_dry/A) * (X_i - X_c) / N_c
|
|
35
|
+
* Falling-rate (linear w.r.t free moisture X - X*):
|
|
36
|
+
N = k_f * (X - X_star)
|
|
37
|
+
t_f = (M_dry/A) * ln( (X_c - X_star) / (X_f - X_star) ) / k_f
|
|
38
|
+
|
|
39
|
+
(B) Compute constant-rate flux from a mass-transfer coefficient (Lewis analogy style)
|
|
40
|
+
Inputs:
|
|
41
|
+
M_dry, A, X_i, X_f, X_c, X_star (as above)
|
|
42
|
+
h_m: float # Mass-transfer coefficient (m/s)
|
|
43
|
+
rho_v: float # Vapor density at interface/film (kg/m^3)
|
|
44
|
+
Y_s: float # Humidity ratio (kg vapor/kg dry gas) at surface (saturation)
|
|
45
|
+
Y_inf: float # Humidity ratio in bulk gas (kg/kg_dry_gas)
|
|
46
|
+
k_f: float # Falling-rate coefficient (1/s)
|
|
47
|
+
Output: same keys as (A)
|
|
48
|
+
Model:
|
|
49
|
+
N_c = h_m * rho_v * (Y_s - Y_inf)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def validate_inputs(self):
|
|
53
|
+
mode = self.inputs.get("mode", "direct").lower()
|
|
54
|
+
if mode not in ("direct", "with_km"):
|
|
55
|
+
raise ValueError("mode must be either 'direct' (A) or 'with_km' (B).")
|
|
56
|
+
|
|
57
|
+
required_common = ("M_dry", "A", "X_i", "X_f", "X_c", "X_star", "k_f")
|
|
58
|
+
for k in required_common:
|
|
59
|
+
if k not in self.inputs:
|
|
60
|
+
raise ValueError(f"Missing required input: {k}")
|
|
61
|
+
|
|
62
|
+
M_dry = float(self.inputs["M_dry"])
|
|
63
|
+
A = float(self.inputs["A"])
|
|
64
|
+
X_i = float(self.inputs["X_i"])
|
|
65
|
+
X_f = float(self.inputs["X_f"])
|
|
66
|
+
X_c = float(self.inputs["X_c"])
|
|
67
|
+
X_star = float(self.inputs["X_star"])
|
|
68
|
+
k_f = float(self.inputs["k_f"])
|
|
69
|
+
|
|
70
|
+
if M_dry <= 0 or A <= 0:
|
|
71
|
+
raise ValueError("M_dry and A must be > 0.")
|
|
72
|
+
if not (X_star <= X_f < X_c <= X_i):
|
|
73
|
+
raise ValueError("Require X_star <= X_f < X_c <= X_i.")
|
|
74
|
+
|
|
75
|
+
if k_f <= 0:
|
|
76
|
+
raise ValueError("k_f must be > 0.")
|
|
77
|
+
|
|
78
|
+
if mode == "direct":
|
|
79
|
+
if "N_c" not in self.inputs:
|
|
80
|
+
raise ValueError("Missing required input for 'direct' mode: N_c")
|
|
81
|
+
if float(self.inputs["N_c"]) <= 0:
|
|
82
|
+
raise ValueError("N_c must be > 0.")
|
|
83
|
+
|
|
84
|
+
if mode == "with_km":
|
|
85
|
+
for k in ("h_m", "rho_v", "Y_s", "Y_inf"):
|
|
86
|
+
if k not in self.inputs:
|
|
87
|
+
raise ValueError(f"Missing required input for 'with_km' mode: {k}")
|
|
88
|
+
|
|
89
|
+
def calculate(self) -> Dict:
|
|
90
|
+
mode = self.inputs.get("mode", "direct").lower()
|
|
91
|
+
|
|
92
|
+
# Common data
|
|
93
|
+
M_dry = float(self.inputs["M_dry"])
|
|
94
|
+
A = float(self.inputs["A"])
|
|
95
|
+
X_i = float(self.inputs["X_i"])
|
|
96
|
+
X_f = float(self.inputs["X_f"])
|
|
97
|
+
X_c = float(self.inputs["X_c"])
|
|
98
|
+
X_star = float(self.inputs["X_star"])
|
|
99
|
+
k_f = float(self.inputs["k_f"])
|
|
100
|
+
|
|
101
|
+
# Constant-rate flux
|
|
102
|
+
if mode == "direct":
|
|
103
|
+
N_c = float(self.inputs["N_c"])
|
|
104
|
+
else:
|
|
105
|
+
h_m = float(self.inputs["h_m"])
|
|
106
|
+
rho_v = float(self.inputs["rho_v"])
|
|
107
|
+
Y_s = float(self.inputs["Y_s"])
|
|
108
|
+
Y_inf = float(self.inputs["Y_inf"])
|
|
109
|
+
N_c = h_m * rho_v * (Y_s - Y_inf)
|
|
110
|
+
|
|
111
|
+
# Constant-rate time
|
|
112
|
+
t_c = (M_dry / A) * (X_i - X_c) / N_c
|
|
113
|
+
|
|
114
|
+
# Falling-rate time (linear model: N = k_f (X - X*))
|
|
115
|
+
# t_f = (M/A) * ∫ dX / (k_f (X - X*)) from X_f..X_c
|
|
116
|
+
# = (M/A) * [ln((X_c - X*)/(X_f - X*))] / k_f
|
|
117
|
+
import math
|
|
118
|
+
t_f = (M_dry / A) * math.log((X_c - X_star) / (X_f - X_star)) / k_f
|
|
119
|
+
|
|
120
|
+
t_total = t_c + t_f
|
|
121
|
+
|
|
122
|
+
# Average flux over whole drying:
|
|
123
|
+
# Total water removed per area = (M/A) * (X_i - X_f)
|
|
124
|
+
total_water_per_area = (M_dry / A) * (X_i - X_f)
|
|
125
|
+
avg_flux = total_water_per_area / t_total
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
"t_constant_s": t_c,
|
|
129
|
+
"t_falling_s": t_f,
|
|
130
|
+
"t_total_s": t_total,
|
|
131
|
+
"avg_flux_kg_m2_s": avg_flux
|
|
132
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# processpi/calculations/reaction_engineering/catalyst_activity.py
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
from ..base import CalculationBase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CatalystActivity(CalculationBase):
|
|
8
|
+
"""
|
|
9
|
+
Catalyst activity decay & temperature correction utilities.
|
|
10
|
+
|
|
11
|
+
Workflows (select with `model`):
|
|
12
|
+
--------------------------------
|
|
13
|
+
1) First-order deactivation:
|
|
14
|
+
model = "first_order"
|
|
15
|
+
Inputs:
|
|
16
|
+
k_d or (A_d, Ea_d, T[, R]) # deactivation constant (1/time) or Arrhenius params
|
|
17
|
+
t: float # time on stream
|
|
18
|
+
a0: float = 1.0 # initial activity
|
|
19
|
+
Output:
|
|
20
|
+
{"a": float}
|
|
21
|
+
Equation:
|
|
22
|
+
a(t) = a0 * exp(-k_d * t)
|
|
23
|
+
|
|
24
|
+
2) Power-law deactivation order n:
|
|
25
|
+
model = "power_order"
|
|
26
|
+
Inputs:
|
|
27
|
+
n: float # deactivation order (n >= 0)
|
|
28
|
+
k_d or (A_d, Ea_d, T[, R]) # deactivation constant or Arrhenius
|
|
29
|
+
t: float
|
|
30
|
+
a0: float = 1.0
|
|
31
|
+
Output:
|
|
32
|
+
{"a": float}
|
|
33
|
+
Equation:
|
|
34
|
+
da/dt = -k_d * a^n
|
|
35
|
+
=> if n = 1: a = a0 exp(-k_d t)
|
|
36
|
+
if n != 1: a = a0 * [1 + (n-1) k_d t / a0^{(n-1)}]^(-1/(n-1))
|
|
37
|
+
|
|
38
|
+
3) Temperature correction for activity-related constants (Arrhenius):
|
|
39
|
+
model = "arrhenius_correction"
|
|
40
|
+
Inputs:
|
|
41
|
+
A_d, Ea_d, T[, R]
|
|
42
|
+
Output:
|
|
43
|
+
{"k_d": float}
|
|
44
|
+
Equation:
|
|
45
|
+
k_d = A_d * exp(-Ea_d/(R*T))
|
|
46
|
+
|
|
47
|
+
Notes
|
|
48
|
+
-----
|
|
49
|
+
* For consistency, default R = 8.314 if not supplied.
|
|
50
|
+
* a0 typically in [0,1], but any positive value is allowed for generic scaling.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def validate_inputs(self):
|
|
54
|
+
model = self.inputs.get("model", "first_order").lower()
|
|
55
|
+
if model not in ("first_order", "power_order", "arrhenius_correction"):
|
|
56
|
+
raise ValueError("model must be 'first_order', 'power_order', or 'arrhenius_correction'.")
|
|
57
|
+
|
|
58
|
+
if model in ("first_order", "power_order"):
|
|
59
|
+
if "t" not in self.inputs:
|
|
60
|
+
raise ValueError("Missing required input: t")
|
|
61
|
+
if "k_d" not in self.inputs and not all(k in self.inputs for k in ("A_d", "Ea_d", "T")):
|
|
62
|
+
raise ValueError("Provide 'k_d' or Arrhenius params (A_d, Ea_d, T).")
|
|
63
|
+
if model == "power_order" and "n" not in self.inputs:
|
|
64
|
+
raise ValueError("power_order requires 'n'.")
|
|
65
|
+
|
|
66
|
+
if model == "arrhenius_correction":
|
|
67
|
+
for k in ("A_d", "Ea_d", "T"):
|
|
68
|
+
if k not in self.inputs:
|
|
69
|
+
raise ValueError(f"Missing required input: {k}")
|
|
70
|
+
|
|
71
|
+
def _arrhenius_kd(self) -> float:
|
|
72
|
+
"""Compute k_d from Arrhenius parameters if needed."""
|
|
73
|
+
import math
|
|
74
|
+
A = float(self.inputs["A_d"])
|
|
75
|
+
Ea = float(self.inputs["Ea_d"])
|
|
76
|
+
T = float(self.inputs["T"])
|
|
77
|
+
R = float(self.inputs.get("R", 8.314))
|
|
78
|
+
return A * math.exp(-Ea / (R * T))
|
|
79
|
+
|
|
80
|
+
def calculate(self) -> Dict:
|
|
81
|
+
import math
|
|
82
|
+
model = self.inputs.get("model", "first_order").lower()
|
|
83
|
+
|
|
84
|
+
if model == "arrhenius_correction":
|
|
85
|
+
return {"k_d": self._arrhenius_kd()}
|
|
86
|
+
|
|
87
|
+
# Common pieces for models producing activity a(t)
|
|
88
|
+
t = float(self.inputs["t"])
|
|
89
|
+
a0 = float(self.inputs.get("a0", 1.0))
|
|
90
|
+
k_d = float(self.inputs.get("k_d", self._arrhenius_kd()))
|
|
91
|
+
if t < 0 or a0 <= 0 or k_d < 0:
|
|
92
|
+
raise ValueError("Require t >= 0, a0 > 0, k_d >= 0.")
|
|
93
|
+
|
|
94
|
+
if model == "first_order":
|
|
95
|
+
a = a0 * math.exp(-k_d * t)
|
|
96
|
+
return {"a": a}
|
|
97
|
+
|
|
98
|
+
# power_order
|
|
99
|
+
n = float(self.inputs["n"])
|
|
100
|
+
if n == 1.0:
|
|
101
|
+
a = a0 * math.exp(-k_d * t)
|
|
102
|
+
else:
|
|
103
|
+
# a = a0 * [1 + (n-1) k_d t / a0^{(n-1)}]^(-1/(n-1))
|
|
104
|
+
denom = 1.0 + (n - 1.0) * k_d * t / (a0 ** (n - 1.0))
|
|
105
|
+
if denom <= 0:
|
|
106
|
+
# Model breakdown; clamp to small positive value
|
|
107
|
+
denom = 1e-12
|
|
108
|
+
a = a0 * (denom ** (-1.0 / (n - 1.0)))
|
|
109
|
+
return {"a": a}
|