parcae 0.1.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 parcae might be problematic. Click here for more details.

parcae/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .api import Parcae
4
+
5
+ __all__ = ["Parcae"]
parcae/api.py ADDED
@@ -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
+ }
parcae/cli.py ADDED
@@ -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()
Binary file
@@ -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,10 @@
1
+ parcae/__init__.py,sha256=xcLcFjuCpmp-5ImKBc6MPFEbbq_2bfG4-kE9YNOhgNM,69
2
+ parcae/api.py,sha256=O-JZw_HvZzC2yXSZGDgLMntwqnxaJs8edxEuTpAWCIw,5132
3
+ parcae/cli.py,sha256=9wkIU42fx2GVDYETyxv0hXkDs-Xn5j8OqKm5mbdThTg,3115
4
+ parcae-0.1.0.data/data/models/hmm.npz,sha256=MMnWWesB0-VzqLDJEGllFIZeHb5GVtUF5DDsXLOl61U,1118
5
+ parcae-0.1.0.dist-info/licenses/LICENSE,sha256=lkt0mQbom19fj92XgwyoYS3T2ES7S6F4aqV2-53AksA,1075
6
+ parcae-0.1.0.dist-info/METADATA,sha256=S2s1arkvKmJfqobtCjUbYjNT4x9PFgU_Gd9MQOoaek0,2839
7
+ parcae-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
8
+ parcae-0.1.0.dist-info/entry_points.txt,sha256=okhIV74Y1DqE8UGhVycpEU2NTwq3ReBkaoxfm7BBsrI,43
9
+ parcae-0.1.0.dist-info/top_level.txt,sha256=S_O9fdoifLS3yrrHeidB6sQ61r8l6KBvSHZ0arhaGoE,7
10
+ parcae-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ parcae = parcae.cli:main
@@ -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.
@@ -0,0 +1 @@
1
+ parcae