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,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,4 @@
1
+ from .catalyst_activity import CatalystActivity
2
+ from .reaction_rate import ReactionRate
3
+ from .residence_time import ResidenceTime
4
+ __all__ = ["CatalystActivity", "ReactionRate", "ResidenceTime"]
@@ -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}