pathsim-fusion 0.1.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.
@@ -0,0 +1,29 @@
1
+ """
2
+ PathSim-Fusion: Fusion fuel cycle and balance-of-plant blocks for PathSim
3
+
4
+ Specialized simulation blocks for the PathSim framework. All blocks follow
5
+ the standard PathSim block interface and can be connected into simulation
6
+ diagrams.
7
+ """
8
+
9
+ try:
10
+ from ._version import version as __version__
11
+ except ImportError:
12
+ __version__ = "unknown"
13
+
14
+ # Pure-Python blocks — eager.
15
+ from .tritium import GLC, Bubbler4, IonisationChamber
16
+
17
+ __all__ = ["GLC", "Bubbler4", "IonisationChamber", "__version__"]
18
+
19
+ # If you add blocks that depend on a native package (e.g. pybamm, jsbsim,
20
+ # casadi, …) put them in their own submodule and re-export them defensively
21
+ # here so the toolbox still imports when the heavy dependency is missing
22
+ # (e.g. in Pyodide). Pair this with a PEP 508 environment marker in
23
+ # pyproject.toml — see the comment there for an example.
24
+ #
25
+ # try:
26
+ # from .native_block import HeavyBlock
27
+ # __all__ += ["HeavyBlock"]
28
+ # except ImportError:
29
+ # pass
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,5 @@
1
+ from .bubbler import Bubbler4
2
+ from .glc import GLC
3
+ from .ionisation_chamber import IonisationChamber
4
+
5
+ __all__ = ["GLC", "Bubbler4", "IonisationChamber"]
@@ -0,0 +1,240 @@
1
+ #########################################################################################
2
+ ##
3
+ ## Bubbler Block
4
+ ##
5
+ #########################################################################################
6
+
7
+ # IMPORTS ===============================================================================
8
+
9
+ import numbers
10
+ from typing import ClassVar
11
+
12
+ import numpy as np
13
+ from pathsim.blocks.dynsys import DynamicalSystem
14
+ from pathsim.events.schedule import ScheduleList
15
+
16
+ # BLOCK DEFINITIONS =====================================================================
17
+
18
+
19
+ class Bubbler4(DynamicalSystem):
20
+ """Tritium bubbling system with sequential vial collection stages.
21
+
22
+ This block models a tritium collection system used in fusion reactor blanket
23
+ purge gas processing. The system bubbles tritium-containing gas through a series
24
+ of liquid-filled vials to capture and concentrate tritium for measurement and
25
+ inventory tracking.
26
+
27
+
28
+ Physical Description
29
+ --------------------
30
+ The bubbler consists of two parallel processing chains:
31
+
32
+ **Soluble Chain (Vials 1-2):**
33
+ Tritium already in soluble forms (HTO, HT) flows sequentially through
34
+ vials 1 and 2. Each vial has a collection efficiency :math:`\\eta_{vial}`,
35
+ representing the fraction of tritium that dissolves into the liquid phase
36
+ and is retained.
37
+
38
+ **Insoluble Chain (Vials 3-4):**
39
+ Tritium in insoluble forms (T₂, organically bound) first undergoes catalytic
40
+ conversion to soluble forms with efficiency :math:`\\alpha_{conv}`. The
41
+ converted tritium, along with uncaptured soluble tritium from the first chain,
42
+ then flows through vials 3 and 4 with the same collection efficiency.
43
+
44
+
45
+ Mathematical Formulation
46
+ -------------------------
47
+ The system is governed by the following differential equations for the
48
+ vial inventories :math:`x_i`:
49
+
50
+ .. math::
51
+
52
+ \\begin{align}
53
+ \\frac{dx_1}{dt} &= \\eta_{vial} \\cdot u_{sol} \\\\
54
+ \\frac{dx_2}{dt} &= \\eta_{vial} \\cdot (1-\\eta_{vial}) \\cdot u_{sol} \\\\
55
+ \\frac{dx_3}{dt} &= \\eta_{vial} \\cdot [\\alpha_{conv} \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot u_{sol}] \\\\
56
+ \\frac{dx_4}{dt} &= \\eta_{vial} \\cdot (1-\\eta_{vial}) \\cdot [\\alpha_{conv} \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot u_{sol}]
57
+ \\end{align}
58
+
59
+
60
+ The sample output represents uncaptured tritium exiting the system:
61
+
62
+ .. math::
63
+
64
+ y_{sample} = (1-\\alpha_{conv}) \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot [\\alpha_{conv} \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot u_{sol}]
65
+
66
+
67
+ Where:
68
+ - :math:`u_{sol}` = soluble tritium input flow rate
69
+ - :math:`u_{insol}` = insoluble tritium input flow rate
70
+ - :math:`\\eta_{vial}` = vial collection efficiency
71
+ - :math:`\\alpha_{conv}` = conversion efficiency from insoluble to soluble
72
+ - :math:`x_i` = tritium inventory in vial i
73
+
74
+ Parameters
75
+ ----------
76
+ conversion_efficiency : float
77
+ Conversion efficiency from insoluble to soluble forms (:math:`\\alpha_{conv}`),
78
+ between 0 and 1.
79
+ vial_efficiency : float
80
+ Collection efficiency of each vial (:math:`\\eta_{vial}`), between 0 and 1.
81
+ replacement_times : float | list[float] | list[list[float]] | None
82
+ Times at which each vial is replaced with a fresh one. If None, no
83
+ replacement events are created. If a single value is provided, it is
84
+ used for all vials. If a single list of floats is provided, it will be
85
+ used for all vials. If a list of four lists is provided, each sublist
86
+ corresponds to the replacement times for each vial.
87
+
88
+ Inputs
89
+ ------
90
+ sample_in_soluble (0) : soluble tritium flow rate into the bubbler
91
+ sample_in_insoluble (1) : insoluble tritium flow rate into the bubbler
92
+
93
+ Outputs
94
+ -------
95
+ vial1 (0) : tritium inventory in vial 1
96
+ vial2 (1) : tritium inventory in vial 2
97
+ vial3 (2) : tritium inventory in vial 3
98
+ vial4 (3) : tritium inventory in vial 4
99
+ sample_out (4) : uncaptured tritium flow rate leaving the bubbler
100
+
101
+ Note
102
+ ----
103
+ Vial replacement is modeled as instantaneous reset events that set the
104
+ corresponding vial inventory to zero, simulating the physical replacement
105
+ of a full vial with an empty one.
106
+ """
107
+
108
+ input_port_labels: ClassVar[dict[str, int]] = {
109
+ "sample_in_soluble": 0,
110
+ "sample_in_insoluble": 1,
111
+ }
112
+ output_port_labels: ClassVar[dict[str, int]] = {
113
+ "vial1": 0,
114
+ "vial2": 1,
115
+ "vial3": 2,
116
+ "vial4": 3,
117
+ "sample_out": 4,
118
+ }
119
+
120
+ def __init__(
121
+ self,
122
+ conversion_efficiency=0.9,
123
+ vial_efficiency=0.9,
124
+ replacement_times=None,
125
+ ):
126
+
127
+ # input validation
128
+ if not 0 <= conversion_efficiency <= 1:
129
+ raise ValueError(
130
+ "'conversion_efficiency' must be between 0 and 1 "
131
+ f"but is {conversion_efficiency}"
132
+ )
133
+ if not 0 <= vial_efficiency <= 1:
134
+ raise ValueError(
135
+ f"'vial_efficiency' must be between 0 and 1 but is {vial_efficiency}"
136
+ )
137
+
138
+ # bubbler parameters
139
+ self.replacement_times = replacement_times
140
+ self.vial_efficiency = vial_efficiency
141
+ self.conversion_efficiency = conversion_efficiency
142
+
143
+ # dynamical component, ode rhs
144
+ def _fn_d(x, u, t):
145
+
146
+ # short
147
+ ve = self.vial_efficiency
148
+ ce = self.conversion_efficiency
149
+
150
+ # unpack inputs
151
+ sol, ins = u
152
+
153
+ # compute vial content change rates
154
+ dv1 = ve * sol
155
+ dv2 = dv1 * (1 - ve)
156
+ dv3 = ve * (ce * ins + (1 - ve) ** 2 * sol)
157
+ dv4 = dv3 * (1 - ve)
158
+
159
+ return np.array([dv1, dv2, dv3, dv4])
160
+
161
+ # algebraic output component
162
+ def _fn_a(x, u, t):
163
+
164
+ # short
165
+ ve = self.vial_efficiency
166
+ ce = self.conversion_efficiency
167
+
168
+ # unpack inputs
169
+ sol, ins = u
170
+
171
+ sample_out = (1 - ce) * ins + (1 - ve) ** 2 * (
172
+ ce * ins + (1 - ve) ** 2 * sol
173
+ )
174
+
175
+ return np.hstack([x, sample_out])
176
+
177
+ # initialization just like `DynamicalSystem` block
178
+ super().__init__(
179
+ func_dyn=_fn_d,
180
+ func_alg=_fn_a,
181
+ initial_value=np.zeros(4),
182
+ )
183
+
184
+ # create internal vial reset events
185
+ self._create_reset_events()
186
+
187
+ def _create_reset_event_vial(self, i, reset_times):
188
+ """Define event action function and return a `ScheduleList` event
189
+ per vial `i` that triggers at predefined `reset_times`.
190
+ """
191
+
192
+ def reset_vial_i(_):
193
+ # get the full engine state
194
+ x = self.engine.get()
195
+ # set index 'i' to zero
196
+ x[i] = 0.0
197
+ # set the full engine state
198
+ self.engine.set(x)
199
+
200
+ return ScheduleList(times_evt=reset_times, func_act=reset_vial_i)
201
+
202
+ def _create_reset_events(self):
203
+ """Create reset events for all vials based on the replacement times.
204
+
205
+ Raises
206
+ ------
207
+ ValueError : If reset_times is not valid.
208
+ """
209
+
210
+ replacement_times = self.replacement_times
211
+ self.events = []
212
+
213
+ # no replacement
214
+ if replacement_times is None:
215
+ return
216
+
217
+ # a single time is used for all vials (numbers.Real also covers numpy scalars)
218
+ if isinstance(replacement_times, numbers.Real):
219
+ replacement_times = [[replacement_times]] * 4
220
+
221
+ # a flat list of times is used for all vials
222
+ elif isinstance(replacement_times, (list, tuple)) and all(
223
+ isinstance(t, numbers.Real) for t in replacement_times
224
+ ):
225
+ replacement_times = [list(replacement_times)] * 4
226
+
227
+ elif isinstance(replacement_times, np.ndarray) and replacement_times.ndim == 1:
228
+ replacement_times = [replacement_times.tolist()] * 4
229
+
230
+ # otherwise one list of times per vial
231
+ elif len(replacement_times) != 4:
232
+ raise ValueError(
233
+ "replacement_times must be a single value or a list with the same length as the number of vials"
234
+ )
235
+
236
+ # create the internal events
237
+ self.events = [
238
+ self._create_reset_event_vial(i, ts)
239
+ for i, ts in enumerate(replacement_times)
240
+ ]
@@ -0,0 +1,537 @@
1
+ """
2
+ Bubble column gas-liquid contactor model solver.
3
+
4
+ This module solves the coupled, non-linear, second-order ordinary differential
5
+ equations that describe tritium transport in a counter-current bubble column,
6
+ based on the model by C. Malara (1995). The boundary value problem is solved
7
+ with the native :class:`~pathsim.blocks.BVP1D` block, which warm-starts the
8
+ mesh between evaluations and skips the re-solve when the boundary data is
9
+ unchanged.
10
+ """
11
+
12
+ from typing import ClassVar
13
+
14
+ import numpy as np
15
+ import scipy.constants as const
16
+ from pathsim.blocks import BVP1D
17
+ from scipy.optimize import root_scalar
18
+
19
+ # --- Physical Constants ---
20
+ g = const.g # m/s^2, Gravitational acceleration
21
+ R = const.R # J/(mol·K), Universal gas constant
22
+ N_A = const.N_A # 1/mol, Avogadro's number
23
+ M_LiPb = 2.875e-25 # Kg/molecule, Lipb molecular mass
24
+
25
+
26
+ def _calculate_properties(params):
27
+ """
28
+ Calculate temperature-dependent and geometry-dependent physical properties.
29
+
30
+ This function computes fluid properties, flow characteristics,
31
+ dimensionless numbers, and hydrodynamic parameters based on the input
32
+ geometry and operating conditions.
33
+
34
+ Args:
35
+ params (dict): Dictionary of input parameters including T, D, Flow_l,
36
+ Flow_g, and P_0.
37
+
38
+ Returns:
39
+ dict: A dictionary containing the calculated physical properties.
40
+ """
41
+ T = params["T"]
42
+ D = params["D"]
43
+ flow_l = params["flow_l"]
44
+ flow_g = params["flow_g"]
45
+ P_in = params["P_in"]
46
+
47
+ # --- Fluid Properties (Temperature Dependent) ---
48
+ # TODO add references
49
+ rho_l = 10.45e3 * (1 - 1.61e-4 * T) # kg/m^3, Liquid (LiPb) density
50
+ sigma_l = 0.52 - 0.11e-3 * T # N/m, Surface tension, liquid-gas interface
51
+ mu_l = 1.87e-4 * np.exp(11640 / (R * T)) # Pa.s, Dynamic viscosity of liquid
52
+ nu_l = mu_l / rho_l # m^2/s, Kinematic viscosity of liquid
53
+ D_T = 2.5e-7 * np.exp(-27000 / (R * T)) # m^2/s, Tritium diffusion coeff
54
+ K_s_at = 2.32e-8 * np.exp(-1350 / (R * T)) # atfrac*Pa^0.5, Sievert's const
55
+ K_s = K_s_at * (rho_l / (M_LiPb * N_A)) # mol/(m^3·Pa^0.5)
56
+
57
+ # --- Flow Properties ---
58
+ A = np.pi * (D / 2) ** 2 # m^2, Cross-sectional area
59
+ Q_l = flow_l / rho_l # m^3/s, Volumetric liquid flow rate
60
+ Q_g = (flow_g * R * T) / P_in # m^3/s, Volumetric gas flow rate at inlet
61
+ u_l = Q_l / A # m/s, Superficial liquid velocity
62
+ u_g0 = Q_g / A # m/s, Superficial gas velocity at inlet
63
+
64
+ # --- Dimensionless Numbers for Correlations ---
65
+ Bn = (g * D**2 * rho_l) / sigma_l # Bond number
66
+ Ga = (g * D**3) / nu_l**2 # Galilei number
67
+ Sc = nu_l / D_T # Schmidt number
68
+ Fr = u_g0 / (g * D) ** 0.5 # Froude number
69
+
70
+ # --- Hydrodynamic and Mass Transfer Parameters ---
71
+ # Gas hold-up (ε_g) from correlation: C = ε_g / (1 - ε_g)^4
72
+ C = 0.2 * (Bn ** (1 / 8)) * (Ga ** (1 / 12)) * Fr
73
+
74
+ def _f_holdup(e, C_val):
75
+ return e / (1 - e) ** 4 - C_val
76
+
77
+ try:
78
+ sol = root_scalar(_f_holdup, args=(C,), bracket=[1e-12, 1 - 1e-12])
79
+ epsilon_g = sol.root
80
+ except Exception as exc:
81
+ raise RuntimeError("Failed to solve for gas hold-up ε_g") from exc
82
+
83
+ epsilon_l = 1 - epsilon_g # Liquid phase fraction
84
+
85
+ # Dispersion coefficients
86
+ E_l = (D * u_g0) / ((13 * Fr) / (1 + 6.5 * (Fr**0.8)))
87
+ E_g = (0.2 * D**2) * u_g0
88
+
89
+ # Interfacial area and mass transfer coefficients
90
+ d_b = (26 * (Bn**-0.5) * (Ga**-0.12) * (Fr**-0.12)) * D
91
+ a = 6 * epsilon_g / d_b
92
+ h_l_a = D_T * (0.6 * Sc**0.5 * Bn**0.62 * Ga**0.31 * epsilon_g**1.1) / (D**2)
93
+ h_l = h_l_a / a # Mass transfer coefficient
94
+
95
+ return {
96
+ "rho_l": rho_l,
97
+ "sigma_l": sigma_l,
98
+ "mu_l": mu_l,
99
+ "nu_l": nu_l,
100
+ "K_s": K_s,
101
+ "Q_l": Q_l,
102
+ "Q_g": Q_g,
103
+ "u_l": u_l,
104
+ "u_g0": u_g0,
105
+ "epsilon_g": epsilon_g,
106
+ "epsilon_l": epsilon_l,
107
+ "E_l": E_l,
108
+ "E_g": E_g,
109
+ "a": a,
110
+ "h_l": h_l,
111
+ }
112
+
113
+
114
+ def _calculate_dimensionless_groups(params, phys_props):
115
+ """
116
+ Calculate the dimensionless groups for the ODE system.
117
+
118
+ Args:
119
+ params (dict): Dictionary of input parameters including L, T, P_in,
120
+ and c_T_inlet.
121
+ phys_props (dict): Dictionary of physical properties calculated by
122
+ _calculate_properties.
123
+
124
+ Returns:
125
+ dict: A dictionary containing the dimensionless groups (Bo_l, phi_l,
126
+ Bo_g, phi_g, psi, nu).
127
+ """
128
+ # Unpack parameters
129
+ L, T, P_in, c_T_in = (
130
+ params["L"],
131
+ params["T"],
132
+ params["P_in"],
133
+ params["c_T_in"],
134
+ )
135
+
136
+ # Unpack physical properties
137
+ rho_l, K_s, u_l, u_g0, epsilon_g, epsilon_l, E_l, E_g, a, h_l = (
138
+ phys_props["rho_l"],
139
+ phys_props["K_s"],
140
+ phys_props["u_l"],
141
+ phys_props["u_g0"],
142
+ phys_props["epsilon_g"],
143
+ phys_props["epsilon_l"],
144
+ phys_props["E_l"],
145
+ phys_props["E_g"],
146
+ phys_props["a"],
147
+ phys_props["h_l"],
148
+ )
149
+
150
+ # Calculate dimensionless groups
151
+ psi = (rho_l * g * epsilon_l * L) / P_in # Hydrostatic pressure ratio
152
+ nu = ((c_T_in / K_s) ** 2) / P_in # Equilibrium ratio
153
+ Bo_l = u_l * L / (epsilon_l * E_l) # Bodenstein number, liquid
154
+ phi_l = a * h_l * L / u_l # Transfer units, liquid
155
+ Bo_g = u_g0 * L / (epsilon_g * E_g) # Bodenstein number, gas
156
+ phi_g = 0.5 * (R * T * c_T_in / P_in) * (a * h_l * L / u_g0)
157
+
158
+ return {
159
+ "Bo_l": Bo_l,
160
+ "phi_l": phi_l,
161
+ "Bo_g": Bo_g,
162
+ "phi_g": phi_g,
163
+ "psi": psi,
164
+ "nu": nu,
165
+ }
166
+
167
+
168
+ def _ode_system(xi, S, dim):
169
+ """First-order ODE system (4 equations) of the dimensionless GLC model.
170
+
171
+ The state is ``S = [x_T, dx_T/d(xi), y_T2, dy_T2/d(xi)]``. The signature
172
+ matches what the native :class:`~pathsim.blocks.BVP1D` block expects, with
173
+ the spatial mesh ``xi`` of shape ``(m,)`` and the state ``S`` of shape
174
+ ``(4, m)``.
175
+
176
+ Args:
177
+ xi (numpy.ndarray): Dimensionless axial coordinate, shape ``(m,)``.
178
+ S (numpy.ndarray): State, shape ``(4, m)``.
179
+ dim (dict): Dimensionless groups (Bo_l, phi_l, Bo_g, phi_g, psi, nu).
180
+
181
+ Returns:
182
+ numpy.ndarray: Derivatives ``dS/d(xi)``, shape ``(4, m)``.
183
+ """
184
+ Bo_l, phi_l, Bo_g, phi_g, psi, nu = (
185
+ dim["Bo_l"],
186
+ dim["phi_l"],
187
+ dim["Bo_g"],
188
+ dim["phi_g"],
189
+ dim["psi"],
190
+ dim["nu"],
191
+ )
192
+
193
+ x_T, dx_T_dxi, y_T2, dy_T2_dxi = S
194
+ theta = x_T - np.sqrt(np.maximum(0, (1 - psi * xi) * y_T2 / nu))
195
+
196
+ dS0_dxi = dx_T_dxi # d(x_T)/d(xi)
197
+ dS1_dxi = Bo_l * (phi_l * theta - dx_T_dxi) # d^2(x_T)/d(xi)^2
198
+ dS2_dxi = dy_T2_dxi # d(y_T2)/d(xi)
199
+ term1 = (1 + 2 * psi / Bo_g) * dy_T2_dxi
200
+ term2 = phi_g * theta
201
+ dS3_dxi = (Bo_g / (1 - psi * xi)) * (term1 - term2) # d^2(y_T2)/d(xi)^2
202
+
203
+ return np.vstack((dS0_dxi, dS1_dxi, dS2_dxi, dS3_dxi))
204
+
205
+
206
+ def _boundary_conditions(Sa, Sb, dim, y_T2_in, BCs):
207
+ """Boundary-condition residuals at xi=0 (Sa) and xi=1 (Sb).
208
+
209
+ Args:
210
+ Sa (numpy.ndarray): State at xi=0 (liquid outlet).
211
+ Sb (numpy.ndarray): State at xi=1 (liquid inlet).
212
+ dim (dict): Dimensionless groups (uses Bo_l, Bo_g).
213
+ y_T2_in (float): Inlet mole fraction of T2 in the gas phase.
214
+ BCs (str): Boundary-condition type, ``"C-C"`` or ``"O-C"``.
215
+
216
+ Returns:
217
+ numpy.ndarray: The four boundary-condition residuals.
218
+
219
+ Raises:
220
+ ValueError: If ``BCs`` is not a recognised type.
221
+ """
222
+ Bo_l, Bo_g = dim["Bo_l"], dim["Bo_g"]
223
+
224
+ if BCs == "C-C": # Closed-Closed
225
+ res1 = Sa[1] # dx_T/d(xi) = 0 at xi=0
226
+ res2 = Sb[0] - (1 - (1 / Bo_l) * Sb[1]) # x_T(1) = 1 - ...
227
+ res3 = Sa[2] - y_T2_in - (1 / Bo_g) * Sa[3] # y_T2(0) = y_T2_in + ...
228
+ res4 = Sb[3] # dy_T2/d(xi) = 0 at xi=1
229
+ elif BCs == "O-C": # Open-Closed
230
+ res1 = Sa[1] # dx_T/d(xi) = 0 at xi=0
231
+ res2 = Sb[0] - 1.0 # x_T(1) = 1
232
+ res3 = Sa[2] - y_T2_in # y_T2(0) = y_T2_in
233
+ res4 = Sb[3] # dy_T2/d(xi) = 0 at xi=1
234
+ else:
235
+ raise ValueError(f"Unknown boundary condition type: {BCs!r}")
236
+
237
+ return np.array([res1, res2, res3, res4])
238
+
239
+
240
+ def _process_results(S_ends, params, phys_props, dim_params):
241
+ """
242
+ Process the BVP solution to produce dimensional results.
243
+
244
+ This function converts the dimensionless solution of the BVP back into
245
+ dimensional quantities, calculates the extraction efficiency, performs a
246
+ mass balance check, and aggregates all results.
247
+
248
+ Args:
249
+ S_ends (numpy.ndarray): Solution sampled at the domain endpoints,
250
+ shape ``(4, 2)`` with column 0 at xi=0 (liquid outlet) and column 1
251
+ at xi=1 (liquid inlet).
252
+ params (dict): The original input parameters.
253
+ phys_props (dict): The calculated physical properties.
254
+ dim_params (dict): The calculated dimensionless groups.
255
+
256
+ Returns:
257
+ dict: The dimensional results dictionary.
258
+ """
259
+
260
+ # Unpack parameters
261
+ c_T_in, P_in, T = params["c_T_in"], params["P_in"], params["T"]
262
+ y_T2_in = params["y_T2_in"]
263
+
264
+ # Dimensionless results (xi=0 -> column 0, xi=1 -> column 1)
265
+ x_T_outlet_dimless = S_ends[0, 0]
266
+ Q_l, Q_g = phys_props["Q_l"], phys_props["Q_g"]
267
+ y_T2_out = S_ends[2, 1]
268
+ efficiency = 1 - x_T_outlet_dimless
269
+
270
+ # Dimensional results
271
+ c_T_out = x_T_outlet_dimless * c_T_in
272
+ P_out = P_in * (1 - dim_params["psi"])
273
+ P_T2_out = y_T2_out * P_out
274
+ P_T2_in = y_T2_in * P_in
275
+
276
+ # Tritium molar flows from the converged solution (mol/s). Each T2 molecule
277
+ # carries two tritium atoms.
278
+ n_T_in_liquid = c_T_in * Q_l
279
+ n_T_in_gas = 2 * P_T2_in * Q_g / (R * T)
280
+ n_T_out_liquid = c_T_out * Q_l
281
+ Q_g_out = Q_g * (P_in / P_out) # m3/s
282
+ n_T_out_gas = 2 * P_T2_out * Q_g_out / (R * T)
283
+
284
+ n_T_in = n_T_in_liquid + n_T_in_gas
285
+ n_T_out = n_T_out_liquid + n_T_out_gas
286
+
287
+ # Mass-balance residual reported as a diagnostic. For closed-closed (C-C)
288
+ # boundary conditions it is at numerical-noise level; for open-closed (O-C)
289
+ # it reflects the dispersive flux neglected by the open inlet condition.
290
+ return {
291
+ "c_T_outlet [mol/m^3]": c_T_out,
292
+ "y_T2_outlet_gas": y_T2_out,
293
+ "extraction_efficiency [fraction]": efficiency,
294
+ "total_gas_P_outlet [Pa]": P_out,
295
+ "liquid_vol_flow [m^3/s]": Q_l,
296
+ "gas_vol_flow_outlet [m^3/s]": Q_g_out,
297
+ "tritium_out_liquid [mol/s]": n_T_out_liquid,
298
+ "tritium_out_gas [mol/s]": n_T_out_gas,
299
+ "Total tritium in [mol/s]": n_T_in,
300
+ "Total tritium out [mol/s]": n_T_out,
301
+ "mass_balance_residual [mol/s]": n_T_in - n_T_out,
302
+ }
303
+
304
+
305
+ class GLC(BVP1D):
306
+ r"""Counter-current bubble column gas-liquid contactor (GLC) for tritium extraction.
307
+
308
+ Solves the coupled, non-linear, second-order boundary value problem that
309
+ describes tritium transport between a liquid metal (LiPb) stream and a
310
+ purge gas in a counter-current bubble column. The model is based on
311
+ C. Malara (1995) and accounts for axial dispersion, interfacial mass
312
+ transfer via Sieverts' law, and hydrostatic pressure variation along
313
+ the column.
314
+
315
+ The block is a specialisation of the native :class:`~pathsim.blocks.BVP1D`
316
+ block: the constructor seeds the parent with the Malara right-hand side and
317
+ boundary conditions, and the four block inputs supply the per-evaluation
318
+ boundary data. The hydrodynamic correlations and dimensionless groups are
319
+ computed from the current input inside the collocation callbacks. As for any
320
+ ``BVP1D``, the solve is warm-started from the previous mesh and skipped
321
+ entirely when the input is unchanged. After each solve the dimensionless
322
+ endpoint solution is post-processed into the dimensional output ports.
323
+
324
+ Use :meth:`results` to retrieve the full dimensional result dictionary,
325
+ which additionally contains partial pressures, physical properties and the
326
+ dimensionless groups.
327
+
328
+ Reference: https://doi.org/10.13182/FST95-A30485
329
+
330
+ **Input ports:**
331
+ ``c_T_in`` -- dissolved tritium concentration in liquid inlet [mol/m³],
332
+ ``flow_l`` -- liquid mass flow rate [kg/s],
333
+ ``y_T2_inlet`` -- T₂ mole fraction in inlet gas [-],
334
+ ``flow_g`` -- gas mass flow rate [kg/s].
335
+
336
+ **Output ports:**
337
+ ``c_T_out`` -- dissolved tritium concentration in liquid outlet [mol/m³],
338
+ ``y_T2_out`` -- T₂ mole fraction in outlet gas [-],
339
+ ``eff`` -- extraction efficiency [-],
340
+ ``P_out`` -- total gas outlet pressure [Pa],
341
+ ``Q_l`` -- liquid volumetric flow rate [m³/s],
342
+ ``Q_g_out`` -- gas volumetric flow rate at outlet [m³/s],
343
+ ``n_T_out_liquid`` -- tritium molar flow in liquid outlet [mol/s],
344
+ ``n_T_out_gas`` -- tritium molar flow in gas outlet [mol/s].
345
+
346
+ Parameters
347
+ ----------
348
+ P_in : float
349
+ Inlet operating pressure [Pa].
350
+ L : float
351
+ Column height [m].
352
+ D : float
353
+ Column diameter [m].
354
+ T : float
355
+ Operating temperature [K]. Used to compute temperature-dependent
356
+ LiPb properties (density, viscosity, Sieverts' constant, etc.).
357
+ BCs : str
358
+ Boundary condition type for the BVP: ``"C-C"`` (closed-closed) or
359
+ ``"O-C"`` (open-closed).
360
+ g : float, optional
361
+ Gravitational acceleration [m/s²]. Default: ``scipy.constants.g``.
362
+ initial_nb_of_elements : int, optional
363
+ Number of mesh elements for the initial BVP grid. Default: 20.
364
+ tol : float, optional
365
+ Solver tolerance for the BVP. Default: 1e-5.
366
+ max_nodes : int, optional
367
+ Maximum number of mesh nodes allowed during BVP refinement. Default:
368
+ 10000.
369
+ """
370
+
371
+ input_port_labels: ClassVar[dict[str, int]] = {
372
+ "c_T_in": 0,
373
+ "flow_l": 1,
374
+ "y_T2_inlet": 2,
375
+ "flow_g": 3,
376
+ }
377
+ output_port_labels: ClassVar[dict[str, int]] = {
378
+ "c_T_out": 0,
379
+ "y_T2_out": 1,
380
+ "eff": 2,
381
+ "P_out": 3,
382
+ "Q_l": 4,
383
+ "Q_g_out": 5,
384
+ "n_T_out_liquid": 6,
385
+ "n_T_out_gas": 7,
386
+ }
387
+
388
+ def __init__(
389
+ self,
390
+ P_in,
391
+ L,
392
+ D,
393
+ T,
394
+ BCs,
395
+ g=const.g,
396
+ initial_nb_of_elements=20,
397
+ tol=1e-5,
398
+ max_nodes=10000,
399
+ ):
400
+ # fixed operating point and geometry; the four block inputs supply the
401
+ # per-evaluation boundary data (c_T_in, flow_l, y_T2_inlet, flow_g)
402
+ self.params = {
403
+ "P_in": P_in,
404
+ "L": L,
405
+ "g": g,
406
+ "D": D,
407
+ "T": T,
408
+ "elements": initial_nb_of_elements,
409
+ "BCs": BCs,
410
+ }
411
+ self.BCs = BCs
412
+
413
+ # cache of (physical properties, dimensionless groups) keyed on the input
414
+ # so the hydrodynamic correlations run once per operating point instead of
415
+ # on every collocation mesh point
416
+ self._u_cache = None
417
+ self._phys = None
418
+ self._dim = None
419
+
420
+ # seed the native BVP block with the Malara model physics; sample the two
421
+ # domain endpoints so the dimensionless outlet/inlet states are available
422
+ super().__init__(
423
+ fun=self._ode,
424
+ bc=self._bc,
425
+ n=4,
426
+ domain=(0.0, 1.0),
427
+ n_nodes=initial_nb_of_elements + 1,
428
+ x_eval=np.array([0.0, 1.0]),
429
+ tol=tol,
430
+ max_nodes=max_nodes,
431
+ )
432
+
433
+ def _physics(self, u):
434
+ """Physical properties and dimensionless groups for the input `u`.
435
+
436
+ Cached on `u` so the hydrodynamic correlations (and the gas hold-up
437
+ root solve) run once per operating point rather than on every mesh
438
+ point of the collocation solve.
439
+ """
440
+ if self._u_cache is not None and np.array_equal(u, self._u_cache):
441
+ return self._phys, self._dim
442
+
443
+ c_T_in, flow_l, y_T2_inlet, flow_g = u
444
+ p = dict(self.params)
445
+ p["c_T_in"] = c_T_in
446
+ p["flow_l"] = flow_l
447
+ p["flow_g"] = flow_g
448
+ p["y_T2_in"] = y_T2_inlet
449
+
450
+ phys = _calculate_properties(p)
451
+
452
+ # guard against a non-physical (non-positive) gas outlet pressure
453
+ P_out = p["P_in"] - phys["rho_l"] * (1 - phys["epsilon_g"]) * g * p["L"]
454
+ if P_out <= 0:
455
+ raise ValueError(
456
+ f"Calculated gas outlet pressure is non-positive ({P_out:.2e} Pa). "
457
+ "Check input parameters P_in, L, etc."
458
+ )
459
+
460
+ dim = _calculate_dimensionless_groups(p, phys)
461
+
462
+ self._u_cache = np.array(u, dtype=float)
463
+ self._phys, self._dim = phys, dim
464
+ return phys, dim
465
+
466
+ def _ode(self, xi, S, p, u):
467
+ """Malara right-hand side, with dimensionless groups derived from `u`."""
468
+ _, dim = self._physics(u)
469
+ return _ode_system(xi, S, dim)
470
+
471
+ def _bc(self, Sa, Sb, p, u):
472
+ """Malara boundary conditions, with groups derived from `u`."""
473
+ _, dim = self._physics(u)
474
+ y_T2_in = max(u[2], 1e-20)
475
+ return _boundary_conditions(Sa, Sb, dim, y_T2_in, self.BCs)
476
+
477
+ def update(self, t):
478
+ """Solve the BVP and expose the dimensional results at the output ports.
479
+
480
+ Delegates the solve to :class:`~pathsim.blocks.BVP1D` (warm-started, and
481
+ skipped when the input is unchanged), then post-processes the
482
+ dimensionless endpoint solution into the eight dimensional output ports.
483
+
484
+ Parameters
485
+ ----------
486
+ t : float
487
+ evaluation time
488
+
489
+ Raises
490
+ ------
491
+ RuntimeError
492
+ if the BVP solve did not converge
493
+ """
494
+ super().update(t)
495
+ if not self.success:
496
+ raise RuntimeError("BVP solver failed to converge.")
497
+
498
+ res = self.results()
499
+ self.outputs.update_from_array(
500
+ np.array(
501
+ [
502
+ res["c_T_outlet [mol/m^3]"],
503
+ res["y_T2_outlet_gas"],
504
+ res["extraction_efficiency [fraction]"],
505
+ res["total_gas_P_outlet [Pa]"],
506
+ res["liquid_vol_flow [m^3/s]"],
507
+ res["gas_vol_flow_outlet [m^3/s]"],
508
+ res["tritium_out_liquid [mol/s]"],
509
+ res["tritium_out_gas [mol/s]"],
510
+ ]
511
+ )
512
+ )
513
+
514
+ def results(self):
515
+ """Post-process the current BVP solution into dimensional results.
516
+
517
+ Returns
518
+ -------
519
+ dict, None
520
+ Dimensional results (outlet concentration, extraction efficiency,
521
+ outlet pressure, volumetric flows, tritium molar flows and the mass
522
+ balance) for the most recent input, or ``None`` if no successful
523
+ solve has happened yet.
524
+ """
525
+ if not self.success:
526
+ return None
527
+
528
+ u = self.inputs.to_array()
529
+ c_T_in, flow_l, y_T2_inlet, flow_g = u
530
+ p = dict(self.params)
531
+ p["c_T_in"] = c_T_in
532
+ p["flow_l"] = flow_l
533
+ p["flow_g"] = flow_g
534
+ p["y_T2_in"] = y_T2_inlet
535
+
536
+ phys, dim = self._physics(u)
537
+ return _process_results(self.solution(), p, phys, dim)
@@ -0,0 +1,96 @@
1
+ #########################################################################################
2
+ ##
3
+ ## Ionisation Chamber Block
4
+ ##
5
+ #########################################################################################
6
+
7
+ # IMPORTS ===============================================================================
8
+
9
+ from typing import ClassVar
10
+
11
+ from pathsim.blocks.function import Function
12
+
13
+ # BLOCKS ================================================================================
14
+
15
+
16
+ class IonisationChamber(Function):
17
+ """Ionisation chamber for tritium detection.
18
+
19
+ Algebraic block that models a flow-through ionisation chamber. The sample
20
+ passes through unchanged while the chamber produces a signal proportional
21
+ to the tritium concentration, scaled by a detection efficiency.
22
+
23
+ Mathematical Formulation
24
+ -------------------------
25
+ The chamber receives a tritium flux and flow rate, computes the
26
+ concentration, and applies the detection efficiency:
27
+
28
+ .. math::
29
+
30
+ c = \\frac{\\Phi_{in}}{\\dot{V}}
31
+
32
+ .. math::
33
+
34
+ \\text{signal} = \\varepsilon(c) \\cdot c
35
+
36
+ .. math::
37
+
38
+ \\Phi_{out} = \\Phi_{in}
39
+
40
+ where :math:`\\varepsilon` is the detection efficiency (constant or
41
+ concentration-dependent).
42
+
43
+ Parameters
44
+ ----------
45
+ detection_efficiency : float or callable, optional
46
+ Constant efficiency factor or a function ``f(c) -> float`` that
47
+ returns the efficiency for a given concentration. Mutually
48
+ exclusive with *detection_threshold*.
49
+ detection_threshold : float, optional
50
+ If provided, the efficiency is a step function: 1 above the
51
+ threshold, 0 below. Mutually exclusive with *detection_efficiency*.
52
+ """
53
+
54
+ input_port_labels: ClassVar[dict[str, int]] = {
55
+ "flux_in": 0,
56
+ "flow_rate": 1,
57
+ }
58
+
59
+ output_port_labels: ClassVar[dict[str, int]] = {
60
+ "flux_out": 0,
61
+ "signal": 1,
62
+ }
63
+
64
+ def __init__(self, detection_efficiency=None, detection_threshold=None):
65
+
66
+ # input validation
67
+ if detection_efficiency is not None and detection_threshold is not None:
68
+ raise ValueError(
69
+ "Specify either 'detection_efficiency' or 'detection_threshold', not both"
70
+ )
71
+ if detection_efficiency is None and detection_threshold is None:
72
+ raise ValueError(
73
+ "One of 'detection_efficiency' or 'detection_threshold' must be provided"
74
+ )
75
+
76
+ if detection_threshold is not None:
77
+ self.detection_efficiency = lambda c: (
78
+ 1.0 if c >= detection_threshold else 0.0
79
+ )
80
+ else:
81
+ self.detection_efficiency = detection_efficiency
82
+
83
+ self.detection_threshold = detection_threshold
84
+
85
+ super().__init__(func=self._eval)
86
+
87
+ def _eval(self, flux_in, flow_rate):
88
+ concentration = flux_in / flow_rate if flow_rate > 0 else 0.0
89
+
90
+ eff = self.detection_efficiency
91
+ epsilon = eff(concentration) if callable(eff) else eff
92
+
93
+ signal = epsilon * concentration
94
+ flux_out = flux_in
95
+
96
+ return (flux_out, signal)
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: pathsim-fusion
3
+ Version: 0.1.0
4
+ Summary: Fusion fuel cycle and balance-of-plant blocks for PathSim
5
+ Author: PathSim Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://pathsim.org
8
+ Project-URL: Documentation, https://docs.pathsim.org/fusion
9
+ Project-URL: Repository, https://github.com/pathsim/pathsim-fusion
10
+ Project-URL: Issues, https://github.com/pathsim/pathsim-fusion/issues
11
+ Keywords: simulation,pathsim,block diagram,fusion,tritium,fuel cycle,balance of plant
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Topic :: Scientific/Engineering :: Physics
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: pathsim>=0.23
26
+ Requires-Dist: numpy>=1.15
27
+ Requires-Dist: scipy>=1.2
28
+ Provides-Extra: test
29
+ Requires-Dist: pytest>=7.0; extra == "test"
30
+ Requires-Dist: pytest-cov>=4.0; extra == "test"
31
+ Provides-Extra: lint
32
+ Requires-Dist: ruff==0.17.3; extra == "lint"
33
+ Dynamic: license-file
34
+
35
+ <p align="center">
36
+ <strong>Fusion fuel cycle and balance-of-plant blocks for PathSim</strong>
37
+ </p>
38
+
39
+ <p align="center">
40
+ <a href="https://pypi.org/project/pathsim-fusion/"><img src="https://img.shields.io/pypi/v/pathsim-fusion" alt="PyPI"></a>
41
+ <img src="https://img.shields.io/github/license/pathsim/pathsim-fusion" alt="License">
42
+ </p>
43
+
44
+ <p align="center">
45
+ <a href="https://pathsim.org">Homepage</a> &bull;
46
+ <a href="https://docs.pathsim.org/fusion">Documentation</a> &bull;
47
+ <a href="https://github.com/pathsim/pathsim-fusion">GitHub</a>
48
+ </p>
49
+
50
+ ---
51
+
52
+ PathSim-Fusion extends the [PathSim](https://github.com/pathsim/pathsim)
53
+ simulation framework with specialized blocks. All blocks follow the standard
54
+ PathSim block interface and can be connected into simulation diagrams.
55
+
56
+ ## Blocks
57
+
58
+ | Block | Description | Key Parameters |
59
+ |-------|-------------|----------------|
60
+ | `IonisationChamber` | Flow-through tritium ionisation chamber (signal = ε(c)·c, flux passes through) | `detection_efficiency`, `detection_threshold` |
61
+ | `Bubbler4` | Four-vial tritium sampling bubbler (soluble + insoluble chains, catalytic conversion, scheduled vial replacement) | `conversion_efficiency`, `vial_efficiency`, `replacement_times` |
62
+ | `GLC` | Counter-current bubble-column gas–liquid contactor for tritium extraction from LiPb (Malara 1995, on `BVP1D`) | `P_in`, `L`, `D`, `T`, `BCs` |
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ pip install pathsim-fusion
68
+ ```
69
+
70
+ ## Quick Example
71
+
72
+ ```python
73
+ from pathsim import Simulation, Connection
74
+ from pathsim.blocks import Constant, Source, Scope
75
+ from pathsim_fusion import IonisationChamber
76
+
77
+ flux = Source(lambda t: 1e-3 * float(t > 1)) # tritium flux, step at t = 1
78
+ flow = Constant(0.5) # carrier gas flow rate
79
+ ic = IonisationChamber(detection_efficiency=0.9)
80
+ sco = Scope(labels=["flux", "signal"])
81
+
82
+ sim = Simulation(
83
+ blocks=[flux, flow, ic, sco],
84
+ connections=[
85
+ Connection(flux, ic["flux_in"], sco[0]),
86
+ Connection(flow, ic["flow_rate"]),
87
+ Connection(ic["signal"], sco[1]),
88
+ ],
89
+ dt=0.01,
90
+ )
91
+ sim.run(5)
92
+ sco.plot()
93
+ ```
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ pip install -e ".[test,lint]" mypy
99
+ ruff check . # lint
100
+ ruff format . # format
101
+ mypy src/pathsim_fusion # type check
102
+ pytest tests/ -v # run tests
103
+ ```
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,11 @@
1
+ pathsim_fusion/__init__.py,sha256=F88HTQsTCXwmZPNozrCBiANCDTPe69kO3DmAcDKb_U4,969
2
+ pathsim_fusion/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
3
+ pathsim_fusion/tritium/__init__.py,sha256=4mbaMbYeRI2EgSvMHzwauO7WYtPscnn8zDz9yHoiNGA,153
4
+ pathsim_fusion/tritium/bubbler.py,sha256=xoaUH6szfOpizWwPZCI2q_A3RlsIWI9csgDYxBF3auw,8418
5
+ pathsim_fusion/tritium/glc.py,sha256=4ws9fUYReNOEdSKz3cXhwlnhEa2lPy_HEOtQgnXDYec,19018
6
+ pathsim_fusion/tritium/ionisation_chamber.py,sha256=rX46wbtd0Q6DSpPd_6gjv1MXOycfmSUtVSeeDplgeZA,3134
7
+ pathsim_fusion-0.1.0.dist-info/licenses/LICENSE,sha256=DWjTGX2hLZpbN3i93RTs1I-Re_5M0hzlVim9eZHR-VQ,1064
8
+ pathsim_fusion-0.1.0.dist-info/METADATA,sha256=dK6Fr__VH--LG65YL4liSFqgXCStbcSYDZmQHzGwC4A,3655
9
+ pathsim_fusion-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ pathsim_fusion-0.1.0.dist-info/top_level.txt,sha256=VfTTVYMISbz-u9KbkwKeecGlhDXjvJtMoFlu0Gtj6f8,15
11
+ pathsim_fusion-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 PathSim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pathsim_fusion