parcae 0.1.1__py3-none-any.whl → 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.
- parcae/__init__.py +1 -1
- parcae/api.py +51 -10
- parcae/cli.py +102 -46
- {parcae-0.1.1.dist-info → parcae-0.2.0.dist-info}/METADATA +12 -3
- parcae-0.2.0.dist-info/RECORD +10 -0
- parcae-0.1.1.dist-info/RECORD +0 -10
- {parcae-0.1.1.data → parcae-0.2.0.data}/data/models/hmm.npz +0 -0
- {parcae-0.1.1.dist-info → parcae-0.2.0.dist-info}/WHEEL +0 -0
- {parcae-0.1.1.dist-info → parcae-0.2.0.dist-info}/entry_points.txt +0 -0
- {parcae-0.1.1.dist-info → parcae-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {parcae-0.1.1.dist-info → parcae-0.2.0.dist-info}/top_level.txt +0 -0
parcae/__init__.py
CHANGED
parcae/api.py
CHANGED
|
@@ -51,6 +51,22 @@ def _viterbi(obs, log_trans, log_emit, log_init):
|
|
|
51
51
|
return path, best
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _parse_timestamps(timestamps):
|
|
55
|
+
out = []
|
|
56
|
+
for t in timestamps:
|
|
57
|
+
if isinstance(t, datetime):
|
|
58
|
+
out.append(t)
|
|
59
|
+
else:
|
|
60
|
+
out.append(datetime.fromisoformat(str(t)))
|
|
61
|
+
return sorted(out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _downsample(x, k):
|
|
65
|
+
n = len(x)
|
|
66
|
+
idx = np.linspace(0, n, k + 1, dtype=int)
|
|
67
|
+
return np.array([x[idx[i] : idx[i + 1]].mean() for i in range(k)], dtype=np.float32)
|
|
68
|
+
|
|
69
|
+
|
|
54
70
|
class Parcae:
|
|
55
71
|
def __init__(self, model_path=None, bin_minutes=15):
|
|
56
72
|
if model_path is None:
|
|
@@ -72,15 +88,6 @@ class Parcae:
|
|
|
72
88
|
self.sleep_state = int(np.argmin(self.emissionprob[:, 1]))
|
|
73
89
|
self.awake_state = 1 - self.sleep_state
|
|
74
90
|
|
|
75
|
-
def _parse_timestamps(self, timestamps):
|
|
76
|
-
out = []
|
|
77
|
-
for t in timestamps:
|
|
78
|
-
if isinstance(t, datetime):
|
|
79
|
-
out.append(t)
|
|
80
|
-
else:
|
|
81
|
-
out.append(datetime.fromisoformat(str(t)))
|
|
82
|
-
return sorted(out)
|
|
83
|
-
|
|
84
91
|
def _bin(self, timestamps):
|
|
85
92
|
start = timestamps[0].replace(hour=0, minute=0, second=0, microsecond=0)
|
|
86
93
|
end = timestamps[-1].replace(
|
|
@@ -100,7 +107,7 @@ class Parcae:
|
|
|
100
107
|
return start, bins
|
|
101
108
|
|
|
102
109
|
def analyze(self, timestamps, tz_range=range(-12, 13)):
|
|
103
|
-
ts =
|
|
110
|
+
ts = _parse_timestamps(timestamps)
|
|
104
111
|
|
|
105
112
|
span = ts[-1] - ts[0]
|
|
106
113
|
if span < timedelta(days=2): # arbitrary number that seems fine
|
|
@@ -134,6 +141,13 @@ class Parcae:
|
|
|
134
141
|
shift_bins = int(best_phi * bins_per_day / 24)
|
|
135
142
|
best_bins = np.roll(bins, shift_bins)
|
|
136
143
|
|
|
144
|
+
days = len(best_bins) // bins_per_day
|
|
145
|
+
day_matrix = best_bins[: days * bins_per_day].reshape(days, bins_per_day)
|
|
146
|
+
|
|
147
|
+
profile = day_matrix.mean(axis=0)
|
|
148
|
+
profile = profile / (profile.sum() + 1e-8)
|
|
149
|
+
profile_24h = _downsample(profile, 24)
|
|
150
|
+
|
|
137
151
|
states, _ = _viterbi(
|
|
138
152
|
best_bins, self.log_transmat, self.log_emissionprob, self.log_startprob
|
|
139
153
|
)
|
|
@@ -158,6 +172,29 @@ class Parcae:
|
|
|
158
172
|
else:
|
|
159
173
|
awake_blocks.append((block_start, len(states)))
|
|
160
174
|
|
|
175
|
+
sleep_durations = [(b - a) * self.bin_minutes for a, b in sleep_blocks]
|
|
176
|
+
|
|
177
|
+
if sleep_durations:
|
|
178
|
+
dur = np.array(sleep_durations, dtype=np.float32)
|
|
179
|
+
sleep_stats = np.array([dur.mean(), dur.std(), np.median(dur)]) / 1440.0
|
|
180
|
+
else:
|
|
181
|
+
sleep_stats = np.zeros(3, dtype=np.float32)
|
|
182
|
+
|
|
183
|
+
if sleep_blocks:
|
|
184
|
+
starts = np.array([a for a, _ in sleep_blocks]) * self.bin_minutes
|
|
185
|
+
ends = np.array([b for _, b in sleep_blocks]) * self.bin_minutes
|
|
186
|
+
|
|
187
|
+
start_m = starts.mean()
|
|
188
|
+
end_m = ends.mean()
|
|
189
|
+
|
|
190
|
+
def circ(m):
|
|
191
|
+
ang = 2 * np.pi * m / 1440.0
|
|
192
|
+
return np.sin(ang), np.cos(ang)
|
|
193
|
+
|
|
194
|
+
sleep_phase = np.array([*circ(start_m), *circ(end_m)], dtype=np.float32)
|
|
195
|
+
else:
|
|
196
|
+
sleep_phase = np.zeros(4, dtype=np.float32)
|
|
197
|
+
|
|
161
198
|
def blocks_to_time(blocks):
|
|
162
199
|
out = []
|
|
163
200
|
for a, b in blocks:
|
|
@@ -170,4 +207,8 @@ class Parcae:
|
|
|
170
207
|
"timezone_offset_hours": int(best_phi),
|
|
171
208
|
"sleep_blocks": blocks_to_time(sleep_blocks),
|
|
172
209
|
"awake_blocks": blocks_to_time(awake_blocks),
|
|
210
|
+
"profile_24h": profile_24h,
|
|
211
|
+
"sleep_phase": sleep_phase,
|
|
212
|
+
"sleep_stats": sleep_stats.astype(np.float32),
|
|
213
|
+
"days": int(days),
|
|
173
214
|
}
|
parcae/cli.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import base64
|
|
2
3
|
import csv
|
|
3
4
|
import math
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
6
7
|
|
|
7
8
|
from parcae import Parcae
|
|
8
9
|
|
|
@@ -31,82 +32,137 @@ def format_hm(minutes):
|
|
|
31
32
|
return f"{h:02d}:{m:02d}"
|
|
32
33
|
|
|
33
34
|
|
|
34
|
-
def
|
|
35
|
-
|
|
35
|
+
def angle_to_minutes(sin_v, cos_v):
|
|
36
|
+
ang = math.atan2(sin_v, cos_v)
|
|
37
|
+
if ang < 0:
|
|
38
|
+
ang += 2 * math.pi
|
|
39
|
+
return int(round(ang * 1440 / (2 * math.pi)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def decode_fp(s):
|
|
43
|
+
s = s.split(":", 2)[2]
|
|
44
|
+
raw = base64.urlsafe_b64decode(s)
|
|
45
|
+
q = np.frombuffer(raw, dtype=np.int16)
|
|
46
|
+
return q.astype(np.float32) / 4096.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cosine(a, b):
|
|
50
|
+
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sparkline(x):
|
|
54
|
+
ticks = "▁▂▃▄▅▆▇█"
|
|
55
|
+
x = np.asarray(x, dtype=float)
|
|
36
56
|
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
lo = x.min()
|
|
58
|
+
hi = x.max()
|
|
39
59
|
|
|
40
|
-
if
|
|
41
|
-
return
|
|
60
|
+
if hi == lo:
|
|
61
|
+
return ticks[0] * len(x)
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
mean_angle += 2 * math.pi
|
|
63
|
+
scaled = (x - lo) / (hi - lo) * (len(ticks) - 1)
|
|
64
|
+
idx = np.round(scaled).astype(int)
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
return "".join(ticks[i] for i in idx)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def hour_axis(n=24, marks=(0, 6, 12, 18, 24)):
|
|
70
|
+
row = [" "] * n
|
|
71
|
+
for m in marks:
|
|
72
|
+
if m < n:
|
|
73
|
+
row[m] = "|"
|
|
74
|
+
return "".join(row)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def hour_labels(n=24, marks=(0, 6, 12, 18, 24)):
|
|
78
|
+
row = [" "] * n
|
|
79
|
+
for m in marks:
|
|
80
|
+
s = f"{m:02d}"
|
|
81
|
+
if m < n:
|
|
82
|
+
for i, c in enumerate(s):
|
|
83
|
+
if m + i < n:
|
|
84
|
+
row[m + i] = c
|
|
85
|
+
return "".join(row)
|
|
49
86
|
|
|
50
87
|
|
|
51
88
|
def main():
|
|
52
89
|
parser = argparse.ArgumentParser(prog="parcae")
|
|
53
|
-
parser.
|
|
54
|
-
|
|
90
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
91
|
+
|
|
92
|
+
p_analyze = sub.add_parser("analyze")
|
|
93
|
+
p_analyze.add_argument("csv", help="CSV file with a 'timestamp' column")
|
|
94
|
+
|
|
95
|
+
p_cmp = sub.add_parser("compare")
|
|
96
|
+
p_cmp.add_argument("fp1")
|
|
97
|
+
p_cmp.add_argument("fp2")
|
|
98
|
+
|
|
99
|
+
parser.add_argument("-v", "--version", action="version", version="%(prog)s 0.2.0")
|
|
100
|
+
|
|
55
101
|
args = parser.parse_args()
|
|
56
102
|
|
|
57
103
|
print("+ Parcae analysis\n")
|
|
58
104
|
|
|
105
|
+
if args.cmd == "compare":
|
|
106
|
+
v1 = decode_fp(args.fp1)
|
|
107
|
+
v2 = decode_fp(args.fp2)
|
|
108
|
+
sim = cosine(v1, v2)
|
|
109
|
+
|
|
110
|
+
print("+ fingerprint comparison:")
|
|
111
|
+
print(f"\tcosine similarity: {sim:.4f}")
|
|
112
|
+
|
|
113
|
+
if sim > 0.95:
|
|
114
|
+
print("\tmatch: very likely same user")
|
|
115
|
+
elif sim > 0.90:
|
|
116
|
+
print("\tmatch: probable")
|
|
117
|
+
else:
|
|
118
|
+
print("\tmatch: unlikely")
|
|
119
|
+
|
|
120
|
+
return
|
|
121
|
+
|
|
59
122
|
timestamps = parse_csv(args.csv)
|
|
60
123
|
|
|
61
124
|
p = Parcae()
|
|
62
125
|
result = p.analyze(timestamps)
|
|
63
126
|
|
|
64
127
|
tz = result["timezone_offset_hours"]
|
|
65
|
-
|
|
128
|
+
days = result["days"]
|
|
66
129
|
|
|
67
130
|
print(f"~ inferred timezone: UTC{tz:+d}\n")
|
|
68
131
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
local_blocks = []
|
|
72
|
-
for b in sleep_blocks:
|
|
73
|
-
start = datetime.fromisoformat(b["start"]) + offset
|
|
74
|
-
end = datetime.fromisoformat(b["end"]) + offset
|
|
75
|
-
local_blocks.append((start, end))
|
|
76
|
-
|
|
77
|
-
by_day = defaultdict(list)
|
|
132
|
+
sleep_phase = result["sleep_phase"]
|
|
133
|
+
sleep_stats = result["sleep_stats"]
|
|
78
134
|
|
|
79
|
-
|
|
80
|
-
day = start.date()
|
|
81
|
-
dur = (end - start).total_seconds()
|
|
82
|
-
by_day[day].append((dur, start, end))
|
|
135
|
+
profile_24h = result["profile_24h"]
|
|
83
136
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
blocks.sort(reverse=True)
|
|
87
|
-
_, start, end = blocks[0]
|
|
88
|
-
main_sleeps.append((start, end))
|
|
137
|
+
mean_start = angle_to_minutes(sleep_phase[0], sleep_phase[1])
|
|
138
|
+
mean_end = angle_to_minutes(sleep_phase[2], sleep_phase[3])
|
|
89
139
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
140
|
+
std_dur = int(round(sleep_stats[1] * 1440))
|
|
141
|
+
med_dur = int(round(sleep_stats[2] * 1440))
|
|
93
142
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
143
|
+
vec = np.concatenate(
|
|
144
|
+
[profile_24h, result["sleep_phase"], result["sleep_stats"]]
|
|
145
|
+
).astype(np.float32)
|
|
97
146
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
durations.sort()
|
|
101
|
-
med_dur = durations[len(durations) // 2]
|
|
147
|
+
q = np.round(vec * 4096).astype(np.int16)
|
|
148
|
+
fp = base64.urlsafe_b64encode(q.tobytes()).decode()
|
|
102
149
|
|
|
103
150
|
print("+ typical schedule:")
|
|
104
151
|
print(
|
|
105
152
|
f"\t- sleep: {format_hm(mean_start)} -> {format_hm(mean_end)} (≈ {med_dur // 60}h {med_dur % 60:02d}m)"
|
|
106
153
|
)
|
|
107
|
-
print(f"\t- awake: {format_hm(mean_end)} -> {format_hm(mean_start)}
|
|
154
|
+
print(f"\t- awake: {format_hm(mean_end)} -> {format_hm(mean_start)}")
|
|
155
|
+
print(f"\t- variability: ±{std_dur}m\n")
|
|
156
|
+
|
|
157
|
+
print("+ activity profile (24h):")
|
|
158
|
+
print(f"\t{sparkline(profile_24h)}")
|
|
159
|
+
print(f"\t{hour_axis(len(profile_24h))}")
|
|
160
|
+
print(f"\t{hour_labels(len(profile_24h))}\n")
|
|
161
|
+
|
|
162
|
+
print("+ fingerprint:")
|
|
163
|
+
print(f"\tparcae:v1:{fp}\n")
|
|
108
164
|
|
|
109
|
-
print(f"~ based on {
|
|
165
|
+
print(f"~ based on {days} days of data")
|
|
110
166
|
print(f"~ bin size: {p.bin_minutes} minutes")
|
|
111
167
|
|
|
112
168
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parcae
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Infer daily rhythm and sleep schedule from message timestamps
|
|
5
5
|
Project-URL: Homepage, https://github.com/jeremyctrl/parcae
|
|
6
6
|
Project-URL: Repository, https://github.com/jeremyctrl/parcae
|
|
@@ -102,8 +102,17 @@ parcae user_timestamps.csv
|
|
|
102
102
|
~ inferred timezone: UTC+3
|
|
103
103
|
|
|
104
104
|
+ typical schedule:
|
|
105
|
-
- sleep:
|
|
106
|
-
- awake:
|
|
105
|
+
- sleep: 23:52 -> 06:34 (≈ 8h 30m)
|
|
106
|
+
- awake: 06:34 -> 23:52
|
|
107
|
+
- variability: ±175m
|
|
108
|
+
|
|
109
|
+
+ activity profile (24h):
|
|
110
|
+
▁▁▁▁▁▁▁▁▅▇▅█▆▁▅▄▅▆▁▇▇▆▆▇
|
|
111
|
+
| | | |
|
|
112
|
+
00 06 12 18
|
|
113
|
+
|
|
114
|
+
+ fingerprint:
|
|
115
|
+
parcae:v1:AAAAAAAAAAAAAAAAAAAAAD0AWQA6AGMAQQAAADoAMAA6AEcAAABWAFUATgBMAFsAd__-D9QPqP12BPEBqwU=
|
|
107
116
|
|
|
108
117
|
~ based on 30 days of data
|
|
109
118
|
~ bin size: 15 minutes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
parcae/__init__.py,sha256=RWBvxIWTjlhW31HwPJFBhJtCByRYynX-H__Cuz5H_yU,69
|
|
2
|
+
parcae/api.py,sha256=8wXm2VqMxu4UqjmeN63I37_43XQsapbpuJr8KccKwM0,6558
|
|
3
|
+
parcae/cli.py,sha256=VUSOUOuCvA261jBKRg7wGftUCogvGrHhtZG5l0tStNA,4186
|
|
4
|
+
parcae-0.2.0.data/data/models/hmm.npz,sha256=EQ-azTtEJ9ZkAWldyjp3pe-kd9W0gdesGSv05LPLyrg,1118
|
|
5
|
+
parcae-0.2.0.dist-info/licenses/LICENSE,sha256=lkt0mQbom19fj92XgwyoYS3T2ES7S6F4aqV2-53AksA,1075
|
|
6
|
+
parcae-0.2.0.dist-info/METADATA,sha256=0N6BXvzOqIYAD9XeU1FUw-hgF9yLD5SbU0rciTtd0lE,3162
|
|
7
|
+
parcae-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
8
|
+
parcae-0.2.0.dist-info/entry_points.txt,sha256=okhIV74Y1DqE8UGhVycpEU2NTwq3ReBkaoxfm7BBsrI,43
|
|
9
|
+
parcae-0.2.0.dist-info/top_level.txt,sha256=S_O9fdoifLS3yrrHeidB6sQ61r8l6KBvSHZ0arhaGoE,7
|
|
10
|
+
parcae-0.2.0.dist-info/RECORD,,
|
parcae-0.1.1.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
parcae/__init__.py,sha256=kaQjVqPuTAVsSpYJazu-jx-zKLSXqsmOgjtB0iadAWY,69
|
|
2
|
-
parcae/api.py,sha256=O-JZw_HvZzC2yXSZGDgLMntwqnxaJs8edxEuTpAWCIw,5132
|
|
3
|
-
parcae/cli.py,sha256=NVpDsp-yMvgoeoTbyJjVpqK-iCc-Mcz7pzjCinbXg40,3115
|
|
4
|
-
parcae-0.1.1.data/data/models/hmm.npz,sha256=EQ-azTtEJ9ZkAWldyjp3pe-kd9W0gdesGSv05LPLyrg,1118
|
|
5
|
-
parcae-0.1.1.dist-info/licenses/LICENSE,sha256=lkt0mQbom19fj92XgwyoYS3T2ES7S6F4aqV2-53AksA,1075
|
|
6
|
-
parcae-0.1.1.dist-info/METADATA,sha256=h6-wrFE0A_jk2TBnTwmqtfOxNpKcer4oC7sm4x83aUY,2839
|
|
7
|
-
parcae-0.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
8
|
-
parcae-0.1.1.dist-info/entry_points.txt,sha256=okhIV74Y1DqE8UGhVycpEU2NTwq3ReBkaoxfm7BBsrI,43
|
|
9
|
-
parcae-0.1.1.dist-info/top_level.txt,sha256=S_O9fdoifLS3yrrHeidB6sQ61r8l6KBvSHZ0arhaGoE,7
|
|
10
|
-
parcae-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|