diff --git a/src/pathsim_chem/tritium/__init__.py b/src/pathsim_chem/tritium/__init__.py index 265bbb7..d79e509 100644 --- a/src/pathsim_chem/tritium/__init__.py +++ b/src/pathsim_chem/tritium/__init__.py @@ -2,4 +2,5 @@ from .splitter import * from .bubbler import * from .glc import * +from .ionisation_chamber import * # from .tcap import * diff --git a/src/pathsim_chem/tritium/ionisation_chamber.py b/src/pathsim_chem/tritium/ionisation_chamber.py new file mode 100644 index 0000000..ed210f6 --- /dev/null +++ b/src/pathsim_chem/tritium/ionisation_chamber.py @@ -0,0 +1,91 @@ +######################################################################################### +## +## Ionisation Chamber Block +## +######################################################################################### + +# IMPORTS =============================================================================== + +from pathsim.blocks.function import Function + +# BLOCKS ================================================================================ + +class IonisationChamber(Function): + """Ionisation chamber for tritium detection. + + Algebraic block that models a flow-through ionisation chamber. The sample + passes through unchanged while the chamber produces a signal proportional + to the tritium concentration, scaled by a detection efficiency. + + Mathematical Formulation + ------------------------- + The chamber receives a tritium flux and flow rate, computes the + concentration, and applies the detection efficiency: + + .. math:: + + c = \\frac{\\Phi_{in}}{\\dot{V}} + + .. math:: + + \\text{signal} = \\varepsilon(c) \\cdot c + + .. math:: + + \\Phi_{out} = \\Phi_{in} + + where :math:`\\varepsilon` is the detection efficiency (constant or + concentration-dependent). + + Parameters + ---------- + detection_efficiency : float or callable, optional + Constant efficiency factor or a function ``f(c) -> float`` that + returns the efficiency for a given concentration. Mutually + exclusive with *detection_threshold*. + detection_threshold : float, optional + If provided, the efficiency is a step function: 1 above the + threshold, 0 below. Mutually exclusive with *detection_efficiency*. + """ + + input_port_labels = { + "flux_in": 0, + "flow_rate": 1, + } + + output_port_labels = { + "flux_out": 0, + "signal": 1, + } + + def __init__(self, detection_efficiency=None, detection_threshold=None): + + # input validation + if detection_efficiency is not None and detection_threshold is not None: + raise ValueError( + "Specify either 'detection_efficiency' or 'detection_threshold', not both" + ) + if detection_efficiency is None and detection_threshold is None: + raise ValueError( + "One of 'detection_efficiency' or 'detection_threshold' must be provided" + ) + + if detection_threshold is not None: + self.detection_efficiency = lambda c: 1.0 if c >= detection_threshold else 0.0 + else: + self.detection_efficiency = detection_efficiency + + self.detection_threshold = detection_threshold + + super().__init__(func=self._eval) + + def _eval(self, flux_in, flow_rate): + concentration = flux_in / flow_rate if flow_rate > 0 else 0.0 + + eff = self.detection_efficiency + epsilon = eff(concentration) if callable(eff) else eff + + signal = epsilon * concentration + flux_out = flux_in + + return (flux_out, signal) diff --git a/tests/tritium/test_ionisation_chamber.py b/tests/tritium/test_ionisation_chamber.py new file mode 100644 index 0000000..524f2b4 --- /dev/null +++ b/tests/tritium/test_ionisation_chamber.py @@ -0,0 +1,117 @@ +######################################################################################## +## +## TESTS FOR +## 'tritium.ionisation_chamber.py' +## +######################################################################################## + +# IMPORTS ============================================================================== + +import unittest + +from pathsim_chem.tritium import IonisationChamber + + +# TESTS ================================================================================ + +class TestIonisationChamber(unittest.TestCase): + """Test the IonisationChamber block.""" + + def test_init_constant_efficiency(self): + """Test initialization with constant detection efficiency.""" + ic = IonisationChamber(detection_efficiency=0.8) + self.assertEqual(ic.detection_efficiency, 0.8) + self.assertIsNone(ic.detection_threshold) + + def test_init_threshold(self): + """Test initialization with detection threshold.""" + ic = IonisationChamber(detection_threshold=10.0) + self.assertEqual(ic.detection_threshold, 10.0) + self.assertTrue(callable(ic.detection_efficiency)) + + def test_init_callable_efficiency(self): + """Test initialization with callable detection efficiency.""" + eff = lambda c: min(c / 100.0, 1.0) + ic = IonisationChamber(detection_efficiency=eff) + self.assertIs(ic.detection_efficiency, eff) + + def test_init_validation_both(self): + """Providing both parameters should raise ValueError.""" + with self.assertRaises(ValueError): + IonisationChamber(detection_efficiency=0.5, detection_threshold=10.0) + + def test_init_validation_neither(self): + """Providing neither parameter should raise ValueError.""" + with self.assertRaises(ValueError): + IonisationChamber() + + def test_port_labels(self): + """Test port label definitions.""" + self.assertEqual(IonisationChamber.input_port_labels["flux_in"], 0) + self.assertEqual(IonisationChamber.input_port_labels["flow_rate"], 1) + self.assertEqual(IonisationChamber.output_port_labels["flux_out"], 0) + self.assertEqual(IonisationChamber.output_port_labels["signal"], 1) + + def test_passthrough(self): + """Sample flux passes through unchanged.""" + ic = IonisationChamber(detection_efficiency=0.5) + ic.inputs[0] = 100.0 # flux_in + ic.inputs[1] = 10.0 # flow_rate + ic.update(None) + + self.assertAlmostEqual(ic.outputs[0], 100.0) + + def test_signal_constant_efficiency(self): + """Signal = efficiency * concentration.""" + ic = IonisationChamber(detection_efficiency=0.8) + ic.inputs[0] = 200.0 # flux_in + ic.inputs[1] = 10.0 # flow_rate -> concentration = 20 + ic.update(None) + + self.assertAlmostEqual(ic.outputs[1], 0.8 * 20.0) + + def test_signal_threshold_above(self): + """Above threshold, signal = concentration.""" + ic = IonisationChamber(detection_threshold=5.0) + ic.inputs[0] = 100.0 # flux + ic.inputs[1] = 10.0 # flow -> concentration = 10 > 5 + ic.update(None) + + self.assertAlmostEqual(ic.outputs[1], 10.0) + + def test_signal_threshold_below(self): + """Below threshold, signal = 0.""" + ic = IonisationChamber(detection_threshold=50.0) + ic.inputs[0] = 100.0 # flux + ic.inputs[1] = 10.0 # flow -> concentration = 10 < 50 + ic.update(None) + + self.assertAlmostEqual(ic.outputs[1], 0.0) + + def test_signal_callable_efficiency(self): + """Callable efficiency applied to concentration.""" + # Linear ramp: efficiency = c / 100, capped at 1 + eff = lambda c: min(c / 100.0, 1.0) + ic = IonisationChamber(detection_efficiency=eff) + ic.inputs[0] = 500.0 # flux + ic.inputs[1] = 10.0 # flow -> concentration = 50 + ic.update(None) + + # efficiency(50) = 0.5, signal = 0.5 * 50 = 25 + self.assertAlmostEqual(ic.outputs[1], 25.0) + + def test_zero_flow_rate(self): + """Zero flow rate should not crash, signal = 0.""" + ic = IonisationChamber(detection_efficiency=1.0) + ic.inputs[0] = 100.0 + ic.inputs[1] = 0.0 + ic.update(None) + + self.assertAlmostEqual(ic.outputs[0], 100.0) + self.assertAlmostEqual(ic.outputs[1], 0.0) + + +# RUN TESTS LOCALLY ==================================================================== + +if __name__ == '__main__': + unittest.main(verbosity=2)