parcae 0.1.0__tar.gz
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 parcae might be problematic. Click here for more details.
- parcae-0.1.0/LICENSE +21 -0
- parcae-0.1.0/PKG-INFO +110 -0
- parcae-0.1.0/README.md +93 -0
- parcae-0.1.0/models/hmm.npz +0 -0
- parcae-0.1.0/parcae/__init__.py +5 -0
- parcae-0.1.0/parcae/api.py +173 -0
- parcae-0.1.0/parcae/cli.py +114 -0
- parcae-0.1.0/parcae.egg-info/PKG-INFO +110 -0
- parcae-0.1.0/parcae.egg-info/SOURCES.txt +13 -0
- parcae-0.1.0/parcae.egg-info/dependency_links.txt +1 -0
- parcae-0.1.0/parcae.egg-info/entry_points.txt +2 -0
- parcae-0.1.0/parcae.egg-info/requires.txt +6 -0
- parcae-0.1.0/parcae.egg-info/top_level.txt +1 -0
- parcae-0.1.0/pyproject.toml +31 -0
- parcae-0.1.0/setup.cfg +4 -0
parcae-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 jeremyctrl (Jeremy)
|
|
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.
|
parcae-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parcae
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Infer daily rhythm and sleep schedule from message timestamps
|
|
5
|
+
Project-URL: Homepage, https://github.com/jeremyctrl/parcae
|
|
6
|
+
Project-URL: Repository, https://github.com/jeremyctrl/parcae
|
|
7
|
+
Project-URL: Issues, https://github.com/jeremyctrl/parcae/issues
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=2.4.1
|
|
12
|
+
Provides-Extra: train
|
|
13
|
+
Requires-Dist: hmmlearn>=0.3.3; extra == "train"
|
|
14
|
+
Requires-Dist: matplotlib>=3.10.8; extra == "train"
|
|
15
|
+
Requires-Dist: pandas>=3.0.0; extra == "train"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
<div align="center">
|
|
19
|
+
|
|
20
|
+
# parcae
|
|
21
|
+
|
|
22
|
+
<div>
|
|
23
|
+
<a href="https://pypi.org/project/parcae/"><img src="https://img.shields.io/pypi/v/parcae.svg" alt="PyPI"></a>
|
|
24
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<a href="https://heyjeremy.vercel.app/blog/when-do-you-sleep"><img src="https://img.shields.io/badge/blog-when%20do%20you%20sleep%3F%20learning%20a%20pattern%20of%20life%20from%20message%20timestamps-blue" alt="Blog"></a>
|
|
28
|
+
|
|
29
|
+
Infer daily rhythm and sleep schedule from message timestamps
|
|
30
|
+
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
`parcae` is a command-line tool and Python library that analyzes nothing but timestamps and infers a user's likely timezone offset and their typical sleep window.
|
|
34
|
+
|
|
35
|
+
## How It Works
|
|
36
|
+
|
|
37
|
+
`parcae` models human behavior as a very small Hidden Markov Model with two hidden states:
|
|
38
|
+
|
|
39
|
+
- Awake (A)
|
|
40
|
+
- Sleep (S)
|
|
41
|
+
|
|
42
|
+
The only observation is "was there at least one message in this time bin?". The model is trained globally across many users to learn:
|
|
43
|
+
|
|
44
|
+
- how likely people are to send messages while "awake"
|
|
45
|
+
- how unlikely they are to send messages while "asleep"
|
|
46
|
+
- how often they switch between the two states
|
|
47
|
+
|
|
48
|
+
At inference time, Parcae:
|
|
49
|
+
|
|
50
|
+
1. Tries many possible timezone offsets
|
|
51
|
+
2. Picks the offset that makes the timeline most explainable by a "human with one long sleep per day"
|
|
52
|
+
3. Decodes the most likely sleep/awake sequence
|
|
53
|
+
4. Extracts daily sleep blocks
|
|
54
|
+
5. Computes a typical schedule and regularity statistics
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
You can install `parcae` using [pipx](https://pypa.github.io/pipx):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pipx install parcae
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### API
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from parcae import Parcae
|
|
70
|
+
|
|
71
|
+
p = Parcae()
|
|
72
|
+
|
|
73
|
+
timestamps = [
|
|
74
|
+
"2025-09-01T05:43:12+00:00",
|
|
75
|
+
"2025-09-01T18:22:10+00:00",
|
|
76
|
+
...
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
print(p.analyze(timestamps))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### CLI
|
|
83
|
+
|
|
84
|
+
Parcae expects a CSV file with one user's timestamps:
|
|
85
|
+
|
|
86
|
+
```csv
|
|
87
|
+
timestamp
|
|
88
|
+
2025-09-01T05:43:12+00:00
|
|
89
|
+
2025-09-01T07:58:33+00:00
|
|
90
|
+
2025-09-01T18:22:10+00:00
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
parcae user_timestamps.csv
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Examples
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
+ Parcae analysis
|
|
101
|
+
|
|
102
|
+
~ inferred timezone: UTC+3
|
|
103
|
+
|
|
104
|
+
+ typical schedule:
|
|
105
|
+
- sleep: 02:46 -> 11:38 (≈ 8h 45m)
|
|
106
|
+
- awake: 11:38 -> 02:46
|
|
107
|
+
|
|
108
|
+
~ based on 30 days of data
|
|
109
|
+
~ bin size: 15 minutes
|
|
110
|
+
```
|
parcae-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# parcae
|
|
4
|
+
|
|
5
|
+
<div>
|
|
6
|
+
<a href="https://pypi.org/project/parcae/"><img src="https://img.shields.io/pypi/v/parcae.svg" alt="PyPI"></a>
|
|
7
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<a href="https://heyjeremy.vercel.app/blog/when-do-you-sleep"><img src="https://img.shields.io/badge/blog-when%20do%20you%20sleep%3F%20learning%20a%20pattern%20of%20life%20from%20message%20timestamps-blue" alt="Blog"></a>
|
|
11
|
+
|
|
12
|
+
Infer daily rhythm and sleep schedule from message timestamps
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
`parcae` is a command-line tool and Python library that analyzes nothing but timestamps and infers a user's likely timezone offset and their typical sleep window.
|
|
17
|
+
|
|
18
|
+
## How It Works
|
|
19
|
+
|
|
20
|
+
`parcae` models human behavior as a very small Hidden Markov Model with two hidden states:
|
|
21
|
+
|
|
22
|
+
- Awake (A)
|
|
23
|
+
- Sleep (S)
|
|
24
|
+
|
|
25
|
+
The only observation is "was there at least one message in this time bin?". The model is trained globally across many users to learn:
|
|
26
|
+
|
|
27
|
+
- how likely people are to send messages while "awake"
|
|
28
|
+
- how unlikely they are to send messages while "asleep"
|
|
29
|
+
- how often they switch between the two states
|
|
30
|
+
|
|
31
|
+
At inference time, Parcae:
|
|
32
|
+
|
|
33
|
+
1. Tries many possible timezone offsets
|
|
34
|
+
2. Picks the offset that makes the timeline most explainable by a "human with one long sleep per day"
|
|
35
|
+
3. Decodes the most likely sleep/awake sequence
|
|
36
|
+
4. Extracts daily sleep blocks
|
|
37
|
+
5. Computes a typical schedule and regularity statistics
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
You can install `parcae` using [pipx](https://pypa.github.io/pipx):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pipx install parcae
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### API
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from parcae import Parcae
|
|
53
|
+
|
|
54
|
+
p = Parcae()
|
|
55
|
+
|
|
56
|
+
timestamps = [
|
|
57
|
+
"2025-09-01T05:43:12+00:00",
|
|
58
|
+
"2025-09-01T18:22:10+00:00",
|
|
59
|
+
...
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
print(p.analyze(timestamps))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### CLI
|
|
66
|
+
|
|
67
|
+
Parcae expects a CSV file with one user's timestamps:
|
|
68
|
+
|
|
69
|
+
```csv
|
|
70
|
+
timestamp
|
|
71
|
+
2025-09-01T05:43:12+00:00
|
|
72
|
+
2025-09-01T07:58:33+00:00
|
|
73
|
+
2025-09-01T18:22:10+00:00
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
parcae user_timestamps.csv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
#### Examples
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
+ Parcae analysis
|
|
84
|
+
|
|
85
|
+
~ inferred timezone: UTC+3
|
|
86
|
+
|
|
87
|
+
+ typical schedule:
|
|
88
|
+
- sleep: 02:46 -> 11:38 (≈ 8h 45m)
|
|
89
|
+
- awake: 11:38 -> 02:46
|
|
90
|
+
|
|
91
|
+
~ based on 30 days of data
|
|
92
|
+
~ bin size: 15 minutes
|
|
93
|
+
```
|
|
Binary file
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import sysconfig
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _logsumexp(a):
|
|
9
|
+
m = np.max(a)
|
|
10
|
+
return m + np.log(np.sum(np.exp(a - m)))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _forward_log(obs, log_trans, log_emit, log_init):
|
|
14
|
+
T = len(obs)
|
|
15
|
+
alpha = np.zeros((T, 2))
|
|
16
|
+
|
|
17
|
+
alpha[0] = log_init + log_emit[:, obs[0]]
|
|
18
|
+
|
|
19
|
+
for t in range(1, T):
|
|
20
|
+
for j in range(2):
|
|
21
|
+
alpha[t, j] = log_emit[j, obs[t]] + _logsumexp(
|
|
22
|
+
alpha[t - 1] + log_trans[:, j]
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
return _logsumexp(alpha[T - 1])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _viterbi(obs, log_trans, log_emit, log_init):
|
|
29
|
+
T = len(obs)
|
|
30
|
+
dp = np.zeros((T, 2))
|
|
31
|
+
back = np.zeros((T, 2), dtype=np.uint8)
|
|
32
|
+
|
|
33
|
+
dp[0] = log_init + log_emit[:, obs[0]]
|
|
34
|
+
|
|
35
|
+
for t in range(1, T):
|
|
36
|
+
for j in range(2):
|
|
37
|
+
scores = dp[t - 1] + log_trans[:, j]
|
|
38
|
+
k = np.argmax(scores)
|
|
39
|
+
dp[t, j] = scores[k] + log_emit[j, obs[t]]
|
|
40
|
+
back[t, j] = k
|
|
41
|
+
|
|
42
|
+
last = np.argmax(dp[T - 1])
|
|
43
|
+
best = dp[T - 1, last]
|
|
44
|
+
|
|
45
|
+
path = np.zeros(T, dtype=np.int8)
|
|
46
|
+
path[T - 1] = last
|
|
47
|
+
|
|
48
|
+
for t in range(T - 2, -1, -1):
|
|
49
|
+
path[t] = back[t + 1, path[t + 1]]
|
|
50
|
+
|
|
51
|
+
return path, best
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Parcae:
|
|
55
|
+
def __init__(self, model_path=None, bin_minutes=15):
|
|
56
|
+
if model_path is None:
|
|
57
|
+
data_path = Path(sysconfig.get_paths()["data"]) / "models"
|
|
58
|
+
model_path = data_path / "hmm.npz"
|
|
59
|
+
|
|
60
|
+
data = np.load(model_path)
|
|
61
|
+
|
|
62
|
+
self.startprob = data["startprob"]
|
|
63
|
+
self.transmat = data["transmat"]
|
|
64
|
+
self.emissionprob = data["emissionprob"]
|
|
65
|
+
|
|
66
|
+
self.log_startprob = np.log(self.startprob)
|
|
67
|
+
self.log_transmat = np.log(self.transmat)
|
|
68
|
+
self.log_emissionprob = np.log(self.emissionprob)
|
|
69
|
+
|
|
70
|
+
self.bin_minutes = int(data.get("bin_minutes", bin_minutes))
|
|
71
|
+
|
|
72
|
+
self.sleep_state = int(np.argmin(self.emissionprob[:, 1]))
|
|
73
|
+
self.awake_state = 1 - self.sleep_state
|
|
74
|
+
|
|
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
|
+
def _bin(self, timestamps):
|
|
85
|
+
start = timestamps[0].replace(hour=0, minute=0, second=0, microsecond=0)
|
|
86
|
+
end = timestamps[-1].replace(
|
|
87
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
88
|
+
) + timedelta(days=1)
|
|
89
|
+
|
|
90
|
+
bin_delta = timedelta(minutes=self.bin_minutes)
|
|
91
|
+
n_bins = int((end - start) / bin_delta)
|
|
92
|
+
|
|
93
|
+
bins = np.zeros(n_bins, dtype=np.uint8)
|
|
94
|
+
|
|
95
|
+
for t in timestamps:
|
|
96
|
+
idx = int((t - start) / bin_delta)
|
|
97
|
+
if 0 <= idx < n_bins:
|
|
98
|
+
bins[idx] = 1
|
|
99
|
+
|
|
100
|
+
return start, bins
|
|
101
|
+
|
|
102
|
+
def analyze(self, timestamps, tz_range=range(-12, 13)):
|
|
103
|
+
ts = self._parse_timestamps(timestamps)
|
|
104
|
+
|
|
105
|
+
span = ts[-1] - ts[0]
|
|
106
|
+
if span < timedelta(days=2): # arbitrary number that seems fine
|
|
107
|
+
raise ValueError("not enough time span to analyze (need at least ~2 days)")
|
|
108
|
+
|
|
109
|
+
start_time, bins = self._bin(ts)
|
|
110
|
+
|
|
111
|
+
bins_per_day = (24 * 60) // self.bin_minutes
|
|
112
|
+
|
|
113
|
+
if len(bins) < 2 * bins_per_day: # arbitrary number that seems fine
|
|
114
|
+
raise ValueError("not enough data after binning (need at least ~2 days)")
|
|
115
|
+
|
|
116
|
+
best_phi = 0
|
|
117
|
+
best_score = -np.inf
|
|
118
|
+
|
|
119
|
+
for phi in tz_range:
|
|
120
|
+
shift_bins = int(phi * bins_per_day / 24)
|
|
121
|
+
bins_phi = np.roll(bins, shift_bins)
|
|
122
|
+
|
|
123
|
+
score = _forward_log(
|
|
124
|
+
bins_phi,
|
|
125
|
+
self.log_transmat,
|
|
126
|
+
self.log_emissionprob,
|
|
127
|
+
self.log_startprob,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if score > best_score:
|
|
131
|
+
best_score = score
|
|
132
|
+
best_phi = phi
|
|
133
|
+
|
|
134
|
+
shift_bins = int(best_phi * bins_per_day / 24)
|
|
135
|
+
best_bins = np.roll(bins, shift_bins)
|
|
136
|
+
|
|
137
|
+
states, _ = _viterbi(
|
|
138
|
+
best_bins, self.log_transmat, self.log_emissionprob, self.log_startprob
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
sleep_blocks = []
|
|
142
|
+
awake_blocks = []
|
|
143
|
+
|
|
144
|
+
current_state = states[0]
|
|
145
|
+
block_start = 0
|
|
146
|
+
|
|
147
|
+
for i in range(1, len(states)):
|
|
148
|
+
if states[i] != current_state:
|
|
149
|
+
(
|
|
150
|
+
sleep_blocks if current_state == self.sleep_state else awake_blocks
|
|
151
|
+
).append((block_start, i))
|
|
152
|
+
|
|
153
|
+
block_start = i
|
|
154
|
+
current_state = states[i]
|
|
155
|
+
|
|
156
|
+
if current_state == self.sleep_state:
|
|
157
|
+
sleep_blocks.append((block_start, len(states)))
|
|
158
|
+
else:
|
|
159
|
+
awake_blocks.append((block_start, len(states)))
|
|
160
|
+
|
|
161
|
+
def blocks_to_time(blocks):
|
|
162
|
+
out = []
|
|
163
|
+
for a, b in blocks:
|
|
164
|
+
t0 = start_time + timedelta(minutes=self.bin_minutes * a)
|
|
165
|
+
t1 = start_time + timedelta(minutes=self.bin_minutes * b)
|
|
166
|
+
out.append({"start": t0.isoformat(), "end": t1.isoformat()})
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"timezone_offset_hours": int(best_phi),
|
|
171
|
+
"sleep_blocks": blocks_to_time(sleep_blocks),
|
|
172
|
+
"awake_blocks": blocks_to_time(awake_blocks),
|
|
173
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import csv
|
|
3
|
+
import math
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
from parcae import Parcae
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_csv(path):
|
|
11
|
+
timestamps = []
|
|
12
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
13
|
+
reader = csv.DictReader(f)
|
|
14
|
+
fieldnames = reader.fieldnames
|
|
15
|
+
if fieldnames is None or "timestamp" not in fieldnames:
|
|
16
|
+
raise ValueError("! CSV must have a 'timestamp' column")
|
|
17
|
+
|
|
18
|
+
for row in reader:
|
|
19
|
+
timestamps.append(row["timestamp"])
|
|
20
|
+
|
|
21
|
+
return timestamps
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def minutes_since_midnight(dt):
|
|
25
|
+
return dt.hour * 60 + dt.minute
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_hm(minutes):
|
|
29
|
+
h = (minutes // 60) % 24
|
|
30
|
+
m = minutes % 60
|
|
31
|
+
return f"{h:02d}:{m:02d}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def circular_mean_minutes(values):
|
|
35
|
+
angles = [2 * math.pi * v / 1440.0 for v in values]
|
|
36
|
+
|
|
37
|
+
x = sum(math.cos(a) for a in angles)
|
|
38
|
+
y = sum(math.sin(a) for a in angles)
|
|
39
|
+
|
|
40
|
+
if x == 0 and y == 0:
|
|
41
|
+
return int(values[0])
|
|
42
|
+
|
|
43
|
+
mean_angle = math.atan2(y, x)
|
|
44
|
+
if mean_angle < 0:
|
|
45
|
+
mean_angle += 2 * math.pi
|
|
46
|
+
|
|
47
|
+
mean_minutes = int(round(mean_angle * 1440.0 / (2 * math.pi)))
|
|
48
|
+
return mean_minutes % 1440
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main():
|
|
52
|
+
parser = argparse.ArgumentParser(prog="parcae")
|
|
53
|
+
parser.add_argument("csv", help="CSV file with a 'timestamp' column")
|
|
54
|
+
parser.add_argument("-v", "--version", action="version", version="%(prog)s 0.1.0")
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
print("+ Parcae analysis\n")
|
|
58
|
+
|
|
59
|
+
timestamps = parse_csv(args.csv)
|
|
60
|
+
|
|
61
|
+
p = Parcae()
|
|
62
|
+
result = p.analyze(timestamps)
|
|
63
|
+
|
|
64
|
+
tz = result["timezone_offset_hours"]
|
|
65
|
+
sleep_blocks = result["sleep_blocks"]
|
|
66
|
+
|
|
67
|
+
print(f"~ inferred timezone: UTC{tz:+d}\n")
|
|
68
|
+
|
|
69
|
+
offset = timedelta(hours=tz)
|
|
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)
|
|
78
|
+
|
|
79
|
+
for start, end in local_blocks:
|
|
80
|
+
day = start.date()
|
|
81
|
+
dur = (end - start).total_seconds()
|
|
82
|
+
by_day[day].append((dur, start, end))
|
|
83
|
+
|
|
84
|
+
main_sleeps = []
|
|
85
|
+
for day, blocks in by_day.items():
|
|
86
|
+
blocks.sort(reverse=True)
|
|
87
|
+
_, start, end = blocks[0]
|
|
88
|
+
main_sleeps.append((start, end))
|
|
89
|
+
|
|
90
|
+
if not main_sleeps:
|
|
91
|
+
print("! no sleep blocks detected")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
sleep_starts = [minutes_since_midnight(s) for s, e in main_sleeps]
|
|
95
|
+
sleep_ends = [minutes_since_midnight(e) for s, e in main_sleeps]
|
|
96
|
+
durations = [int((e - s).total_seconds() / 60) for s, e in main_sleeps]
|
|
97
|
+
|
|
98
|
+
mean_start = circular_mean_minutes(sleep_starts)
|
|
99
|
+
mean_end = circular_mean_minutes(sleep_ends)
|
|
100
|
+
durations.sort()
|
|
101
|
+
med_dur = durations[len(durations) // 2]
|
|
102
|
+
|
|
103
|
+
print("+ typical schedule:")
|
|
104
|
+
print(
|
|
105
|
+
f"\t- sleep: {format_hm(mean_start)} -> {format_hm(mean_end)} (≈ {med_dur // 60}h {med_dur % 60:02d}m)"
|
|
106
|
+
)
|
|
107
|
+
print(f"\t- awake: {format_hm(mean_end)} -> {format_hm(mean_start)}\n")
|
|
108
|
+
|
|
109
|
+
print(f"~ based on {len(main_sleeps)} days of data")
|
|
110
|
+
print(f"~ bin size: {p.bin_minutes} minutes")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parcae
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Infer daily rhythm and sleep schedule from message timestamps
|
|
5
|
+
Project-URL: Homepage, https://github.com/jeremyctrl/parcae
|
|
6
|
+
Project-URL: Repository, https://github.com/jeremyctrl/parcae
|
|
7
|
+
Project-URL: Issues, https://github.com/jeremyctrl/parcae/issues
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: numpy>=2.4.1
|
|
12
|
+
Provides-Extra: train
|
|
13
|
+
Requires-Dist: hmmlearn>=0.3.3; extra == "train"
|
|
14
|
+
Requires-Dist: matplotlib>=3.10.8; extra == "train"
|
|
15
|
+
Requires-Dist: pandas>=3.0.0; extra == "train"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
<div align="center">
|
|
19
|
+
|
|
20
|
+
# parcae
|
|
21
|
+
|
|
22
|
+
<div>
|
|
23
|
+
<a href="https://pypi.org/project/parcae/"><img src="https://img.shields.io/pypi/v/parcae.svg" alt="PyPI"></a>
|
|
24
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"></a>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<a href="https://heyjeremy.vercel.app/blog/when-do-you-sleep"><img src="https://img.shields.io/badge/blog-when%20do%20you%20sleep%3F%20learning%20a%20pattern%20of%20life%20from%20message%20timestamps-blue" alt="Blog"></a>
|
|
28
|
+
|
|
29
|
+
Infer daily rhythm and sleep schedule from message timestamps
|
|
30
|
+
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
`parcae` is a command-line tool and Python library that analyzes nothing but timestamps and infers a user's likely timezone offset and their typical sleep window.
|
|
34
|
+
|
|
35
|
+
## How It Works
|
|
36
|
+
|
|
37
|
+
`parcae` models human behavior as a very small Hidden Markov Model with two hidden states:
|
|
38
|
+
|
|
39
|
+
- Awake (A)
|
|
40
|
+
- Sleep (S)
|
|
41
|
+
|
|
42
|
+
The only observation is "was there at least one message in this time bin?". The model is trained globally across many users to learn:
|
|
43
|
+
|
|
44
|
+
- how likely people are to send messages while "awake"
|
|
45
|
+
- how unlikely they are to send messages while "asleep"
|
|
46
|
+
- how often they switch between the two states
|
|
47
|
+
|
|
48
|
+
At inference time, Parcae:
|
|
49
|
+
|
|
50
|
+
1. Tries many possible timezone offsets
|
|
51
|
+
2. Picks the offset that makes the timeline most explainable by a "human with one long sleep per day"
|
|
52
|
+
3. Decodes the most likely sleep/awake sequence
|
|
53
|
+
4. Extracts daily sleep blocks
|
|
54
|
+
5. Computes a typical schedule and regularity statistics
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
You can install `parcae` using [pipx](https://pypa.github.io/pipx):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pipx install parcae
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
### API
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from parcae import Parcae
|
|
70
|
+
|
|
71
|
+
p = Parcae()
|
|
72
|
+
|
|
73
|
+
timestamps = [
|
|
74
|
+
"2025-09-01T05:43:12+00:00",
|
|
75
|
+
"2025-09-01T18:22:10+00:00",
|
|
76
|
+
...
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
print(p.analyze(timestamps))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### CLI
|
|
83
|
+
|
|
84
|
+
Parcae expects a CSV file with one user's timestamps:
|
|
85
|
+
|
|
86
|
+
```csv
|
|
87
|
+
timestamp
|
|
88
|
+
2025-09-01T05:43:12+00:00
|
|
89
|
+
2025-09-01T07:58:33+00:00
|
|
90
|
+
2025-09-01T18:22:10+00:00
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
parcae user_timestamps.csv
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Examples
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
+ Parcae analysis
|
|
101
|
+
|
|
102
|
+
~ inferred timezone: UTC+3
|
|
103
|
+
|
|
104
|
+
+ typical schedule:
|
|
105
|
+
- sleep: 02:46 -> 11:38 (≈ 8h 45m)
|
|
106
|
+
- awake: 11:38 -> 02:46
|
|
107
|
+
|
|
108
|
+
~ based on 30 days of data
|
|
109
|
+
~ bin size: 15 minutes
|
|
110
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
models/hmm.npz
|
|
5
|
+
parcae/__init__.py
|
|
6
|
+
parcae/api.py
|
|
7
|
+
parcae/cli.py
|
|
8
|
+
parcae.egg-info/PKG-INFO
|
|
9
|
+
parcae.egg-info/SOURCES.txt
|
|
10
|
+
parcae.egg-info/dependency_links.txt
|
|
11
|
+
parcae.egg-info/entry_points.txt
|
|
12
|
+
parcae.egg-info/requires.txt
|
|
13
|
+
parcae.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parcae
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "parcae"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Infer daily rhythm and sleep schedule from message timestamps"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"numpy>=2.4.1",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/jeremyctrl/parcae"
|
|
13
|
+
Repository = "https://github.com/jeremyctrl/parcae"
|
|
14
|
+
Issues = "https://github.com/jeremyctrl/parcae/issues"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
train = [
|
|
18
|
+
"hmmlearn>=0.3.3",
|
|
19
|
+
"matplotlib>=3.10.8",
|
|
20
|
+
"pandas>=3.0.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
parcae = "parcae.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
packages = ["parcae"]
|
|
28
|
+
include-package-data = true
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.data-files]
|
|
31
|
+
models = ["models/hmm.npz"]
|
parcae-0.1.0/setup.cfg
ADDED