commute-optimizer 1.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.
- commute/__init__.py +0 -0
- commute/__main__.py +76 -0
- commute/check.py +84 -0
- commute/config.example.json +12 -0
- commute/config.py +76 -0
- commute/db.py +65 -0
- commute/demo.py +79 -0
- commute/poller.py +199 -0
- commute/report.py +302 -0
- commute/schedule.py +302 -0
- commute/watch.py +111 -0
- commute_optimizer-1.1.0.dist-info/METADATA +246 -0
- commute_optimizer-1.1.0.dist-info/RECORD +17 -0
- commute_optimizer-1.1.0.dist-info/WHEEL +5 -0
- commute_optimizer-1.1.0.dist-info/entry_points.txt +2 -0
- commute_optimizer-1.1.0.dist-info/licenses/LICENSE +21 -0
- commute_optimizer-1.1.0.dist-info/top_level.txt +1 -0
commute/__init__.py
ADDED
|
File without changes
|
commute/__main__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CLI entry point: python -m commute {poll|report}"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _version():
|
|
7
|
+
try:
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
return version("commute-optimizer")
|
|
10
|
+
except Exception: # running from a source checkout without install
|
|
11
|
+
return "dev"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
p = argparse.ArgumentParser(prog="commute",
|
|
16
|
+
description="Track commute times and find the best departure time.")
|
|
17
|
+
p.add_argument("-c", "--config", help="Path to config.json (default: ./config.json)")
|
|
18
|
+
p.add_argument("--version", action="version", version=f"commute {_version()}")
|
|
19
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
20
|
+
sub.add_parser("init", help="Write an example config.json to the current directory")
|
|
21
|
+
pp = sub.add_parser("poll", help="Start the adaptive polling loop (Ctrl+C to stop)")
|
|
22
|
+
pp.add_argument("-d", "--duration",
|
|
23
|
+
help="Auto-stop after this long and generate the report "
|
|
24
|
+
"(e.g. 7d, 24h, 90m, 1d12h)")
|
|
25
|
+
pp.add_argument("--no-report", action="store_true",
|
|
26
|
+
help="Don't auto-generate the report when the duration ends")
|
|
27
|
+
pp.add_argument("--until",
|
|
28
|
+
help="Auto-stop at this ISO timestamp (what 'schedule' uses "
|
|
29
|
+
"so the deadline survives reboots)")
|
|
30
|
+
rp = sub.add_parser("report", help="Analyze samples and generate report.html")
|
|
31
|
+
rp.add_argument("--no-open", action="store_true", help="Don't open the report in a browser")
|
|
32
|
+
wp = sub.add_parser("watch",
|
|
33
|
+
help="Watch an in-progress run: regenerate the report and a "
|
|
34
|
+
"day-by-day report on an interval")
|
|
35
|
+
wp.add_argument("-i", "--interval", default="15m",
|
|
36
|
+
help="How often to regenerate (default 15m)")
|
|
37
|
+
wp.add_argument("--no-open", action="store_true",
|
|
38
|
+
help="Don't open report-daily.html in a browser")
|
|
39
|
+
sp = sub.add_parser("schedule",
|
|
40
|
+
help="Keep polling alive across sleep and reboots via a "
|
|
41
|
+
"scheduled task (Windows)")
|
|
42
|
+
sp.add_argument("action", choices=["install", "remove", "status"])
|
|
43
|
+
sp.add_argument("-d", "--duration",
|
|
44
|
+
help="With install: stop polling this long from now and "
|
|
45
|
+
"generate the report (e.g. 7d)")
|
|
46
|
+
sub.add_parser("check", help="Preflight: verify config, API key, DB, and live API access")
|
|
47
|
+
sub.add_parser("demo", help="Seed the DB with a synthetic week of two-direction data")
|
|
48
|
+
args = p.parse_args()
|
|
49
|
+
|
|
50
|
+
if args.cmd == "init":
|
|
51
|
+
from .config import init_config
|
|
52
|
+
init_config(args.config)
|
|
53
|
+
elif args.cmd == "poll":
|
|
54
|
+
from . import poller
|
|
55
|
+
poller.run(args.config, duration=args.duration,
|
|
56
|
+
report_on_end=not args.no_report, until=args.until)
|
|
57
|
+
elif args.cmd == "schedule":
|
|
58
|
+
from . import schedule
|
|
59
|
+
schedule.run(args.action, args.config, args.duration)
|
|
60
|
+
elif args.cmd == "watch":
|
|
61
|
+
from . import watch
|
|
62
|
+
watch.run(args.config, interval=args.interval,
|
|
63
|
+
open_browser=not args.no_open)
|
|
64
|
+
elif args.cmd == "check":
|
|
65
|
+
from . import check
|
|
66
|
+
raise SystemExit(check.run(args.config))
|
|
67
|
+
elif args.cmd == "demo":
|
|
68
|
+
from . import demo
|
|
69
|
+
demo.run(args.config)
|
|
70
|
+
else:
|
|
71
|
+
from . import report
|
|
72
|
+
report.run(args.config, open_browser=not args.no_open)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
commute/check.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Preflight check: validate config, API key, database, and make one real
|
|
2
|
+
Routes API call per direction before committing to a week-long run."""
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from . import db
|
|
7
|
+
from .config import load_api_key, load_config
|
|
8
|
+
from .poller import current_interval, fetch_route
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _estimate_calls_per_day(cfg):
|
|
12
|
+
"""Walk a synthetic day in 1-minute steps and count when a poll would fire."""
|
|
13
|
+
calls = 0
|
|
14
|
+
t = datetime(2026, 1, 5, 0, 0) # any Monday; only time-of-day matters
|
|
15
|
+
next_poll = t
|
|
16
|
+
while t.date() == datetime(2026, 1, 5).date():
|
|
17
|
+
interval = current_interval(cfg, t)
|
|
18
|
+
if interval is not None and t >= next_poll:
|
|
19
|
+
calls += 1
|
|
20
|
+
next_poll = t + timedelta(seconds=interval)
|
|
21
|
+
t += timedelta(minutes=1)
|
|
22
|
+
return calls * (2 if cfg.get("track_both_directions", True) else 1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(config_path=None):
|
|
26
|
+
ok = True
|
|
27
|
+
|
|
28
|
+
# 1. config
|
|
29
|
+
try:
|
|
30
|
+
cfg = load_config(config_path)
|
|
31
|
+
print(f"[ok] config: {cfg['origin']!r} -> {cfg['destination']!r}")
|
|
32
|
+
except SystemExit as e:
|
|
33
|
+
print(f"[FAIL] config: {e}")
|
|
34
|
+
return 1
|
|
35
|
+
|
|
36
|
+
# 2. API key present
|
|
37
|
+
try:
|
|
38
|
+
api_key = load_api_key()
|
|
39
|
+
print(f"[ok] API key found ({api_key[:6]}...{api_key[-4:]})")
|
|
40
|
+
except SystemExit as e:
|
|
41
|
+
print(f"[FAIL] API key: {e}")
|
|
42
|
+
return 1
|
|
43
|
+
|
|
44
|
+
# 3. database writable
|
|
45
|
+
try:
|
|
46
|
+
conn = db.connect()
|
|
47
|
+
conn.execute("SELECT 1")
|
|
48
|
+
n = db.count_samples(conn)
|
|
49
|
+
conn.close()
|
|
50
|
+
print(f"[ok] database writable at {db.DB_PATH} ({n} existing samples)")
|
|
51
|
+
if n:
|
|
52
|
+
print(" note: existing samples will mix into the report — "
|
|
53
|
+
"delete commute.db for a clean run.")
|
|
54
|
+
except Exception as e:
|
|
55
|
+
print(f"[FAIL] database: {e!r}")
|
|
56
|
+
ok = False
|
|
57
|
+
|
|
58
|
+
# 4. live API call per direction
|
|
59
|
+
directions = [("ab", cfg["origin"], cfg["destination"])]
|
|
60
|
+
if cfg.get("track_both_directions", True):
|
|
61
|
+
directions.append(("ba", cfg["destination"], cfg["origin"]))
|
|
62
|
+
for name, origin, destination in directions:
|
|
63
|
+
arrow = "->" if name == "ab" else "<-"
|
|
64
|
+
try:
|
|
65
|
+
r = fetch_route(api_key, origin, destination,
|
|
66
|
+
cfg.get("travel_mode", "DRIVE"))
|
|
67
|
+
print(f"[ok] live route {arrow} {r['duration_s'] / 60:.1f} min now "
|
|
68
|
+
f"(free-flow {r['static_duration_s'] / 60:.1f} min, "
|
|
69
|
+
f"{r['distance_m'] / 1000:.1f} km)")
|
|
70
|
+
except Exception as e:
|
|
71
|
+
msg = getattr(e, "read", None)
|
|
72
|
+
detail = msg().decode(errors="replace")[:300] if msg else repr(e)
|
|
73
|
+
print(f"[FAIL] live route {arrow}: {detail}")
|
|
74
|
+
ok = False
|
|
75
|
+
|
|
76
|
+
# 5. quota estimate
|
|
77
|
+
per_day = _estimate_calls_per_day(cfg)
|
|
78
|
+
per_week = per_day * 7
|
|
79
|
+
print(f"[{'ok' if per_week < 9500 else 'WARN'}] estimated API usage: "
|
|
80
|
+
f"~{per_day}/day, ~{per_week}/week (free tier ~10,000/month)")
|
|
81
|
+
|
|
82
|
+
print("\nAll checks passed — ready for a long run."
|
|
83
|
+
if ok else "\nFix the failures above before starting a long run.")
|
|
84
|
+
return 0 if ok else 1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"origin": "1600 Amphitheatre Parkway, Mountain View, CA",
|
|
3
|
+
"destination": "1 Market St, San Francisco, CA",
|
|
4
|
+
"travel_mode": "DRIVE",
|
|
5
|
+
"track_both_directions": true,
|
|
6
|
+
"poll": {
|
|
7
|
+
"active_window": ["00:00", "23:59"],
|
|
8
|
+
"rush_windows": [["07:00", "10:00"], ["16:00", "19:00"]],
|
|
9
|
+
"rush_interval_min": 1,
|
|
10
|
+
"offpeak_interval_min": 10
|
|
11
|
+
}
|
|
12
|
+
}
|
commute/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Load config.json and the API key from .env / environment."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# All data files (config.json, .env, commute.db, reports, poll.log/lock) live
|
|
9
|
+
# in the directory you run the command from, so a pip-installed `commute` can
|
|
10
|
+
# keep several tracking projects in separate folders.
|
|
11
|
+
ROOT = Path.cwd()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_hhmm(s):
|
|
15
|
+
h, m = s.split(":")
|
|
16
|
+
return time(int(h), int(m))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(path=None):
|
|
20
|
+
path = Path(path) if path else ROOT / "config.json"
|
|
21
|
+
if not path.exists():
|
|
22
|
+
raise SystemExit(
|
|
23
|
+
f"Config not found at {path}. Copy config.example.json to config.json and edit it."
|
|
24
|
+
)
|
|
25
|
+
with open(path, encoding="utf-8") as f:
|
|
26
|
+
cfg = json.load(f)
|
|
27
|
+
|
|
28
|
+
poll = cfg.get("poll", {})
|
|
29
|
+
cfg["_active_window"] = tuple(
|
|
30
|
+
_parse_hhmm(s) for s in poll.get("active_window", ["00:00", "23:59"])
|
|
31
|
+
)
|
|
32
|
+
cfg["_rush_windows"] = [
|
|
33
|
+
tuple(_parse_hhmm(s) for s in w) for w in poll.get("rush_windows", [])
|
|
34
|
+
]
|
|
35
|
+
cfg["_rush_interval"] = int(poll.get("rush_interval_min", 1)) * 60
|
|
36
|
+
cfg["_offpeak_interval"] = int(poll.get("offpeak_interval_min", 10)) * 60
|
|
37
|
+
return cfg
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def init_config(dest=None):
|
|
41
|
+
"""Write the packaged example config to ./config.json (for `commute init`)."""
|
|
42
|
+
from importlib.resources import files
|
|
43
|
+
dest = Path(dest) if dest else ROOT / "config.json"
|
|
44
|
+
if dest.exists():
|
|
45
|
+
raise SystemExit(f"{dest} already exists — edit it instead.")
|
|
46
|
+
template = files("commute").joinpath("config.example.json").read_text(
|
|
47
|
+
encoding="utf-8")
|
|
48
|
+
dest.write_text(template, encoding="utf-8")
|
|
49
|
+
print(f"Wrote {dest}\nEdit the addresses, put GOOGLE_MAPS_API_KEY in a .env "
|
|
50
|
+
"file next to it, then run: commute check")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_api_key():
|
|
54
|
+
key = os.environ.get("GOOGLE_MAPS_API_KEY")
|
|
55
|
+
if not key:
|
|
56
|
+
env_file = ROOT / ".env"
|
|
57
|
+
if env_file.exists():
|
|
58
|
+
for line in env_file.read_text(encoding="utf-8-sig").splitlines():
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if line.startswith("GOOGLE_MAPS_API_KEY="):
|
|
61
|
+
key = line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
62
|
+
break
|
|
63
|
+
if not key:
|
|
64
|
+
raise SystemExit(
|
|
65
|
+
"No API key. Set GOOGLE_MAPS_API_KEY in the environment or in a .env file "
|
|
66
|
+
"(GOOGLE_MAPS_API_KEY=your-key)."
|
|
67
|
+
)
|
|
68
|
+
return key
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def in_window(t, window):
|
|
72
|
+
"""True if time t falls inside (start, end), handling overnight windows."""
|
|
73
|
+
start, end = window
|
|
74
|
+
if start <= end:
|
|
75
|
+
return start <= t <= end
|
|
76
|
+
return t >= start or t <= end
|
commute/db.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SQLite storage for commute samples."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
from .config import ROOT
|
|
6
|
+
|
|
7
|
+
DB_PATH = ROOT / "commute.db"
|
|
8
|
+
|
|
9
|
+
SCHEMA = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS samples (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
ts_utc TEXT NOT NULL,
|
|
13
|
+
ts_local TEXT NOT NULL,
|
|
14
|
+
direction TEXT NOT NULL DEFAULT 'ab', -- 'ab' = origin->destination, 'ba' = reverse
|
|
15
|
+
weekday INTEGER NOT NULL, -- 0 = Monday
|
|
16
|
+
minute_of_day INTEGER NOT NULL, -- local time, 0..1439
|
|
17
|
+
duration_s INTEGER, -- traffic-aware travel time
|
|
18
|
+
static_duration_s INTEGER, -- free-flow travel time
|
|
19
|
+
distance_m INTEGER,
|
|
20
|
+
error TEXT
|
|
21
|
+
);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_samples_dir_wd_min
|
|
23
|
+
ON samples(direction, weekday, minute_of_day);
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def connect(path=None):
|
|
28
|
+
# timeout so readers (report/watch) wait out the poller's brief write locks
|
|
29
|
+
conn = sqlite3.connect(path or DB_PATH, timeout=15)
|
|
30
|
+
conn.executescript(SCHEMA)
|
|
31
|
+
return conn
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def insert_sample(conn, row, commit=True):
|
|
35
|
+
conn.execute(
|
|
36
|
+
"""INSERT INTO samples
|
|
37
|
+
(ts_utc, ts_local, direction, weekday, minute_of_day, duration_s,
|
|
38
|
+
static_duration_s, distance_m, error)
|
|
39
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
40
|
+
(
|
|
41
|
+
row["ts_utc"], row["ts_local"], row.get("direction", "ab"),
|
|
42
|
+
row["weekday"], row["minute_of_day"],
|
|
43
|
+
row.get("duration_s"), row.get("static_duration_s"),
|
|
44
|
+
row.get("distance_m"), row.get("error"),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
if commit:
|
|
48
|
+
conn.commit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_samples(conn, direction):
|
|
52
|
+
cur = conn.execute(
|
|
53
|
+
"""SELECT ts_local, weekday, minute_of_day, duration_s
|
|
54
|
+
FROM samples WHERE duration_s IS NOT NULL AND direction = ?
|
|
55
|
+
ORDER BY ts_utc""",
|
|
56
|
+
(direction,),
|
|
57
|
+
)
|
|
58
|
+
return [
|
|
59
|
+
{"ts": r[0], "weekday": r[1], "minute": r[2], "duration_s": r[3]}
|
|
60
|
+
for r in cur.fetchall()
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count_samples(conn):
|
|
65
|
+
return conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
commute/demo.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Seed the database with a synthetic week of two-direction commute data,
|
|
2
|
+
so the report can be previewed without polling for a week."""
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
import random
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from . import db
|
|
9
|
+
from .config import load_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _bump(minute, center, width, amp):
|
|
13
|
+
"""Gaussian traffic bump in minutes of extra delay."""
|
|
14
|
+
return amp * math.exp(-(((minute - center) / width) ** 2))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def synth_duration(direction, weekday, minute, rng):
|
|
18
|
+
"""Plausible drive time (seconds) for a suburb<->city commute."""
|
|
19
|
+
weekend = weekday >= 5
|
|
20
|
+
if direction == "ab": # home -> work: morning peak
|
|
21
|
+
base = 28.0
|
|
22
|
+
if not weekend:
|
|
23
|
+
delay = _bump(minute, 8 * 60 + 15, 55, 24) # AM peak ~8:15
|
|
24
|
+
delay += _bump(minute, 17 * 60 + 30, 70, 7) # light PM echo
|
|
25
|
+
if weekday == 4: # Friday AM is lighter
|
|
26
|
+
delay *= 0.8
|
|
27
|
+
else:
|
|
28
|
+
delay = _bump(minute, 12 * 60, 150, 6) # midday weekend bump
|
|
29
|
+
else: # work -> home: evening peak
|
|
30
|
+
base = 29.0
|
|
31
|
+
if not weekend:
|
|
32
|
+
delay = _bump(minute, 17 * 60 + 30, 60, 26) # PM peak ~17:30
|
|
33
|
+
delay += _bump(minute, 8 * 60 + 15, 70, 6)
|
|
34
|
+
if weekday == 4: # Friday PM starts earlier
|
|
35
|
+
delay = _bump(minute, 16 * 60 + 30, 75, 28)
|
|
36
|
+
else:
|
|
37
|
+
delay = _bump(minute, 12 * 60, 150, 6)
|
|
38
|
+
total = base + delay + rng.gauss(0, 1.5)
|
|
39
|
+
if rng.random() < 0.01: # occasional incident
|
|
40
|
+
total += rng.uniform(8, 25)
|
|
41
|
+
return int(max(base * 0.9, total) * 60)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(config_path=None):
|
|
45
|
+
cfg = load_config(config_path)
|
|
46
|
+
conn = db.connect()
|
|
47
|
+
if db.count_samples(conn) > 0:
|
|
48
|
+
raise SystemExit(
|
|
49
|
+
"commute.db already contains samples — refusing to mix demo data with "
|
|
50
|
+
"real data. Delete commute.db first if you want a fresh demo."
|
|
51
|
+
)
|
|
52
|
+
rng = random.Random(42)
|
|
53
|
+
start = datetime(2026, 6, 29, 0, 0) # a Monday
|
|
54
|
+
rush = cfg["_rush_windows"]
|
|
55
|
+
n = 0
|
|
56
|
+
for day in range(7):
|
|
57
|
+
t = start + timedelta(days=day)
|
|
58
|
+
end_of_day = t + timedelta(days=1)
|
|
59
|
+
while t < end_of_day:
|
|
60
|
+
minute = t.hour * 60 + t.minute
|
|
61
|
+
in_rush = any(w[0] <= t.time() <= w[1] for w in rush)
|
|
62
|
+
for direction in ("ab", "ba"):
|
|
63
|
+
dur = synth_duration(direction, t.weekday(), minute, rng)
|
|
64
|
+
db.insert_sample(conn, {
|
|
65
|
+
"ts_utc": t.isoformat(timespec="seconds"),
|
|
66
|
+
"ts_local": t.isoformat(timespec="seconds"),
|
|
67
|
+
"direction": direction,
|
|
68
|
+
"weekday": t.weekday(),
|
|
69
|
+
"minute_of_day": minute,
|
|
70
|
+
"duration_s": dur,
|
|
71
|
+
"static_duration_s": int((28 if direction == "ab" else 29) * 60),
|
|
72
|
+
"distance_m": 31000,
|
|
73
|
+
}, commit=False)
|
|
74
|
+
n += 1
|
|
75
|
+
t += timedelta(minutes=1 if in_rush else 10)
|
|
76
|
+
conn.commit()
|
|
77
|
+
conn.close()
|
|
78
|
+
print(f"Seeded {n} demo samples (one synthetic week, both directions).")
|
|
79
|
+
print("Now run: commute report")
|
commute/poller.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Poll the Google Routes API on an adaptive schedule and store samples."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time as time_mod
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
from . import db
|
|
12
|
+
from .config import ROOT, in_window, load_api_key, load_config
|
|
13
|
+
|
|
14
|
+
ROUTES_URL = "https://routes.googleapis.com/directions/v2:computeRoutes"
|
|
15
|
+
FIELD_MASK = "routes.duration,routes.staticDuration,routes.distanceMeters"
|
|
16
|
+
RETRY_DELAYS = (5, 15, 45) # seconds between attempts on transient failures
|
|
17
|
+
LOCK_PATH = ROOT / "poll.lock"
|
|
18
|
+
LOG_PATH = ROOT / "poll.log"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fetch_route(api_key, origin, destination, travel_mode="DRIVE"):
|
|
22
|
+
"""One traffic-aware route request. Returns dict or raises."""
|
|
23
|
+
body = {
|
|
24
|
+
"origin": {"address": origin},
|
|
25
|
+
"destination": {"address": destination},
|
|
26
|
+
"travelMode": travel_mode,
|
|
27
|
+
"routingPreference": "TRAFFIC_AWARE",
|
|
28
|
+
}
|
|
29
|
+
req = urllib.request.Request(
|
|
30
|
+
ROUTES_URL,
|
|
31
|
+
data=json.dumps(body).encode(),
|
|
32
|
+
headers={
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
"X-Goog-Api-Key": api_key,
|
|
35
|
+
"X-Goog-FieldMask": FIELD_MASK,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
39
|
+
data = json.load(resp)
|
|
40
|
+
route = data["routes"][0]
|
|
41
|
+
return {
|
|
42
|
+
"duration_s": int(route["duration"].rstrip("s").split(".")[0]),
|
|
43
|
+
"static_duration_s": int(route["staticDuration"].rstrip("s").split(".")[0]),
|
|
44
|
+
"distance_m": route.get("distanceMeters"),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_transient(e):
|
|
49
|
+
"""Worth retrying? Rate limits and server/network errors, yes; a bad key
|
|
50
|
+
or malformed request (4xx) will fail the same way every time, no."""
|
|
51
|
+
if isinstance(e, urllib.error.HTTPError):
|
|
52
|
+
return e.code == 429 or e.code >= 500
|
|
53
|
+
return isinstance(e, (urllib.error.URLError, TimeoutError, OSError,
|
|
54
|
+
json.JSONDecodeError, KeyError))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fetch_route_retrying(api_key, origin, destination, travel_mode="DRIVE"):
|
|
58
|
+
"""fetch_route with backoff on transient failures. Raises the last error."""
|
|
59
|
+
for i, delay in enumerate((*RETRY_DELAYS, None)):
|
|
60
|
+
try:
|
|
61
|
+
return fetch_route(api_key, origin, destination, travel_mode)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
if delay is None or not _is_transient(e):
|
|
64
|
+
raise
|
|
65
|
+
print(f" transient error ({type(e).__name__}), "
|
|
66
|
+
f"retry {i + 1}/{len(RETRY_DELAYS)} in {delay}s")
|
|
67
|
+
time_mod.sleep(delay)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def current_interval(cfg, now):
|
|
71
|
+
t = now.time()
|
|
72
|
+
if not in_window(t, cfg["_active_window"]):
|
|
73
|
+
return None
|
|
74
|
+
for w in cfg["_rush_windows"]:
|
|
75
|
+
if in_window(t, w):
|
|
76
|
+
return cfg["_rush_interval"]
|
|
77
|
+
return cfg["_offpeak_interval"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def take_sample(conn, cfg, api_key):
|
|
81
|
+
directions = [("ab", cfg["origin"], cfg["destination"])]
|
|
82
|
+
if cfg.get("track_both_directions", True):
|
|
83
|
+
directions.append(("ba", cfg["destination"], cfg["origin"]))
|
|
84
|
+
for direction, origin, destination in directions:
|
|
85
|
+
now_utc = datetime.now(timezone.utc)
|
|
86
|
+
now = now_utc.astimezone()
|
|
87
|
+
row = {
|
|
88
|
+
"ts_utc": now_utc.isoformat(timespec="seconds"),
|
|
89
|
+
"ts_local": now.isoformat(timespec="seconds"),
|
|
90
|
+
"direction": direction,
|
|
91
|
+
"weekday": now.weekday(),
|
|
92
|
+
"minute_of_day": now.hour * 60 + now.minute,
|
|
93
|
+
}
|
|
94
|
+
arrow = "->" if direction == "ab" else "<-"
|
|
95
|
+
try:
|
|
96
|
+
row.update(fetch_route_retrying(api_key, origin, destination,
|
|
97
|
+
cfg.get("travel_mode", "DRIVE")))
|
|
98
|
+
mins = row["duration_s"] / 60
|
|
99
|
+
print(f"[{now:%a %H:%M:%S}] {arrow} {mins:.1f} min "
|
|
100
|
+
f"(free-flow {row['static_duration_s'] / 60:.1f} min)")
|
|
101
|
+
except urllib.error.HTTPError as e:
|
|
102
|
+
row["error"] = f"HTTP {e.code}: {e.read().decode(errors='replace')[:500]}"
|
|
103
|
+
print(f"[{now:%a %H:%M:%S}] {arrow} ERROR {row['error']}")
|
|
104
|
+
except Exception as e: # network blips shouldn't kill a week-long run
|
|
105
|
+
row["error"] = repr(e)
|
|
106
|
+
print(f"[{now:%a %H:%M:%S}] {arrow} ERROR {row['error']}")
|
|
107
|
+
db.insert_sample(conn, row)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_duration(s):
|
|
111
|
+
"""'7d', '24h', '90m', or combinations like '1d12h' -> seconds."""
|
|
112
|
+
import re
|
|
113
|
+
parts = re.findall(r"(\d+(?:\.\d+)?)([dhm])", s.lower())
|
|
114
|
+
if not parts or "".join(f"{n}{u}" for n, u in parts) != s.lower():
|
|
115
|
+
raise SystemExit(f"Can't parse duration {s!r} — use e.g. 7d, 24h, 90m, 1d12h")
|
|
116
|
+
mult = {"d": 86400, "h": 3600, "m": 60}
|
|
117
|
+
return sum(float(n) * mult[u] for n, u in parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def acquire_lock():
|
|
121
|
+
"""Exclusive poll.lock so a second poller (e.g. a scheduled-task tick while
|
|
122
|
+
one is already running) exits instead of double-sampling. Returns the open
|
|
123
|
+
file to hold for the process lifetime, or None if another poller has it."""
|
|
124
|
+
f = open(LOCK_PATH, "a+", encoding="utf-8")
|
|
125
|
+
try:
|
|
126
|
+
if os.name == "nt":
|
|
127
|
+
import msvcrt
|
|
128
|
+
f.seek(0)
|
|
129
|
+
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
|
|
130
|
+
else:
|
|
131
|
+
import fcntl
|
|
132
|
+
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
133
|
+
except OSError:
|
|
134
|
+
f.close()
|
|
135
|
+
return None
|
|
136
|
+
f.seek(0)
|
|
137
|
+
f.truncate()
|
|
138
|
+
f.write(str(os.getpid()))
|
|
139
|
+
f.flush()
|
|
140
|
+
return f
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run(config_path=None, duration=None, report_on_end=True, until=None):
|
|
144
|
+
if sys.stdout is None: # pythonw.exe (scheduled task) — log to a file
|
|
145
|
+
sys.stdout = sys.stderr = open(LOG_PATH, "a", buffering=1,
|
|
146
|
+
encoding="utf-8")
|
|
147
|
+
print(f"\n--- poller started {datetime.now().astimezone():%Y-%m-%d %H:%M:%S} ---")
|
|
148
|
+
lock = acquire_lock() # noqa: F841 — held (not used) until process exit
|
|
149
|
+
if lock is None:
|
|
150
|
+
print("Another poll is already running — exiting.")
|
|
151
|
+
return
|
|
152
|
+
cfg = load_config(config_path)
|
|
153
|
+
api_key = load_api_key()
|
|
154
|
+
conn = db.connect()
|
|
155
|
+
both = cfg.get("track_both_directions", True)
|
|
156
|
+
print(f"Polling {cfg['origin']!r} {'<->' if both else '->'} {cfg['destination']!r}")
|
|
157
|
+
print(f"Rush interval {cfg['_rush_interval'] // 60} min, "
|
|
158
|
+
f"off-peak {cfg['_offpeak_interval'] // 60} min. Ctrl+C to stop.")
|
|
159
|
+
deadline = None
|
|
160
|
+
if until is not None:
|
|
161
|
+
deadline = datetime.fromisoformat(until)
|
|
162
|
+
if datetime.now().astimezone() >= deadline:
|
|
163
|
+
# a scheduled task can keep ticking past its deadline; exit without
|
|
164
|
+
# re-generating (and re-opening) the report every time
|
|
165
|
+
print(f"Deadline {deadline:%Y-%m-%d %H:%M} already passed — exiting.")
|
|
166
|
+
return
|
|
167
|
+
print(f"Will stop automatically at {deadline:%Y-%m-%d %H:%M} "
|
|
168
|
+
f"and generate the report.")
|
|
169
|
+
elif duration is not None:
|
|
170
|
+
seconds = parse_duration(duration)
|
|
171
|
+
deadline = datetime.now().astimezone() + timedelta(seconds=seconds)
|
|
172
|
+
print(f"Will stop automatically at {deadline:%Y-%m-%d %H:%M} "
|
|
173
|
+
f"and generate the report.")
|
|
174
|
+
finished = False
|
|
175
|
+
try:
|
|
176
|
+
while True:
|
|
177
|
+
now = datetime.now().astimezone()
|
|
178
|
+
if deadline and now >= deadline:
|
|
179
|
+
finished = True
|
|
180
|
+
print(f"\nReached the scheduled stop time ({deadline:%Y-%m-%d %H:%M}).")
|
|
181
|
+
break
|
|
182
|
+
interval = current_interval(cfg, now)
|
|
183
|
+
if interval is None:
|
|
184
|
+
sleep_s = 60
|
|
185
|
+
else:
|
|
186
|
+
take_sample(conn, cfg, api_key)
|
|
187
|
+
elapsed = (datetime.now().astimezone() - now).total_seconds()
|
|
188
|
+
sleep_s = max(1, interval - elapsed)
|
|
189
|
+
if deadline: # never sleep past the deadline
|
|
190
|
+
sleep_s = min(sleep_s, max(
|
|
191
|
+
1, (deadline - datetime.now().astimezone()).total_seconds()))
|
|
192
|
+
time_mod.sleep(sleep_s)
|
|
193
|
+
except KeyboardInterrupt:
|
|
194
|
+
print("\nStopped.")
|
|
195
|
+
finally:
|
|
196
|
+
conn.close()
|
|
197
|
+
if finished and report_on_end:
|
|
198
|
+
from . import report
|
|
199
|
+
report.run(config_path)
|