melafit 0.1.1__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.
melafit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .fitting import *
2
+ from .markers import *
3
+ from .utils import *
melafit/fitting.py ADDED
@@ -0,0 +1,497 @@
1
+ import scipy.optimize as opt
2
+ import numpy as np
3
+
4
+ # Parameter names for melatonin wave approximation functions
5
+ BCF_PARAM_NAMES = ["phi", "b", "H", "c"]
6
+ SBCF_PARAM_NAMES = ["phi", "b", "H", "c", "v"]
7
+ BBCF_PARAM_NAMES = ["phi", "b", "H", "c", "m"]
8
+ BSBCF_PARAM_NAMES = ["phi", "b", "H", "c", "v", "m"]
9
+
10
+ def _resolve_params(p: np.ndarray | dict) -> np.ndarray:
11
+ """
12
+ Convert parameter dict to array if needed, pass array through unchanged.
13
+ """
14
+
15
+ if isinstance(p, dict):
16
+ return np.array(list(p.values()))
17
+ return p
18
+
19
+ def bcf(t: np.ndarray,
20
+ p: dict | np.ndarray) -> np.ndarray:
21
+ """
22
+ Baseline cosine function
23
+ [Ruf '92](https://doi.org/10.1076/brhm.27.2.153.12942)
24
+
25
+ Parameters
26
+ ----------
27
+ t : Numpy array of floats
28
+ Time values for the BCF waveform
29
+ p : Dictionary or Numpy array of floats
30
+ BCF parameters phi, b, H, c
31
+
32
+ Returns
33
+ -------
34
+ bcf_val : Numpy array of floats
35
+ Values of the BCF function for the respective time points
36
+ """
37
+
38
+ p = _resolve_params(p)
39
+
40
+ phi = p[0]
41
+ b = p[1]
42
+ H = p[2]
43
+ c = p[3]
44
+
45
+ phi = 2 * np.pi * phi
46
+ t = 2 * np.pi * t
47
+
48
+ bcf_val = b + H / (2 * (1 - c)) * (
49
+ np.cos(t - phi) - c + abs(np.cos(t - phi) - c))
50
+
51
+ return bcf_val
52
+
53
+ def sbcf(t: np.ndarray,
54
+ p: dict | np.ndarray) -> np.ndarray:
55
+ """
56
+ Skewed baseline cosine function
57
+ [Van Someren & Nagtegaal '07](https://doi.org/10.1016/j.sleep.2007.03.012)
58
+
59
+ Parameters
60
+ ----------
61
+ t : Numpy array of floats
62
+ Time values for the SBCF waveform
63
+ p : Dictionary or Numpy array of floats
64
+ SBCF parameters phi, b, H, c, v
65
+
66
+ Returns
67
+ -------
68
+ sbcf_val : Numpy array of floats
69
+ Values of the SBCF function for the respective time points
70
+ """
71
+
72
+ p = _resolve_params(p)
73
+
74
+ phi = p[0]
75
+ b = p[1]
76
+ H = p[2]
77
+ c = p[3]
78
+ v = p[4]
79
+
80
+ phi = 2 * np.pi * phi
81
+ t = 2 * np.pi * t
82
+
83
+ sbcf_val = b + H / (2 * (1 - c)) * (
84
+ np.cos(t - phi + v * np.cos(t - phi)) - c +
85
+ abs(np.cos(t - phi + v * np.cos(t - phi)) - c))
86
+
87
+ return sbcf_val
88
+
89
+ def bbcf(t: np.ndarray,
90
+ p: dict | np.ndarray) -> np.ndarray:
91
+ """
92
+ Bimodal baseline cosine function
93
+ [Van Someren & Nagtegaal '07](https://doi.org/10.1016/j.sleep.2007.03.012)
94
+
95
+ Parameters
96
+ ----------
97
+ t : Numpy array of floats
98
+ Time values for the BBCF waveform
99
+ p : Dictionary or Numpy array of floats
100
+ BBCF parameters phi, b, H, c, m
101
+
102
+ Returns
103
+ -------
104
+ bbcf_val : Numpy array of floats
105
+ Values of the BBCF function for the respective time points
106
+ """
107
+
108
+ p = _resolve_params(p)
109
+
110
+ phi = p[0]
111
+ b = p[1]
112
+ H = p[2]
113
+ c = p[3]
114
+ m = p[4]
115
+
116
+ phi = 2 * np.pi * phi
117
+ t = 2 * np.pi * t
118
+
119
+ bbcf_val = b + H / (2 * (1 - c)) * (
120
+ np.cos(t - phi) + m * np.cos(2 * t - 2 * phi - np.pi) - c +
121
+ abs(np.cos(t - phi) + m * np.cos(2 * t - 2 * phi - np.pi) - c))
122
+
123
+ return bbcf_val
124
+
125
+ def bsbcf(t: np.ndarray,
126
+ p: np.ndarray) -> np.ndarray:
127
+ """
128
+ Bimodal skewed baseline cosine function
129
+ [Van Someren & Nagtegaal '07](https://doi.org/10.1016/j.sleep.2007.03.012)
130
+
131
+ Parameters
132
+ ----------
133
+ t : Numpy array of floats
134
+ Time values for the bsbcf waveform
135
+ p : Numpy array of floats
136
+ BSBCF parameters phi, b, H, c, v, m
137
+
138
+ Returns
139
+ -------
140
+ bsbcf_val : Dictionary or Numpy array of floats
141
+ Values of the BSBCF function for the respective time points
142
+ """
143
+
144
+ p = _resolve_params(p)
145
+
146
+ phi = p[0]
147
+ b = p[1]
148
+ H = p[2]
149
+ c = p[3]
150
+ v = p[4]
151
+ m = p[5]
152
+
153
+ phi = 2 * np.pi * phi
154
+ t = 2 * np.pi * t
155
+
156
+ bsbcf_val = b + H / (2 * (1 - c)) * (
157
+ np.cos(t - phi + v * np.cos(t - phi)) +
158
+ m * np.cos(2 * t - 2 * phi - np.pi) - c +
159
+ abs(np.cos(t - phi + v * np.cos(t - phi)) +
160
+ m * np.cos(2 * t - 2 * phi - np.pi) - c))
161
+
162
+ return bsbcf_val
163
+
164
+ # Mapping of functions to parameter names for conversion between dict
165
+ # and array representations
166
+ PARAM_NAMES = {
167
+ bcf: BCF_PARAM_NAMES,
168
+ sbcf: SBCF_PARAM_NAMES,
169
+ bbcf: BBCF_PARAM_NAMES,
170
+ bsbcf: BSBCF_PARAM_NAMES,
171
+ }
172
+
173
+ def params_to_array(params: dict) -> np.ndarray:
174
+ """
175
+ Convert parameter dictionary to numpy array for scipy.optimize.
176
+
177
+ Parameters
178
+ ----------
179
+ params : dict
180
+ Dictionary of parameter names and values
181
+ Returns
182
+ -------
183
+ p : Numpy array of floats
184
+ Parameter vector for scipy.optimize
185
+ """
186
+ return np.array(list(params.values()))
187
+
188
+ def array_to_params(x: np.ndarray, f: callable) -> dict:
189
+ """
190
+ Convert scipy.optimize result array to named parameter dictionary.
191
+
192
+ Parameters
193
+ ----------
194
+ x : Numpy array of floats
195
+ Parameter vector from scipy.optimize
196
+ f : callable
197
+ Melatonin wave approximation function for which the parameters were fitted
198
+ Returns
199
+ -------
200
+ params : dict
201
+ Dictionary of parameter names and values for the respective function
202
+ """
203
+
204
+ param_names = PARAM_NAMES.get(f)
205
+ if param_names is None:
206
+ raise ValueError(f"Function {f.__name__} not recognized for parameter " +
207
+ "conversion.")
208
+ return dict(zip(param_names, x))
209
+
210
+ def cost(p: np.ndarray,
211
+ t: np.ndarray,
212
+ y: np.ndarray,
213
+ f: callable,
214
+ cost_p : dict | None = None) -> np.float64:
215
+ """
216
+ Cost function for melatonin fitting, penalizes the trivial solution when
217
+ all model values = 0
218
+ [Gabel et al. '17](https://doi.org/10.1038/s41598-017-07060-8)
219
+ NOTE: the order of parameters is pre-defined by the SciPy optimization
220
+ routine
221
+
222
+ Parameters
223
+ ----------
224
+ p : Numpy array of floats
225
+ Function parameter vector
226
+ t : Numpy array of floats
227
+ X-values for curve fitting (time)
228
+ y: Numpy array of floats
229
+ Y-values for curve fitting (melatonin levels)
230
+ f : callable
231
+ Melatonin wave approximation function
232
+ cost_p : dict | None
233
+ Cost function parameters (defaults to None) in which case
234
+ {"eps": 1e-8} is used
235
+
236
+ Returns
237
+ -------
238
+ val : float
239
+ Value of the cost function
240
+ """
241
+
242
+ if cost_p is None:
243
+ cost_p = {}
244
+ eps = cost_p.get("eps", 1e-8)
245
+
246
+ y_ = f(t, p)
247
+
248
+ return np.nanmean(np.square(y - y_)) / (np.var(y_) + eps)
249
+
250
+ def rsquared(Y: np.ndarray,
251
+ y: np.ndarray) -> np.float64:
252
+ """
253
+ R2 goodness of fit
254
+
255
+ Parameters
256
+ ----------
257
+ Y : Numpy array of floats
258
+ Reference values
259
+ y : Numpy array of floats
260
+ Fitted values
261
+
262
+ Returns
263
+ -------
264
+ r2 : float
265
+ R2 value
266
+ """
267
+
268
+ err = Y - y
269
+ Y_ = Y - np.nanmean(Y)
270
+ r2 = 1 - np.nansum(np.square(err)) / np.nansum(np.square(Y_))
271
+
272
+ return r2
273
+
274
+ def func_defaults(data_fit: np.ndarray,
275
+ f: callable) -> tuple[dict, dict, dict]:
276
+ """
277
+ Default initial conditions and constraints for melatonin wave approximation
278
+ functions
279
+
280
+ Parameters
281
+ ----------
282
+ data_fit : Numpy array of floats
283
+ Y-values for curve fitting (melatonin levels)
284
+ f : callable
285
+ Melatonin wave approximation function
286
+
287
+ Returns
288
+ -------
289
+ p0 : Dictionary
290
+ Initial guess for the function parameters
291
+ lb : Dictionary
292
+ Lower bounds for the function parameters
293
+ ub : Dictionary
294
+ Upper bounds for the function parameters
295
+ """
296
+
297
+ minx = np.min(data_fit)
298
+ maxx = np.max(data_fit)
299
+
300
+ data_range = (maxx - minx)
301
+
302
+ if f==bcf:
303
+ # Initial guess for BCF parameters
304
+ p0 = [
305
+ 0, # phi
306
+ minx, # b
307
+ (maxx-minx), # H
308
+ 0 # c
309
+ ]
310
+
311
+ # Lower bounds for BCF parameters
312
+ lb = [
313
+ -0.5, # phi
314
+ minx, # b
315
+ 0.5 * data_range, # H
316
+ -1 # c
317
+ ]
318
+
319
+ # Upper bounds for BCF parameters
320
+ ub = [
321
+ 0.5, # phi
322
+ maxx, # b
323
+ 2 * data_range, # H
324
+ 1 - 1e-6 # c
325
+ ]
326
+ elif f==sbcf:
327
+ # Initial guess for SBCF parameters
328
+ p0 = [
329
+ 0, # phi
330
+ minx, # b
331
+ (maxx-minx), # H
332
+ 0, # c
333
+ 0 # v
334
+ ]
335
+
336
+ # Lower bounds for SBCF parameters
337
+ lb = [
338
+ -0.5, # phi
339
+ minx, # b
340
+ 0.5 * data_range, # H
341
+ -1, # c
342
+ -1 # v
343
+ ]
344
+
345
+ # Upper bounds for SBCF parameters
346
+ ub = [
347
+ 0.5, # phi
348
+ maxx, # b
349
+ 2 * data_range, # H
350
+ 1 - 1e-6, # c
351
+ 1 # v
352
+ ]
353
+ elif f==bbcf:
354
+ # Initial guess for BBCF parameters
355
+ p0 = [
356
+ 0, # phi
357
+ minx, # b
358
+ (maxx-minx), # H
359
+ 0, # c
360
+ 0 # m
361
+ ]
362
+
363
+ # Lower bounds for BBCF parameters
364
+ lb = [
365
+ -0.5, # phi
366
+ minx, # b
367
+ 0.5 * data_range, # H
368
+ -1, # c
369
+ 0 # m
370
+ ]
371
+
372
+ # Upper bounds for BBCF parameters
373
+ ub = [
374
+ 0.5, # phi
375
+ maxx, # b
376
+ 2 * data_range, # H
377
+ 1 - 1e-6, # c
378
+ 1 - 1e-6 # m
379
+ ]
380
+ elif f==bsbcf:
381
+ # Initial guess for BSBCF parameters
382
+ p0 = [
383
+ 0, # phi
384
+ minx, # b
385
+ (maxx-minx), # H
386
+ 0, # c
387
+ 0, # v
388
+ 0 # m
389
+ ]
390
+
391
+ # Lower bounds for BSBCF parameters
392
+ lb = [
393
+ -0.5, # phi
394
+ minx, # b
395
+ 0.5 * data_range, # H
396
+ -1, # c
397
+ -1, # v
398
+ 0 # m
399
+ ]
400
+
401
+ # Upper bounds for BSBCF parameters
402
+ ub = [
403
+ 0.5, # phi
404
+ maxx, # b
405
+ 2 * data_range, # H
406
+ 1 - 1e-6, # c
407
+ 1, # v
408
+ 1 - 1e-6 # m
409
+ ]
410
+ else:
411
+ raise NotImplementedError("Constraints and initial conditions for " +
412
+ f"function '{f.__name__}' are not defined!")
413
+
414
+ return (array_to_params(p0, f),
415
+ array_to_params(lb, f),
416
+ array_to_params(ub, f))
417
+
418
+ def fit(time_fit: np.ndarray,
419
+ data_fit: np.ndarray,
420
+ f: callable=bsbcf,
421
+ p0: np.ndarray | None = None,
422
+ lb: np.ndarray | None = None,
423
+ ub: np.ndarray | None = None,
424
+ cost_f: callable=cost,
425
+ cost_p: dict | None = None) -> opt.OptimizeResult:
426
+ """
427
+ Melatonin data fitting routine
428
+
429
+ Parameters
430
+ ----------
431
+ time_fit : Numpy array of floats
432
+ X-values for curve fitting (time)
433
+ data_fit : Numpy array of floats
434
+ Y-values for curve fitting (melatonin levels)
435
+ f : callable
436
+ Melatonin wave approximation function (defaults to `bsbcf`)
437
+ p0 : Numpy array of floats or None
438
+ Non-standard initial values for wave approximation function
439
+ (defaults to 'None')
440
+ lb : Numpy array of floats or None
441
+ Non-standard lower bounds for wave approximation function
442
+ parameters (defaults to 'None')
443
+ ub : Numpy array of floats or None
444
+ Non-standard upper bounds for wave approximation function
445
+ parameters (defaults to 'None')
446
+ cost_f : callable
447
+ Cost function for curve fitting (defaults to `cost`)
448
+ cost_p : dict | None
449
+ Cost function parameters as dictionary or None (defaults to None)
450
+
451
+ Returns
452
+ -------
453
+ res : OptimizeResult
454
+ Optimization result including parameters of the fitted function
455
+ in the field `x`
456
+ """
457
+
458
+ # Only try to fetch defaults if we recognize the function
459
+ if f in PARAM_NAMES.keys():
460
+ _p0, _lb, _ub = func_defaults(data_fit, f)
461
+
462
+ if p0 is not None:
463
+ _p0 = p0
464
+
465
+ if lb is not None:
466
+ _lb = lb
467
+
468
+ if ub is not None:
469
+ _ub = ub
470
+ else:
471
+ # For custom functions, require the user to have provided p0/lb/ub
472
+ if p0 is None or lb is None or ub is None:
473
+ raise ValueError(f"Function '{f.__name__}' is not a built-in model. " +
474
+ "You must provide p0, lb, and ub manually.")
475
+ _p0, _lb, _ub = p0, lb, ub
476
+
477
+ bounds = opt.Bounds(_resolve_params(_lb), _resolve_params(_ub))
478
+ res = opt.minimize(fun=cost_f,
479
+ args=(time_fit, data_fit, f, cost_p),
480
+ x0=_resolve_params(_p0),
481
+ bounds=bounds)
482
+
483
+ if f in PARAM_NAMES:
484
+ res.p = array_to_params(res.x, f)
485
+ else:
486
+ if isinstance(_p0, dict):
487
+ param_names = list(_p0.keys())
488
+ elif isinstance(_lb, dict):
489
+ param_names = list(_lb.keys())
490
+ elif isinstance(_ub, dict):
491
+ param_names = list(_ub.keys())
492
+ else:
493
+ param_names = None
494
+
495
+ res.p = dict(zip(param_names, res.x)) if param_names is not None else None
496
+
497
+ return res
melafit/markers.py ADDED
@@ -0,0 +1,136 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from melafit.utils import day_profile, abs_threshold, time_to_phase
4
+
5
+ def amplitude(values: np.ndarray) -> np.float64:
6
+ """
7
+ Peak-to-baseline amplitude of fitted waveform
8
+
9
+ Parameters
10
+ ----------
11
+ values : Numpy array of floats
12
+ Waveform values
13
+
14
+ Returns
15
+ -------
16
+ ampl : float
17
+ Peak-to-baseline amplitude
18
+ """
19
+
20
+ return np.max(values) - np.min(values)
21
+
22
+
23
+ def midpoint(times: pd.DatetimeIndex,
24
+ values: np.ndarray,
25
+ threshold: np.float64,
26
+ thresh_abs: bool = False
27
+ ) -> tuple[np.float64, np.float64, np.float64, np.float64]:
28
+ """
29
+ Compute melatonin midpoint, DLMOn and DLMOff times. NOTE: This function
30
+ assumes that there is at least 24h of data. If this is not the case, the
31
+ results may be inaccurate. When working with waveforms, make sure to
32
+ generate a full 24h curve which is usually possible even with shorter
33
+ raw data the curve was fitted to.
34
+
35
+ Parameters
36
+ ----------
37
+ times : pandas DatetimeIndex
38
+ Datetime values
39
+ values : Numpy array of floats
40
+ Melatonin waveform values
41
+ threshold: float
42
+ Relative threshold, fraction of range peak-to-baseline (0 to 1)
43
+ thresh_abs: bool
44
+ If True, the given threshold is absolute. Otherwise, the absolute
45
+ threshold is computed from the given relative threshold and the
46
+ range of values (defaults to False)
47
+
48
+ Returns
49
+ -------
50
+ result : tuple[float, float, float, float]
51
+ Melatonin midpoint, DLMOn and DLMOff times as phase (from 0.0 to
52
+ 1.0, 1.0 = 24h), and absolute threshold
53
+
54
+ See also
55
+ --------
56
+ melafit.utils.compute_wave: Compute waveform resampled to given time
57
+ resolution
58
+ """
59
+
60
+ data_series = pd.Series(index=times, data=values)
61
+ d_profile = day_profile(data_series, binsize=1)[0]
62
+
63
+ if not thresh_abs:
64
+ threshold = abs_threshold(values, threshold)
65
+
66
+ idx_on = np.argwhere((d_profile.values[:-1] < threshold) &
67
+ (d_profile.values[1:] >= threshold))[0]
68
+ idx_off = np.argwhere((d_profile.values[:-1] >= threshold) &
69
+ (d_profile.values[1:] < threshold))[0]
70
+
71
+ time_on = d_profile.index.values[idx_on][0] / 24.0
72
+ time_off = d_profile.index.values[idx_off][0] / 24.0
73
+
74
+ if time_on > time_off:
75
+ time_off += 1.0
76
+
77
+ time_midpoint = 0.5 * (time_on + time_off)
78
+
79
+ time_midpoint = time_to_phase(time_midpoint)
80
+ time_on = time_to_phase(time_on)
81
+ time_off = time_to_phase(time_off)
82
+
83
+ return time_midpoint, time_on, time_off, threshold
84
+
85
+ def area_cog(times: pd.DatetimeIndex,
86
+ values: np.ndarray,
87
+ baseline: np.float64 | None = None
88
+ ) -> tuple[np.float64, np.float64]:
89
+ """
90
+ Center of gravity of area under the curve
91
+
92
+ Parameters
93
+ ----------
94
+ times : pandas DatetimeIndex
95
+ Datetime values
96
+ values : Numpy array of floats
97
+ Waveform values
98
+ baseline : float or None
99
+ Baseline for area computation. Equals to minimum of values if
100
+ None is given (default)
101
+
102
+ Returns
103
+ -------
104
+ area : float
105
+ Area under the curve
106
+ cog : float
107
+ Center of gravity of area under the curve as phase (from 0.0 to
108
+ 1.0, 1.0 = 24h)
109
+ """
110
+
111
+ if baseline is None:
112
+ baseline = np.min(values)
113
+
114
+ bin_minutes = 1
115
+
116
+ data_series = pd.Series(index=times, data=values)
117
+ d_profile = day_profile(data_series, binsize=bin_minutes)[0]
118
+
119
+ times = d_profile.index.values / 24.0
120
+ values = d_profile.values
121
+
122
+ idx_on = np.argwhere((values[:-1] <= baseline) &
123
+ (values[1:] > baseline))[0][0]
124
+
125
+ times = np.concatenate([times[idx_on:], 1.0 + times[:idx_on]])
126
+ values = np.concatenate([values[idx_on:], values[:idx_on]]) - baseline
127
+
128
+ area = np.sum(values)
129
+ cog = np.dot(values, times) / area
130
+
131
+ # Convert COG to phase (from 0.0 to 1.0, 1.0 = 24h)
132
+ cog = time_to_phase(cog)
133
+
134
+ area /= (24.0 * 60.0 / bin_minutes) # Normalize by bin size in minutes
135
+
136
+ return area, cog
melafit/utils.py ADDED
@@ -0,0 +1,322 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import datetime as dt
4
+
5
+ def read_data(data_pathname: str) -> pd.DataFrame:
6
+ """
7
+ Read data to be analyzed from an Excel spreadsheet.
8
+
9
+ Column must be named as follows:
10
+ * *Participant* for study participant ID
11
+ * *Date* for dates of the respective samples
12
+ * *Time* for sample timestamps
13
+ * *Mel* for melatonin level values
14
+
15
+ Parameters
16
+ ----------
17
+ data_pathname : str
18
+ Pathname of the Excel spreadsheet file to read data from
19
+
20
+ Returns
21
+ -------
22
+ data : pandas DataFrame
23
+ Data for all participants read from the Excel table
24
+ """
25
+
26
+ # Read data from Excel spreadsheet
27
+ data = pd.read_excel(data_pathname)
28
+
29
+ # Enforce correct data types
30
+ data.Participant = data.Participant.astype(int, errors="ignore")
31
+ data.Date = pd.to_datetime(data.Date, dayfirst=True, errors="coerce").dt.date
32
+ data.Time = pd.to_datetime(data.Time.astype(str), errors="coerce").dt.time
33
+ data.Mel = data.Mel.astype(float, errors="ignore")
34
+
35
+ # Add combined datetime timestamp
36
+ data["Timestamp"] = data.apply(lambda x: dt.datetime.combine(x.Date, x.Time), axis=1)
37
+
38
+ return data
39
+
40
+ def prepare_part_data(data: pd.DataFrame,
41
+ participant: str | int) -> pd.DataFrame:
42
+ """
43
+ Prepare one participant's data for analysis
44
+
45
+ Parameters
46
+ ----------
47
+ data : pandas DataFrame
48
+ All participants' data
49
+ participant : string or integer
50
+ Participant's identifier
51
+
52
+ Returns
53
+ -------
54
+ p_data : pandas DataFrame
55
+ Prepared data for one participant
56
+ """
57
+
58
+ # Select participant's data
59
+ p_data = data.loc[data.Participant==participant]
60
+
61
+ # Extract cumulative time in days
62
+ base = p_data.Timestamp.min()
63
+ diff = p_data.Timestamp - base
64
+ p_data["Timedays"] = (diff.dt.total_seconds() / (24*60*60) +
65
+ base.hour / 24 +
66
+ base.minute / (24*60) +
67
+ base.second / (24*60*60))
68
+
69
+ # Check and fix errors in timestamps
70
+ idiff = np.diff(p_data.Timedays) < 0
71
+
72
+ if any(idiff):
73
+ ix = np.where(idiff)
74
+
75
+ for i in ix:
76
+ idx = p_data.index[i[0]+1] # get the actual index label
77
+ p_data.loc[idx, 'Timestamp'] += pd.Timedelta(days=1)
78
+ p_data.loc[idx, 'Timedays'] += 1.0
79
+ print(f"Corrected one timestamp for participant {participant}")
80
+
81
+ return p_data
82
+
83
+ def compute_wave(tmin: np.float64,
84
+ tmax: np.float64,
85
+ dt_minutes: np.float64,
86
+ f: callable,
87
+ p: dict | np.ndarray,
88
+ full_wave: bool = True) -> np.ndarray:
89
+ """
90
+ Compute waveform resampled to given time resolution
91
+
92
+ Parameters
93
+ ----------
94
+ tmin : float
95
+ Start time (1.0 = 24 hours)
96
+ tmax : float
97
+ Stop time (inclusive, 1.0 = 24 hours)
98
+ dt_minutes : float
99
+ Time increment in minutes
100
+ f : callable
101
+ Waveform function
102
+ p : Dictionary or Numpy array of floats
103
+ Waveform parameter vector
104
+ full_wave: bool
105
+ If True and (tmax-tmin) < 1.0, tmax = tmin + 1.0 (defaults to
106
+ True)
107
+
108
+ Returns
109
+ -------
110
+ curve_val : Numpy array of floats
111
+ Values of the waveform function for the respective time points
112
+ """
113
+
114
+ if full_wave and ((tmax - tmin) < 1.0):
115
+ tmax = tmin + 1.0
116
+
117
+ step = 1.0 / (dt_minutes * 24 * 60)
118
+ time_curve = np.arange(tmin, tmax + 1.1 * step, step)
119
+ curve_val = f(t=time_curve, p=p)
120
+
121
+ return curve_val
122
+
123
+ def day_profile(data: pd.Series,
124
+ binsize: int = 60,
125
+ double: bool = False,
126
+ stderr: bool = False,
127
+ repfirst: bool = False)->tuple[pd.Series, pd.Series]:
128
+ """
129
+ Compute averaged day profile of a (quasi-)periodic time series
130
+
131
+ Parameters
132
+ ----------
133
+ data : pandas Series
134
+ Time series data
135
+ binsize : int
136
+ Bin size in minutes (defaults to 60)
137
+ double: bool
138
+ Prepare data for double plot (defaults to False)
139
+ stderr : bool
140
+ Compute standard errors per bin (defaults to False)
141
+ repfirst : bool
142
+ Add first bin at 00:00 to the end (defaults to False)
143
+
144
+ Returns
145
+ -------
146
+ profile : tuple[pd.Series, pd.Series]
147
+ Bin averages and standard errors with index in hours (0..24)
148
+ """
149
+
150
+ # Bin data, ensure centering of the data points around bin centers
151
+ smpstr=str(binsize)+'min'
152
+ profile = data.shift(0.5, freq=smpstr).resample(smpstr).mean()
153
+ profile = profile.groupby(profile.index.hour + profile.index.minute/60)
154
+
155
+ # Compute average profile and standard deviations for each bin
156
+ profile_mean = profile.mean()
157
+ profile_std = profile.std()
158
+
159
+ # If standard errors requested, compute these from std's and bin counts
160
+ if stderr:
161
+ profile_std = profile_std / np.sqrt(profile.count())
162
+
163
+ # Concatenate results
164
+ profile = pd.DataFrame(data=pd.concat([profile_mean, profile_std],
165
+ axis=1))
166
+
167
+ # Prepare data for double plot if requested
168
+ if double:
169
+ profile = pd.concat([profile, profile])
170
+
171
+ # Add first bin at 00:00 to the end
172
+ if repfirst:
173
+ profile = pd.concat([profile, pd.DataFrame(profile.iloc[0,:]).T])
174
+
175
+ # Split returned results up for maximum flexibility
176
+ return profile.iloc[:,0], profile.iloc[:,1]
177
+
178
+ def time_to_phase(t: np.float64,
179
+ hours: bool = False) -> np.float64:
180
+ """
181
+ Convert time values to phase representation (0.0 to 1.0, 1.0 = 24h)
182
+
183
+ Parameters
184
+ ----------
185
+ t : float
186
+ Time value (in days or hours)
187
+ hours: bool
188
+ If True, time value is in hours and will be converted to phase by
189
+ dividing by 24. If False, time value is in days (1.0 = 24h).
190
+ Defaults to False.
191
+
192
+ Returns
193
+ -------
194
+ phase : float
195
+ Time as phase (0.0 to 1.0, 1.0 = 24h)
196
+ """
197
+
198
+ if hours:
199
+ t = t / 24.0
200
+
201
+ if t < 0:
202
+ return np.ceil(-t) + t
203
+ else:
204
+ return t - np.floor(t)
205
+
206
+ def phase_to_string(phase: np.float64) -> str:
207
+ """
208
+ Convert phase representation of time (0.0 to 1.0) to string
209
+
210
+ Parameters
211
+ ----------
212
+ phase : float
213
+ Time as phase (0.0 to 1.0, 1.0 = 24h)
214
+
215
+ Returns
216
+ -------
217
+ string : str
218
+ String representation of phase
219
+ """
220
+
221
+ if phase < 0:
222
+ sign_str = "-"
223
+ phase = -phase
224
+ else:
225
+ sign_str = ""
226
+
227
+ td = pd.Timedelta(days=phase)
228
+ td_in_seconds = td.total_seconds()
229
+
230
+ hours, remainder = divmod(td_in_seconds, 3600)
231
+ minutes, _ = divmod(remainder, 60)
232
+ hours = int(hours)
233
+ minutes = int(minutes)
234
+
235
+ string = f"{sign_str}{hours:02d}:{minutes:02d}"
236
+
237
+ return string
238
+
239
+ def abs_threshold(values: np.ndarray,
240
+ thresh_rel: np.float64) -> np.float64:
241
+ """
242
+ Compute absolute threshold from relative threshold
243
+
244
+ Parameters
245
+ ----------
246
+ values : Numpy array of floats
247
+ Waveform values
248
+ thresh_rel: float
249
+ Relative threshold, fraction of range peak-to-baseline (0 to 1)
250
+
251
+ Returns
252
+ -------
253
+ thresh_abs : float
254
+ Absolute threshold
255
+ """
256
+
257
+ baseline = np.min(values)
258
+ val_range = np.max(values) - baseline
259
+ thresh_abs = baseline + thresh_rel * val_range
260
+
261
+ return thresh_abs
262
+
263
+ def phase_diff(phase1: np.float64,
264
+ phase2: np.float64) -> np.float64:
265
+ """
266
+ Compute difference between two phases (0.0 to 1.0, 1.0 = 24h)
267
+
268
+ Parameters
269
+ ----------
270
+ phase1 : float
271
+ First time as phase (0.0 to 1.0, 1.0 = 24h)
272
+ phase2 : float
273
+ Second time as phase (0.0 to 1.0, 1.0 = 24h)
274
+
275
+ Returns
276
+ -------
277
+ dp : float
278
+ Difference between the two phases (0.0 to 1.0, 1.0 = 24h),
279
+ adjusted to be in the range -0.5 to 0.5 (i.e., -12h to 12h)
280
+ """
281
+
282
+ # Make sure we deal with phases in the range 0.0 to 1.0
283
+ phase1 = time_to_phase(phase1, hours=False)
284
+ phase2 = time_to_phase(phase2, hours=False)
285
+
286
+ dp = phase1 - phase2
287
+
288
+ # Adjust difference to be in the range -0.5 to 0.5 (i.e., -12h to 12h)
289
+ if dp < -0.5:
290
+ dp += 1.0
291
+ elif dp > 0.5:
292
+ dp -= 1.0
293
+
294
+ return dp
295
+
296
+ def params_to_string(params: dict | np.ndarray, ndec: int = 3) -> str:
297
+ """
298
+ Convert curve fitting parameters to string
299
+
300
+ Parameters
301
+ ----------
302
+ params : dict or Numpy array of floats
303
+ Curve fitting parameters
304
+ ndec : int
305
+ Number of decimal places to display (defaults to 3)
306
+
307
+ Returns
308
+ -------
309
+ string : str
310
+ String representation of curve fitting parameters
311
+ """
312
+
313
+ if isinstance(params, dict):
314
+ param_strs = [f"{key}={value:.{ndec}f}"
315
+ for key, value in params.items()]
316
+ else:
317
+ param_strs = [f"p{i}={value:.{ndec}f}"
318
+ for i, value in enumerate(params)]
319
+
320
+ string = ", ".join(param_strs)
321
+
322
+ return string
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: melafit
3
+ Version: 0.1.1
4
+ Summary: High-precision circadian melatonin profile analysis
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/vitaliy-ch25/melafit
7
+ Requires-Python: >=3.12
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy
10
+ Requires-Dist: scipy
11
+ Requires-Dist: pandas
12
+ Requires-Dist: openpyxl
13
+ Requires-Dist: matplotlib
14
+ Dynamic: license-file
@@ -0,0 +1,9 @@
1
+ melafit/__init__.py,sha256=J26zmnP1H9BSnzAqLpeQPpwi4sNcaxEhdtfsQXVjxsU,66
2
+ melafit/fitting.py,sha256=nr0ozZAryy-CWrocQz_tICIC355Bb7JMhU1hSIofrQo,13190
3
+ melafit/markers.py,sha256=4mLNp0HcXydMwSO0sK4sV1_35HWsvjVL2XCBx2A5m8o,4224
4
+ melafit/utils.py,sha256=tELEIRzqwWtu7PLtVHS2Vrbrl8M7oYYHDJqkPteqlew,9188
5
+ melafit-0.1.1.dist-info/licenses/LICENSE,sha256=LmGtoza4siaMUr-hbi1mSVLAVrNrTk0roIjs_WIeMeU,1096
6
+ melafit-0.1.1.dist-info/METADATA,sha256=o9jIY8WPZOfe-JHR5JpqQE4vFGjbhhlYg5eKWfZFN20,370
7
+ melafit-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ melafit-0.1.1.dist-info/top_level.txt,sha256=ZSDwz00o4NuPeW4PSUc0udjKmTgpqy20DNJE5M1sSv0,8
9
+ melafit-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vitaliy Kolodyazhniy, Christian Cajochen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ melafit