Skip to content

Commit 2ee4da5

Browse files
authored
Merge pull request #24 from pathsim/feature/ionisation-chamber
Add IonisationChamber block for tritium detection
2 parents 16221e3 + ec0046e commit 2ee4da5

File tree

3 files changed

+209
-0
lines changed

3 files changed

+209
-0
lines changed

src/pathsim_chem/tritium/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
from .splitter import *
33
from .bubbler import *
44
from .glc import *
5+
from .ionisation_chamber import *
56
# from .tcap import *
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#########################################################################################
2+
##
3+
## Ionisation Chamber Block
4+
##
5+
#########################################################################################
6+
7+
# IMPORTS ===============================================================================
8+
9+
from pathsim.blocks.function import Function
10+
11+
# BLOCKS ================================================================================
12+
13+
class IonisationChamber(Function):
14+
"""Ionisation chamber for tritium detection.
15+
16+
Algebraic block that models a flow-through ionisation chamber. The sample
17+
passes through unchanged while the chamber produces a signal proportional
18+
to the tritium concentration, scaled by a detection efficiency.
19+
20+
Mathematical Formulation
21+
-------------------------
22+
The chamber receives a tritium flux and flow rate, computes the
23+
concentration, and applies the detection efficiency:
24+
25+
.. math::
26+
27+
c = \\frac{\\Phi_{in}}{\\dot{V}}
28+
29+
.. math::
30+
31+
\\text{signal} = \\varepsilon(c) \\cdot c
32+
33+
.. math::
34+
35+
\\Phi_{out} = \\Phi_{in}
36+
37+
where :math:`\\varepsilon` is the detection efficiency (constant or
38+
concentration-dependent).
39+
40+
Parameters
41+
----------
42+
detection_efficiency : float or callable, optional
43+
Constant efficiency factor or a function ``f(c) -> float`` that
44+
returns the efficiency for a given concentration. Mutually
45+
exclusive with *detection_threshold*.
46+
detection_threshold : float, optional
47+
If provided, the efficiency is a step function: 1 above the
48+
threshold, 0 below. Mutually exclusive with *detection_efficiency*.
49+
"""
50+
51+
input_port_labels = {
52+
"flux_in": 0,
53+
"flow_rate": 1,
54+
}
55+
56+
output_port_labels = {
57+
"flux_out": 0,
58+
"signal": 1,
59+
}
60+
61+
def __init__(self, detection_efficiency=None, detection_threshold=None):
62+
63+
# input validation
64+
if detection_efficiency is not None and detection_threshold is not None:
65+
raise ValueError(
66+
"Specify either 'detection_efficiency' or 'detection_threshold', not both"
67+
)
68+
if detection_efficiency is None and detection_threshold is None:
69+
raise ValueError(
70+
"One of 'detection_efficiency' or 'detection_threshold' must be provided"
71+
)
72+
73+
if detection_threshold is not None:
74+
self.detection_efficiency = lambda c: 1.0 if c >= detection_threshold else 0.0
75+
else:
76+
self.detection_efficiency = detection_efficiency
77+
78+
self.detection_threshold = detection_threshold
79+
80+
super().__init__(func=self._eval)
81+
82+
def _eval(self, flux_in, flow_rate):
83+
concentration = flux_in / flow_rate if flow_rate > 0 else 0.0
84+
85+
eff = self.detection_efficiency
86+
epsilon = eff(concentration) if callable(eff) else eff
87+
88+
signal = epsilon * concentration
89+
flux_out = flux_in
90+
91+
return (flux_out, signal)
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
########################################################################################
2+
##
3+
## TESTS FOR
4+
## 'tritium.ionisation_chamber.py'
5+
##
6+
########################################################################################
7+
8+
# IMPORTS ==============================================================================
9+
10+
import unittest
11+
12+
from pathsim_chem.tritium import IonisationChamber
13+
14+
15+
# TESTS ================================================================================
16+
17+
class TestIonisationChamber(unittest.TestCase):
18+
"""Test the IonisationChamber block."""
19+
20+
def test_init_constant_efficiency(self):
21+
"""Test initialization with constant detection efficiency."""
22+
ic = IonisationChamber(detection_efficiency=0.8)
23+
self.assertEqual(ic.detection_efficiency, 0.8)
24+
self.assertIsNone(ic.detection_threshold)
25+
26+
def test_init_threshold(self):
27+
"""Test initialization with detection threshold."""
28+
ic = IonisationChamber(detection_threshold=10.0)
29+
self.assertEqual(ic.detection_threshold, 10.0)
30+
self.assertTrue(callable(ic.detection_efficiency))
31+
32+
def test_init_callable_efficiency(self):
33+
"""Test initialization with callable detection efficiency."""
34+
eff = lambda c: min(c / 100.0, 1.0)
35+
ic = IonisationChamber(detection_efficiency=eff)
36+
self.assertIs(ic.detection_efficiency, eff)
37+
38+
def test_init_validation_both(self):
39+
"""Providing both parameters should raise ValueError."""
40+
with self.assertRaises(ValueError):
41+
IonisationChamber(detection_efficiency=0.5, detection_threshold=10.0)
42+
43+
def test_init_validation_neither(self):
44+
"""Providing neither parameter should raise ValueError."""
45+
with self.assertRaises(ValueError):
46+
IonisationChamber()
47+
48+
def test_port_labels(self):
49+
"""Test port label definitions."""
50+
self.assertEqual(IonisationChamber.input_port_labels["flux_in"], 0)
51+
self.assertEqual(IonisationChamber.input_port_labels["flow_rate"], 1)
52+
self.assertEqual(IonisationChamber.output_port_labels["flux_out"], 0)
53+
self.assertEqual(IonisationChamber.output_port_labels["signal"], 1)
54+
55+
def test_passthrough(self):
56+
"""Sample flux passes through unchanged."""
57+
ic = IonisationChamber(detection_efficiency=0.5)
58+
ic.inputs[0] = 100.0 # flux_in
59+
ic.inputs[1] = 10.0 # flow_rate
60+
ic.update(None)
61+
62+
self.assertAlmostEqual(ic.outputs[0], 100.0)
63+
64+
def test_signal_constant_efficiency(self):
65+
"""Signal = efficiency * concentration."""
66+
ic = IonisationChamber(detection_efficiency=0.8)
67+
ic.inputs[0] = 200.0 # flux_in
68+
ic.inputs[1] = 10.0 # flow_rate -> concentration = 20
69+
ic.update(None)
70+
71+
self.assertAlmostEqual(ic.outputs[1], 0.8 * 20.0)
72+
73+
def test_signal_threshold_above(self):
74+
"""Above threshold, signal = concentration."""
75+
ic = IonisationChamber(detection_threshold=5.0)
76+
ic.inputs[0] = 100.0 # flux
77+
ic.inputs[1] = 10.0 # flow -> concentration = 10 > 5
78+
ic.update(None)
79+
80+
self.assertAlmostEqual(ic.outputs[1], 10.0)
81+
82+
def test_signal_threshold_below(self):
83+
"""Below threshold, signal = 0."""
84+
ic = IonisationChamber(detection_threshold=50.0)
85+
ic.inputs[0] = 100.0 # flux
86+
ic.inputs[1] = 10.0 # flow -> concentration = 10 < 50
87+
ic.update(None)
88+
89+
self.assertAlmostEqual(ic.outputs[1], 0.0)
90+
91+
def test_signal_callable_efficiency(self):
92+
"""Callable efficiency applied to concentration."""
93+
# Linear ramp: efficiency = c / 100, capped at 1
94+
eff = lambda c: min(c / 100.0, 1.0)
95+
ic = IonisationChamber(detection_efficiency=eff)
96+
ic.inputs[0] = 500.0 # flux
97+
ic.inputs[1] = 10.0 # flow -> concentration = 50
98+
ic.update(None)
99+
100+
# efficiency(50) = 0.5, signal = 0.5 * 50 = 25
101+
self.assertAlmostEqual(ic.outputs[1], 25.0)
102+
103+
def test_zero_flow_rate(self):
104+
"""Zero flow rate should not crash, signal = 0."""
105+
ic = IonisationChamber(detection_efficiency=1.0)
106+
ic.inputs[0] = 100.0
107+
ic.inputs[1] = 0.0
108+
ic.update(None)
109+
110+
self.assertAlmostEqual(ic.outputs[0], 100.0)
111+
self.assertAlmostEqual(ic.outputs[1], 0.0)
112+
113+
114+
# RUN TESTS LOCALLY ====================================================================
115+
116+
if __name__ == '__main__':
117+
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)