ZaksPhysicsLibrary 1.2.2__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.
@@ -0,0 +1,287 @@
1
+ """
2
+ file_parser_generic.py
3
+ ----------------------
4
+ Best-effort parser for arbitrary tabular files.
5
+
6
+ Supports:
7
+ .xlsx / .xls — openpyxl required (pip install openpyxl)
8
+ .csv — comma-delimited
9
+ .tsv — tab-delimited
10
+ .txt / .dat / other — delimiter sniffed, then numpy genfromtxt fallback
11
+
12
+ The key feature is *sub-table detection*: a single sheet or file can contain
13
+ multiple independent tables laid out side-by-side (separated by empty columns)
14
+ or stacked (separated by empty rows). Each block is returned as a separate
15
+ GenericTable so the user can choose which one to plot.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import csv
21
+ import os
22
+ from dataclasses import dataclass, field
23
+ from typing import Optional
24
+
25
+ import numpy as np
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Public data structure
30
+ # ---------------------------------------------------------------------------
31
+
32
+ @dataclass
33
+ class GenericTable:
34
+ name: str
35
+ headers: list[str]
36
+ data: np.ndarray # (n_rows, n_cols) float64; NaN for missing
37
+ metadata: dict = field(default_factory=dict)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Public entry point
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def load_any_file(path: str) -> list[GenericTable]:
45
+ """
46
+ Parse *path* and return every detected table.
47
+ Raises ValueError if nothing useful is found.
48
+ """
49
+ ext = os.path.splitext(path)[1].lower()
50
+ if ext in ('.xlsx', '.xls'):
51
+ return _parse_excel(path)
52
+ elif ext == '.csv':
53
+ return _parse_delimited(path, delimiter=',')
54
+ elif ext == '.tsv':
55
+ return _parse_delimited(path, delimiter='\t')
56
+ else:
57
+ return _parse_text(path)
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Excel
62
+ # ---------------------------------------------------------------------------
63
+
64
+ def _parse_excel(path: str) -> list[GenericTable]:
65
+ try:
66
+ import openpyxl
67
+ except ImportError:
68
+ raise ImportError("Install openpyxl to read .xlsx files: pip install openpyxl")
69
+
70
+ wb = openpyxl.load_workbook(path, data_only=True)
71
+ tables: list[GenericTable] = []
72
+
73
+ for sheet_name in wb.sheetnames:
74
+ ws = wb[sheet_name]
75
+ # Load full sheet into a 2-D list, trim trailing all-None rows
76
+ grid: list[list] = [list(row) for row in ws.iter_rows(values_only=True)]
77
+ while grid and all(c is None for c in grid[-1]):
78
+ grid.pop()
79
+ if not grid:
80
+ continue
81
+
82
+ n_rows = len(grid)
83
+ n_cols = max(len(r) for r in grid)
84
+ for r in grid: # pad to uniform width
85
+ while len(r) < n_cols:
86
+ r.append(None)
87
+
88
+ # ── Find empty-column boundaries ──────────────────────────────────
89
+ empty_col = [
90
+ all(grid[r][c] is None for r in range(n_rows))
91
+ for c in range(n_cols)
92
+ ]
93
+
94
+ # Collect contiguous non-empty column spans
95
+ col_groups: list[tuple[int, int]] = [] # (start_inclusive, end_exclusive)
96
+ c = 0
97
+ while c < n_cols:
98
+ if not empty_col[c]:
99
+ j = c
100
+ while j < n_cols and not empty_col[j]:
101
+ j += 1
102
+ col_groups.append((c, j))
103
+ c = j
104
+ else:
105
+ c += 1
106
+
107
+ for c_start, c_end in col_groups:
108
+ sub = _extract_block(grid, n_rows, c_start, c_end)
109
+ if sub is None:
110
+ continue
111
+
112
+ col_letter_range = f"cols {_col_letter(c_start)}-{_col_letter(c_end - 1)}"
113
+ tname = (sheet_name if len(col_groups) == 1
114
+ else f"{sheet_name} | {col_letter_range}")
115
+ t = _block_to_table(sub, tname, {'sheet': sheet_name, 'col_start': c_start})
116
+ if t is not None:
117
+ tables.append(t)
118
+
119
+ return tables
120
+
121
+
122
+ def _extract_block(grid: list[list], n_rows: int,
123
+ c_start: int, c_end: int) -> Optional[list[list]]:
124
+ """Slice columns c_start..c_end-1 from grid; trim trailing all-None rows."""
125
+ sub = [[grid[r][c] for c in range(c_start, c_end)] for r in range(n_rows)]
126
+ while sub and all(c is None for c in sub[-1]):
127
+ sub.pop()
128
+ return sub if len(sub) >= 2 else None
129
+
130
+
131
+ def _block_to_table(sub: list[list], name: str, meta: dict) -> Optional[GenericTable]:
132
+ """
133
+ Given a 2-D block of raw cells, find the header row, extract numeric data,
134
+ drop all-NaN rows and columns, and return a GenericTable.
135
+ """
136
+ # ── Find header row ────────────────────────────────────────────────────
137
+ # The header row is the LAST row where >= 50 % of non-None values are strings.
138
+ header_row = 0
139
+ for ri, row in enumerate(sub):
140
+ non_none = [c for c in row if c is not None]
141
+ if not non_none:
142
+ continue
143
+ str_frac = sum(1 for c in non_none if isinstance(c, str)) / len(non_none)
144
+ if str_frac >= 0.5:
145
+ header_row = ri
146
+
147
+ # ── Build column headers ───────────────────────────────────────────────
148
+ headers: list[str] = []
149
+ for ci, val in enumerate(sub[header_row]):
150
+ if val is not None and str(val).strip():
151
+ headers.append(str(val).strip())
152
+ else:
153
+ # Fall back to nearest non-None value above in the same column
154
+ label = f"Col{ci + 1}"
155
+ for prev_ri in range(header_row - 1, -1, -1):
156
+ pv = sub[prev_ri][ci]
157
+ if pv is not None and str(pv).strip():
158
+ label = str(pv).strip()
159
+ break
160
+ headers.append(label)
161
+
162
+ # ── Convert data rows to float ─────────────────────────────────────────
163
+ data_rows = sub[header_row + 1:]
164
+ if not data_rows:
165
+ return None
166
+
167
+ float_grid: list[list[float]] = []
168
+ for row in data_rows:
169
+ converted = []
170
+ for v in row:
171
+ try:
172
+ converted.append(float(v)) # type: ignore[arg-type]
173
+ except (TypeError, ValueError):
174
+ converted.append(float('nan'))
175
+ float_grid.append(converted)
176
+
177
+ arr = np.array(float_grid, dtype=np.float64)
178
+
179
+ # Drop all-NaN columns, then all-NaN rows
180
+ valid_cols = [c for c in range(arr.shape[1]) if not np.all(np.isnan(arr[:, c]))]
181
+ if not valid_cols:
182
+ return None
183
+ arr = arr[:, valid_cols]
184
+ headers = [headers[c] if c < len(headers) else f"Col{c + 1}" for c in valid_cols]
185
+
186
+ valid_rows = [r for r in range(arr.shape[0]) if not np.all(np.isnan(arr[r, :]))]
187
+ if not valid_rows:
188
+ return None
189
+ arr = arr[valid_rows, :]
190
+
191
+ return GenericTable(name=name, headers=headers, data=arr, metadata=meta)
192
+
193
+
194
+ def _col_letter(i: int) -> str:
195
+ """0-based column index → Excel column letter (A, B, … Z, AA, AB, …)."""
196
+ s = ''
197
+ i += 1
198
+ while i > 0:
199
+ i, r = divmod(i - 1, 26)
200
+ s = chr(65 + r) + s
201
+ return s
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # CSV / TSV
206
+ # ---------------------------------------------------------------------------
207
+
208
+ def _parse_delimited(path: str, delimiter: str) -> list[GenericTable]:
209
+ with open(path, 'r', encoding='utf-8', errors='replace') as fh:
210
+ rows = [r for r in csv.reader(fh, delimiter=delimiter)]
211
+ if not rows:
212
+ return []
213
+ t = _rows_to_table(rows, os.path.basename(path))
214
+ return [t] if t is not None else []
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Plain text (sniff delimiter)
219
+ # ---------------------------------------------------------------------------
220
+
221
+ def _parse_text(path: str) -> list[GenericTable]:
222
+ with open(path, 'r', encoding='utf-8', errors='replace') as fh:
223
+ sample = fh.read(8192)
224
+
225
+ # Pick the most-frequent candidate delimiter
226
+ counts = {'\t': sample.count('\t'), ',': sample.count(','), ';': sample.count(';')}
227
+ best = max(counts, key=counts.get)
228
+ if counts[best] > 0:
229
+ return _parse_delimited(path, best)
230
+
231
+ # Last resort: numpy whitespace splitting
232
+ try:
233
+ arr = np.genfromtxt(path, comments='#', filling_values=np.nan)
234
+ if arr.ndim == 1:
235
+ arr = arr[:, np.newaxis]
236
+ headers = [f"Col{i + 1}" for i in range(arr.shape[1])]
237
+ return [GenericTable(name=os.path.basename(path), headers=headers, data=arr)]
238
+ except Exception:
239
+ return []
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Helpers shared by CSV / text paths
244
+ # ---------------------------------------------------------------------------
245
+
246
+ def _rows_to_table(rows: list[list[str]], name: str) -> Optional[GenericTable]:
247
+ if not rows:
248
+ return None
249
+
250
+ # Find header row: last row where >= 50 % of non-empty cells are non-numeric
251
+ header_row = 0
252
+ for ri, row in enumerate(rows):
253
+ non_empty = [c for c in row if c.strip()]
254
+ if not non_empty:
255
+ continue
256
+ non_numeric = sum(1 for c in non_empty if not _is_numeric(c))
257
+ if non_numeric / len(non_empty) >= 0.5:
258
+ header_row = ri
259
+
260
+ headers = [c.strip() or f"Col{i + 1}" for i, c in enumerate(rows[header_row])]
261
+
262
+ float_grid: list[list[float]] = []
263
+ for row in rows[header_row + 1:]:
264
+ if not any(c.strip() for c in row):
265
+ continue
266
+ converted = []
267
+ for c in row:
268
+ try:
269
+ converted.append(float(c))
270
+ except ValueError:
271
+ converted.append(float('nan'))
272
+ float_grid.append(converted)
273
+
274
+ if not float_grid:
275
+ return None
276
+
277
+ arr = np.array(float_grid, dtype=np.float64)
278
+ return GenericTable(name=name, headers=headers, data=arr)
279
+
280
+
281
+ def _is_numeric(s: str) -> bool:
282
+ try:
283
+ float(s)
284
+ return True
285
+ except ValueError:
286
+ return False
287
+
File without changes
@@ -0,0 +1,235 @@
1
+ """
2
+ loaders/oxysoft_loader.py
3
+ ---------------------------
4
+ Oxysoft / Artinis .txt export parsing (Oxymon, OctaMon, PortaMon, ...).
5
+
6
+ _parse_oxysoft_txt() is the low-level single-file parser; load_oxysoft()
7
+ concatenates a whole folder of exports; load_oxysoft_file() wraps a
8
+ single file into a Dataset for the "open one file" path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+
19
+ from ..dataset import Dataset
20
+
21
+
22
+ def load_oxysoft(folder_path: str, folder_name: str) -> Dataset:
23
+ """
24
+ Parse an Oxysoft TXT export folder.
25
+ Multiple .txt files are concatenated in alphabetical order.
26
+ """
27
+ txt_files = sorted(
28
+ f for f in os.listdir(folder_path) if f.lower().endswith('.txt')
29
+ )
30
+ if not txt_files:
31
+ raise FileNotFoundError(f"No .txt files found in: {folder_path}")
32
+
33
+ all_o2hb: list[np.ndarray] = []
34
+ all_hhb: list[np.ndarray] = []
35
+ all_events: list[dict] = []
36
+ metadata: dict = {}
37
+ ch_labels: list[str] = []
38
+ sample_rate: float = 0.0
39
+ first_file = True
40
+
41
+ for fname in txt_files:
42
+ fpath = os.path.join(folder_path, fname)
43
+ o2hb, hhb, events, meta, labels, fs = _parse_oxysoft_txt(fpath)
44
+
45
+ if first_file:
46
+ metadata = meta
47
+ ch_labels = labels
48
+ sample_rate = fs
49
+ first_file = False
50
+
51
+ all_o2hb.append(o2hb)
52
+ all_hhb.append(hhb)
53
+ all_events.extend(events)
54
+
55
+ # o2hb / hhb: shape (n_channels, n_samples)
56
+ o2hb_concat = np.concatenate(all_o2hb, axis=1)
57
+ hhb_concat = np.concatenate(all_hhb, axis=1)
58
+
59
+ n_ch, n_samp = o2hb_concat.shape
60
+ duration_s = n_samp / sample_rate if sample_rate > 0 else 0.0
61
+
62
+ # Stack as (2 * n_channels, n_samples): first all O2Hb, then all HHb
63
+ signals = np.concatenate([o2hb_concat, hhb_concat], axis=0)
64
+
65
+ # Channel names: e.g. ['Tx1 O2Hb', 'Tx2 O2Hb', 'Tx3 O2Hb', 'Tx1 HHb', ...]
66
+ o2hb_names = [f"{l} O2Hb" for l in ch_labels]
67
+ hhb_names = [f"{l} HHb" for l in ch_labels]
68
+
69
+ return Dataset(
70
+ source_format = "Oxysoft",
71
+ folder_path = folder_path,
72
+ folder_name = folder_name,
73
+ sample_rate = sample_rate,
74
+ num_samples = n_samp,
75
+ duration_s = duration_s,
76
+ signals = signals,
77
+ channel_names = o2hb_names + hhb_names,
78
+ num_channels = 2 * n_ch,
79
+ events = all_events,
80
+ metadata = {
81
+ **metadata,
82
+ "n_channels": n_ch, # how many physical channels (not * 2)
83
+ },
84
+ )
85
+
86
+
87
+ def load_oxysoft_file(file_path: str) -> Dataset:
88
+ """Parse a single Oxysoft .txt export into a Dataset."""
89
+ folder_name = os.path.splitext(os.path.basename(file_path))[0]
90
+ o2hb, hhb, events, metadata, ch_labels, sample_rate = _parse_oxysoft_txt(file_path)
91
+ n_ch, n_samp = o2hb.shape
92
+ signals = np.concatenate([o2hb, hhb], axis=0)
93
+ return Dataset(
94
+ source_format = "Oxysoft",
95
+ folder_path = os.path.dirname(file_path),
96
+ folder_name = folder_name,
97
+ sample_rate = sample_rate,
98
+ num_samples = n_samp,
99
+ duration_s = n_samp / sample_rate if sample_rate > 0 else 0.0,
100
+ signals = signals,
101
+ channel_names = [f"{l} O2Hb" for l in ch_labels] + [f"{l} HHb" for l in ch_labels],
102
+ num_channels = 2 * n_ch,
103
+ events = events,
104
+ metadata = {**metadata, "n_channels": n_ch},
105
+ )
106
+
107
+
108
+ def _parse_oxysoft_txt(
109
+ filepath: str,
110
+ ) -> tuple[np.ndarray, np.ndarray, list[dict], dict, list[str], float]:
111
+ """
112
+ Parse a single Oxysoft .txt file.
113
+
114
+ The Oxysoft format has a Legend block that maps column numbers to
115
+ channel names, followed by a numeric header row (1 2 3 ...) and
116
+ then the data. Row 0 is absolute baseline and is skipped.
117
+
118
+ Returns
119
+ -------
120
+ o2hb : np.ndarray shape (n_channels, n_samples)
121
+ hhb : np.ndarray shape (n_channels, n_samples)
122
+ events : list of {'label': str, 'sample': int}
123
+ metadata : dict
124
+ channel_labels: list[str] e.g. ['Tx1', 'Tx2', 'Tx3']
125
+ sample_rate : float (Hz)
126
+ """
127
+ metadata: dict = {}
128
+ events: list = []
129
+ data_rows: list = []
130
+ sample_rate = 0.0
131
+
132
+ # Will be filled from the Legend block
133
+ # col_index → ('O2Hb' | 'HHb', channel_label)
134
+ col_map: dict[int, tuple[str, str]] = {}
135
+ event_col: Optional[int] = None
136
+
137
+ in_legend = False
138
+ in_data = False
139
+ fit_factor_col: Optional[int] = None
140
+
141
+ with open(filepath, 'r', encoding='utf-8', errors='replace') as fh:
142
+ for raw_line in fh:
143
+ line = raw_line.rstrip('\n')
144
+ parts = line.split('\t')
145
+
146
+ # ---- metadata -----------------------------------------------
147
+ if not in_legend and not in_data:
148
+ if 'sample rate' in line.lower():
149
+ try:
150
+ sample_rate = float(parts[1].strip().split()[0])
151
+ except (IndexError, ValueError):
152
+ pass
153
+
154
+ if line.strip() == 'Legend:':
155
+ in_legend = True
156
+ continue
157
+
158
+ # ---- legend block -------------------------------------------
159
+ if in_legend:
160
+ # The column-number header row signals end of legend
161
+ stripped = [p.strip() for p in parts]
162
+ if all(p.isdigit() for p in stripped if p):
163
+ in_legend = False
164
+ in_data = True
165
+ continue
166
+
167
+ # Legend rows look like: "2\tRx1 - Tx1 O2Hb (filename)"
168
+ if len(parts) >= 2:
169
+ try:
170
+ col_idx = int(parts[0].strip())
171
+ col_desc = parts[1].strip()
172
+ except ValueError:
173
+ continue
174
+
175
+ if '(Event)' in col_desc or 'Event' in col_desc:
176
+ event_col = col_idx - 1 # convert to 0-based
177
+ elif 'O2Hb' in col_desc:
178
+ m = re.search(r'Tx\d+', col_desc)
179
+ label = m.group(0) if m else f"Ch{col_idx}"
180
+ col_map[col_idx - 1] = ('O2Hb', label)
181
+ elif 'HHb' in col_desc:
182
+ m = re.search(r'Tx\d+', col_desc)
183
+ label = m.group(0) if m else f"Ch{col_idx}"
184
+ col_map[col_idx - 1] = ('HHb', label)
185
+ elif 'Fit Factor' in col_desc:
186
+ fit_factor_col = col_idx - 1 # 0-based
187
+ # TSI%, sample number → ignored
188
+ continue
189
+
190
+ # ---- data section -------------------------------------------
191
+ if not in_data or len(parts) < 2:
192
+ continue
193
+
194
+ try:
195
+ row = [float(p) if p.strip() else 0.0 for p in parts]
196
+ except ValueError:
197
+ continue
198
+
199
+ # Skip row 0 — absolute baseline, not delta values
200
+ sample_num = int(row[0])
201
+ if sample_num == 0:
202
+ continue
203
+
204
+ data_rows.append(row)
205
+
206
+ # Events
207
+ if event_col is not None and event_col < len(row):
208
+ ev = parts[event_col].strip() if event_col < len(parts) else ''
209
+ if ev and ev != '0':
210
+ events.append({'label': ev, 'sample': len(data_rows) - 1})
211
+
212
+ if not data_rows:
213
+ raise ValueError(f"No data rows parsed from {filepath}")
214
+
215
+ data = np.array(data_rows, dtype=np.float64) # (n_samples, n_cols)
216
+
217
+ # Sort col_map into ordered O2Hb and HHb columns
218
+ o2hb_cols = sorted(
219
+ [(idx, label) for idx, (kind, label) in col_map.items() if kind == 'O2Hb'],
220
+ key=lambda x: x[1]
221
+ )
222
+ hhb_cols = sorted(
223
+ [(idx, label) for idx, (kind, label) in col_map.items() if kind == 'HHb'],
224
+ key=lambda x: x[1]
225
+ )
226
+
227
+ channel_labels = [label for _, label in o2hb_cols]
228
+
229
+ o2hb = np.array([data[:, idx] for idx, _ in o2hb_cols]) # (n_ch, n_samp)
230
+ hhb = np.array([data[:, idx] for idx, _ in hhb_cols]) # (n_ch, n_samp)
231
+
232
+ if fit_factor_col is not None and fit_factor_col < data.shape[1]:
233
+ metadata['fit_factor_mean'] = float(np.mean(data[:, fit_factor_col]))
234
+
235
+ return o2hb, hhb, events, metadata, channel_labels, sample_rate
@@ -0,0 +1,43 @@
1
+ """
2
+ loaders/pt2_loader.py
3
+ -----------------------
4
+ Terranova / Prospa .pt2 EFNMR/MRI image parser.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ def load_pt2(path: str) -> np.ndarray:
11
+ """
12
+ Parse a Terranova Prospa .pt2 2D NMR/MRI image file.
13
+
14
+ The reconstructed magnitude image is stored after the 4-byte marker
15
+ b'LAER' ('REAL' reversed) as little-endian float32 values.
16
+
17
+ Returns
18
+ -------
19
+ np.ndarray
20
+ 2D float32 array shaped (n, n).
21
+ """
22
+ with open(path, 'rb') as f:
23
+ raw = f.read()
24
+
25
+ pos = raw.find(b'LAER')
26
+ if pos == -1:
27
+ raise ValueError("Not a recognised .pt2 file — LAER image marker not found.")
28
+ pos += 4
29
+
30
+ arr = np.frombuffer(raw[pos:], dtype='<f4').copy()
31
+ n_total = len(arr)
32
+
33
+ for n in [16, 32, 64, 128, 256]:
34
+ if n_total == n * n and np.isfinite(arr).all() and arr.max() > 0:
35
+ return arr.reshape(n, n)
36
+
37
+ sq = int(np.sqrt(n_total))
38
+ if sq * sq == n_total and np.isfinite(arr).all() and arr.max() > 0:
39
+ return arr.reshape(sq, sq)
40
+
41
+ raise ValueError(
42
+ f"Could not determine image dimensions ({n_total} floats after LAER)."
43
+ )
@@ -0,0 +1,49 @@
1
+ """
2
+ loaders/tdt_loader.py
3
+ -----------------------
4
+ Wraps processing_TDT's validate + process pipeline and packages the
5
+ result into a universal Dataset.
6
+ """
7
+
8
+ import numpy as np
9
+
10
+ from .. import processing_TDT
11
+ from ..dataset import Dataset
12
+
13
+
14
+ def load_tdt(folder_path: str, folder_name: str) -> Dataset:
15
+ """Load a TDT tank using the existing validate + process pipeline."""
16
+ valid, msg = processing_TDT.validate_tdt_folder(folder_path)
17
+ if not valid:
18
+ raise ValueError(f"TDT validation failed: {msg}")
19
+
20
+ result = processing_TDT.process_tdt_folder(folder_path)
21
+ signals = result["corr"]
22
+ signals_2d = signals[np.newaxis, :]
23
+ fs = float(result["fs"])
24
+ num_samp = signals.shape[0]
25
+
26
+ events = []
27
+ for marker in result["markers"]:
28
+ events.append({
29
+ "label": marker["label"],
30
+ "sample": int(round(marker["time"] * fs)),
31
+ })
32
+
33
+ return Dataset(
34
+ source_format = "TDT",
35
+ folder_path = folder_path,
36
+ folder_name = folder_name,
37
+ sample_rate = fs,
38
+ num_samples = num_samp,
39
+ duration_s = num_samp / fs if fs > 0 else 0.0,
40
+ signals = signals_2d,
41
+ channel_names = [result["store"]],
42
+ num_channels = 1,
43
+ events = events,
44
+ metadata = {
45
+ "f0": result["f0"],
46
+ "raw": result["raw"],
47
+ "x": result["x"],
48
+ },
49
+ )
@@ -0,0 +1,62 @@
1
+ """
2
+ models.py
3
+ ---------
4
+ Parametric model functions for curve fitting via scipy.optimize.curve_fit.
5
+
6
+ Each function follows the signature f(x, *params) -> y so they can be
7
+ passed directly to fit_model_to_segment in analysis.py.
8
+ """
9
+
10
+ import numpy as np
11
+
12
+
13
+ def visibility_model(beta, a, v, beta_c, period):
14
+ """
15
+ Photon-entanglement visibility model.
16
+
17
+ Parameters
18
+ ----------
19
+ beta : array Polarizer angle (degrees or radians)
20
+ a : float Amplitude of the sinusoid
21
+ v : float Visibility (0–1)
22
+ beta_c : float Angular offset / centre
23
+ period : float Period of the oscillation
24
+
25
+ Returns
26
+ -------
27
+ array
28
+ """
29
+ return (a / 2) * (1 - v * np.sin((beta - beta_c) / period))
30
+
31
+
32
+ def double_exponential_model(x, a, b, c, d, k):
33
+ """
34
+ Physical model of fluorophore bleaching.
35
+ y = a*exp(-b*x) + c*exp(-d*x) + k
36
+ """
37
+ return a * np.exp(-b * x) + c * np.exp(-d * x) + k
38
+
39
+
40
+ def linear_model(x, m, b):
41
+ """y = mx + b"""
42
+ return m * x + b
43
+
44
+
45
+ def single_exponential_model(x, a, b, c):
46
+ """y = a * exp(-b*x) + c"""
47
+ return a * np.exp(-b * x) + c
48
+
49
+
50
+ def exponential_rise_model(x, a, b, c):
51
+ """y = a * (1 - exp(-b*x)) + c — rising exponential (e.g. venous occlusion)"""
52
+ return a * (1 - np.exp(-b * x)) + c
53
+
54
+
55
+ def gaussian_model(x, a, mu, sigma):
56
+ """y = a * exp(-(x-mu)^2 / (2*sigma^2))"""
57
+ return a * np.exp(-((x - mu) ** 2) / (2 * sigma ** 2))
58
+
59
+
60
+ def sinusoidal_model(x, a, f, phi, c):
61
+ """y = a * sin(2*pi*f*x + phi) + c"""
62
+ return a * np.sin(2 * np.pi * f * x + phi) + c