mekosotto commited on
Commit
dd8acc2
·
1 Parent(s): ccf23d1

feat(fusion): add clinical-test signal normalisers (MMSE/MoCA/UPDRS/gait/age)

Browse files
src/fusion/clinical.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Map raw clinical-test scores to a unitless signal in [-1, 1].
2
+
3
+ +1 means the test strongly supports the disease being present.
4
+ -1 means it strongly supports the disease being absent.
5
+ """
6
+ from __future__ import annotations
7
+
8
+
9
+ def _linear_map(value: float, low: float, high: float, *, invert: bool) -> float:
10
+ """Map `value` from [low, high] to [-1, 1]. If invert, flip sign."""
11
+ if high == low:
12
+ return 0.0
13
+ clipped = max(low, min(high, value))
14
+ norm = (clipped - low) / (high - low)
15
+ signal = 2.0 * norm - 1.0
16
+ return -signal if invert else signal
17
+
18
+
19
+ def mmse_to_signal(score: float) -> float:
20
+ """MMSE: 30 = healthy (-1), 18 = severe (+1), 24 = mild cutoff (0)."""
21
+ return _linear_map(score, low=18.0, high=30.0, invert=True)
22
+
23
+
24
+ def moca_to_signal(score: float) -> float:
25
+ """MoCA: same scale as MMSE."""
26
+ return _linear_map(score, low=18.0, high=30.0, invert=True)
27
+
28
+
29
+ def updrs_to_signal(score: float) -> float:
30
+ return _linear_map(score, low=0.0, high=199.0, invert=False)
31
+
32
+
33
+ def gait_to_signal(speed_m_s: float) -> float:
34
+ return _linear_map(speed_m_s, low=0.0, high=1.4, invert=True)
35
+
36
+
37
+ def age_to_signal(years: float) -> float:
38
+ return _linear_map(years, low=30.0, high=90.0, invert=False)
tests/fusion/test_clinical.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for src.fusion.clinical — per-test signal normalisers.
2
+
3
+ Convention: signal in [-1, 1] where +1 = strong evidence the disease IS
4
+ present and -1 = strong evidence it is NOT present.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+
10
+ from src.fusion import clinical
11
+
12
+
13
+ class TestMMSE:
14
+ def test_perfect_score_signals_no_alzheimers(self) -> None:
15
+ assert clinical.mmse_to_signal(30.0) == pytest.approx(-1.0)
16
+
17
+ def test_severely_impaired_signals_alzheimers(self) -> None:
18
+ assert clinical.mmse_to_signal(0.0) == pytest.approx(1.0)
19
+
20
+ def test_borderline_24_is_near_neutral_slightly_positive(self) -> None:
21
+ sig = clinical.mmse_to_signal(24.0)
22
+ assert -0.1 < sig < 0.5
23
+
24
+
25
+ class TestMoCA:
26
+ def test_perfect_signals_negative(self) -> None:
27
+ assert clinical.moca_to_signal(30.0) == pytest.approx(-1.0)
28
+
29
+ def test_zero_signals_positive(self) -> None:
30
+ assert clinical.moca_to_signal(0.0) == pytest.approx(1.0)
31
+
32
+
33
+ class TestUPDRS:
34
+ def test_zero_signals_no_parkinsons(self) -> None:
35
+ assert clinical.updrs_to_signal(0.0) == pytest.approx(-1.0)
36
+
37
+ def test_max_signals_parkinsons(self) -> None:
38
+ assert clinical.updrs_to_signal(199.0) == pytest.approx(1.0, abs=1e-3)
39
+
40
+
41
+ class TestGait:
42
+ def test_fast_walker_signals_negative(self) -> None:
43
+ assert clinical.gait_to_signal(1.4) < -0.4
44
+
45
+ def test_slow_walker_signals_positive(self) -> None:
46
+ assert clinical.gait_to_signal(0.3) > 0.4
47
+
48
+
49
+ class TestAge:
50
+ def test_young_signals_negative(self) -> None:
51
+ assert clinical.age_to_signal(30.0) < -0.4
52
+
53
+ def test_elderly_signals_positive(self) -> None:
54
+ assert clinical.age_to_signal(85.0) > 0.4