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.
- pathsim_fusion/__init__.py +29 -0
- pathsim_fusion/_version.py +24 -0
- pathsim_fusion/tritium/__init__.py +5 -0
- pathsim_fusion/tritium/bubbler.py +240 -0
- pathsim_fusion/tritium/glc.py +537 -0
- pathsim_fusion/tritium/ionisation_chamber.py +96 -0
- pathsim_fusion-0.1.0.dist-info/METADATA +107 -0
- pathsim_fusion-0.1.0.dist-info/RECORD +11 -0
- pathsim_fusion-0.1.0.dist-info/WHEEL +5 -0
- pathsim_fusion-0.1.0.dist-info/licenses/LICENSE +21 -0
- pathsim_fusion-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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> •
|
|
46
|
+
<a href="https://docs.pathsim.org/fusion">Documentation</a> •
|
|
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,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
|