PyGlaucoMetrics 0.2.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.

Potentially problematic release.


This version of PyGlaucoMetrics might be problematic. Click here for more details.

@@ -0,0 +1,228 @@
1
+ """
2
+ vf_core.py
3
+ Pure-Python replacement for the R `visualFields` package.
4
+ Covers: normative lookup, TD/PD, MD/PSD/VFI, progression regression.
5
+
6
+ Dependencies: numpy, scipy, pandas — no R or rpy2 required.
7
+ """
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from scipy import stats
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # 1. Normative values
17
+ # ---------------------------------------------------------------------------
18
+ # Auto-load real Sunyiu 24-2 normatives computed from vfctrSunyiu24d2 dataset.
19
+ # Falls back to Heijl 1987 approximation if CSV not found.
20
+
21
+ def _load_default_normdb() -> np.ndarray:
22
+ """Load normvals_sunyiu24d2.csv if available, else use Heijl 1987 approximation."""
23
+ csv_path = Path(__file__).parent / 'data' / 'normvals_sunyiu24d2.csv'
24
+ if csv_path.exists():
25
+ df = pd.read_csv(csv_path)
26
+ return df[['intercept', 'slope']].values.astype(np.float32)
27
+ # Heijl 1987 approximation fallback (54 points)
28
+ return np.array([
29
+ [33.5, -0.082], [34.2, -0.082], [33.8, -0.083], [33.1, -0.082],
30
+ [32.9, -0.081], [33.0, -0.081], [33.2, -0.082], [32.8, -0.081],
31
+ [31.5, -0.080], [32.0, -0.081], [32.5, -0.080], [32.1, -0.079],
32
+ [30.8, -0.079], [31.3, -0.079], [31.6, -0.079], [31.0, -0.078],
33
+ [30.1, -0.078], [30.5, -0.078], [30.9, -0.078], [30.3, -0.077],
34
+ [29.5, -0.077], [29.8, -0.077], [30.2, -0.077], [29.6, -0.076],
35
+ [28.9, -0.076], [29.1, -0.076], [29.5, -0.076], [28.8, -0.075],
36
+ [28.0, -0.075], [28.4, -0.075], [28.7, -0.075], [28.1, -0.074],
37
+ [27.2, -0.074], [27.6, -0.074], [27.9, -0.074], [27.3, -0.073],
38
+ [26.5, -0.073], [26.8, -0.073], [27.1, -0.073], [26.5, -0.072],
39
+ [25.8, -0.072], [26.0, -0.072], [26.3, -0.072], [25.7, -0.071],
40
+ [25.0, -0.071], [25.2, -0.071], [25.5, -0.071], [24.9, -0.070],
41
+ [24.2, -0.070], [24.4, -0.070], [24.7, -0.070], [24.1, -0.069],
42
+ [23.5, -0.069], [23.7, -0.069],
43
+ ], dtype=np.float32)
44
+
45
+ _NORM_24_2 = _load_default_normdb()
46
+
47
+
48
+ def load_normative_db(csv_path: Optional[str] = None) -> np.ndarray:
49
+ """
50
+ Load normative table (54 × 2: intercept, slope).
51
+ Pass a CSV path with columns ['intercept', 'slope'] to override defaults.
52
+ """
53
+ if csv_path and Path(csv_path).exists():
54
+ df = pd.read_csv(csv_path)
55
+ return df[['intercept', 'slope']].values.astype(np.float32)
56
+ return _NORM_24_2
57
+
58
+
59
+ def age_matched_norms(age: float, norm_db: Optional[np.ndarray] = None) -> np.ndarray:
60
+ """Expected threshold per test point for a given age (years). Returns shape (54,)."""
61
+ db = norm_db if norm_db is not None else _NORM_24_2
62
+ return db[:, 0] + db[:, 1] * age # intercept + slope × age
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # 2. Pointwise deviations
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def total_deviation(sensitivity: np.ndarray, age: float,
70
+ norm_db: Optional[np.ndarray] = None) -> np.ndarray:
71
+ if age is None or np.isnan(age): # ← ADD
72
+ age = 60.0 # ← fallback to population mean
73
+ norms = age_matched_norms(age, norm_db)
74
+ return sensitivity - norms
75
+
76
+ def pattern_deviation(td: np.ndarray, percentile: float = 85.0) -> np.ndarray:
77
+ valid = td[~np.isnan(td)]
78
+ if len(valid) == 0: # ← ADD THIS GUARD
79
+ return np.full_like(td, np.nan)
80
+ general_height = np.percentile(valid, percentile)
81
+ return td - general_height
82
+
83
+ def compute_indices(sensitivity: np.ndarray, age: float,
84
+ weights=None, norm_db=None,
85
+ norm_percentile: float = 85.0) -> dict:
86
+ td = total_deviation(sensitivity, age, norm_db)
87
+ if np.sum(~np.isnan(td)) == 0: # all-NaN row — return safe nulls
88
+ nan54 = np.full(len(td), np.nan)
89
+ return {"td": nan54, "pd": nan54,
90
+ "MD": np.nan, "PSD": np.nan, "VFI": np.nan}
91
+ pd_vals = pattern_deviation(td, norm_percentile)
92
+ return {
93
+ "td": td,
94
+ "pd": pd_vals,
95
+ "MD": mean_deviation(td, weights),
96
+ "PSD": pattern_std(pd_vals, weights),
97
+ "VFI": vfi(pd_vals, weights),
98
+ }
99
+ # ---------------------------------------------------------------------------
100
+ # 3. Global indices
101
+ # ---------------------------------------------------------------------------
102
+
103
+ # Eccentricity-based weights for 24-2 (simplified Heijl 1987 Gaussian weights).
104
+ # In production, load the exact per-point weights from the normative package.
105
+ _WEIGHTS_24_2 = np.ones(54, dtype=np.float32) # uniform fallback
106
+
107
+
108
+ def mean_deviation(td: np.ndarray, weights: Optional[np.ndarray] = None) -> float:
109
+ w = weights if weights is not None else _WEIGHTS_24_2
110
+ mask = ~np.isnan(td)
111
+ if mask.sum() == 0: # ← ADD
112
+ return np.nan # ← nothing to average
113
+ return float(np.average(td[mask], weights=w[mask]))
114
+
115
+
116
+ def pattern_std(pd_vals: np.ndarray, weights: Optional[np.ndarray] = None) -> float:
117
+ """
118
+ Pattern Standard Deviation (PSD) in dB.
119
+ Weighted root-mean-square of pattern deviation values.
120
+ """
121
+ w = weights if weights is not None else _WEIGHTS_24_2
122
+ mask = ~np.isnan(pd_vals)
123
+ pd_m = pd_vals[mask]
124
+ w_m = w[mask]
125
+ w_m = w_m / w_m.sum()
126
+ weighted_mean = np.dot(w_m, pd_m)
127
+ weighted_var = np.dot(w_m, (pd_m - weighted_mean) ** 2)
128
+ return float(np.sqrt(weighted_var))
129
+
130
+
131
+ def vfi(pd_vals: np.ndarray, weights: Optional[np.ndarray] = None) -> float:
132
+ """
133
+ Visual Field Index (VFI) — approximate (Bengtsson & Heijl 2008).
134
+ Returns value in [0, 100] where 100 = normal.
135
+ """
136
+ w = weights if weights is not None else _WEIGHTS_24_2
137
+ mask = ~np.isnan(pd_vals)
138
+ pd_m = pd_vals[mask]
139
+ w_m = w[mask] / w[mask].sum()
140
+ # clamp PD contribution to [−30, 0] then scale to percentage
141
+ contrib = np.clip(pd_m, -30.0, 0.0) / 30.0 # 0 = normal, −1 = fully depressed
142
+ return float(100.0 * (1.0 + np.dot(w_m, contrib)))
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # 4. Progression analysis
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def vf_progression(dates: list, md_series: list) -> dict:
149
+ """
150
+ Linear regression of MD over time (OLS — mirrors `vfprogression` PLR).
151
+
152
+ Parameters
153
+ ----------
154
+ dates : list of date-like objects or float years (e.g. 2020.5)
155
+ md_series : list/array of MD values (dB)
156
+
157
+ Returns
158
+ -------
159
+ dict: slope (dB/year), intercept, r2, p_value, se, progression_flag
160
+ """
161
+ if isinstance(dates[0], (int, float)):
162
+ t = np.array(dates, dtype=float)
163
+ else:
164
+ t0 = pd.to_datetime(dates[0])
165
+ t = np.array([(pd.to_datetime(d) - t0).days / 365.25 for d in dates])
166
+
167
+ md = np.array(md_series, dtype=float)
168
+ mask = ~np.isnan(md)
169
+ if mask.sum() < 3:
170
+ return {"slope": np.nan, "intercept": np.nan,
171
+ "r2": np.nan, "p_value": np.nan,
172
+ "se": np.nan, "progression_flag": False}
173
+
174
+ slope, intercept, r, p, se = stats.linregress(t[mask], md[mask])
175
+ # Flag as progressing: slope < −1.0 dB/year AND p < 0.05 (common clinical threshold)
176
+ flag = bool(slope < -1.0 and p < 0.05)
177
+ return {
178
+ "slope": round(slope, 4),
179
+ "intercept": round(intercept, 4),
180
+ "r2": round(r ** 2, 4),
181
+ "p_value": round(p, 4),
182
+ "se": round(se, 4),
183
+ "progression_flag": flag,
184
+ }
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # 5. Probability maps (p < 0.05 / 0.02 / 0.01 / 0.005 flags)
189
+ # ---------------------------------------------------------------------------
190
+ # Normal distribution approximation; replace with empirical quantiles if available.
191
+
192
+ def _load_empirical_cutoffs():
193
+ """Load per-location empirical probability cutoffs if available."""
194
+ csv_path = Path(__file__).parent / 'data' / 'normvals_cutoffs.csv'
195
+ if csv_path.exists():
196
+ return pd.read_csv(csv_path).values # shape (54, 4): p0.005, p0.01, p0.02, p0.05
197
+ return None
198
+
199
+ _EMPIRICAL_CUTOFFS = _load_empirical_cutoffs() # (54,4) or None
200
+
201
+ _TD_PROB_CUTOFFS = {0.05: -2.0, 0.02: -2.5, 0.01: -3.0, 0.005: -3.5}
202
+
203
+
204
+ def probability_map(deviation: np.ndarray,
205
+ cutoffs: Optional[dict] = None) -> np.ndarray:
206
+ """
207
+ Assign probability level per test point.
208
+ Uses per-location empirical cutoffs if available (matches R exactly),
209
+ otherwise falls back to normal approximation.
210
+ Returns float array: 0.05, 0.02, 0.01, 0.005, or 1.0 (normal).
211
+ """
212
+ dev = np.array(deviation, dtype=float)
213
+ levels = np.ones(len(dev))
214
+
215
+ if _EMPIRICAL_CUTOFFS is not None and cutoffs is None:
216
+ n = min(len(dev), _EMPIRICAL_CUTOFFS.shape[0])
217
+ for j, p in zip([3, 2, 1, 0], [0.05, 0.02, 0.01, 0.005]):
218
+ col = _EMPIRICAL_CUTOFFS[:n, j]
219
+ for i in range(n):
220
+ if not np.isnan(dev[i]) and dev[i] <= col[i]:
221
+ levels[i] = p
222
+ else:
223
+ # Fallback: uniform dB cutoffs
224
+ cuts = cutoffs or _TD_PROB_CUTOFFS
225
+ for p_level in sorted(cuts.keys(), reverse=True):
226
+ levels[deviation <= cuts[p_level]] = p_level
227
+
228
+ return levels