From 59ad9d8736f46c26ecede22b2814d20c2dc92253 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:10:00 -0400 Subject: [PATCH 01/15] test(cli): widen pending-ping window so snooze tests survive morning runs --- tests/test_cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 97089fb..65eae68 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -816,7 +816,7 @@ def test_snooze_shifts_prep_ping_forward( result = runner.invoke(cli, ["snooze", "evt-abc", "--minutes", "20"]) assert result.exit_code == 0, result.output - pending = store.pending_pings(now_nyc() + timedelta(hours=4)) + pending = store.pending_pings(now_nyc() + timedelta(hours=24)) prep = [p for p in pending if p.event_id == "evt-abc" and p.kind == "prep"] assert len(prep) == 1 # The new fire_at is ~20 minutes after the original (allowing ms drift). @@ -848,7 +848,7 @@ def test_snooze_skip_marks_ping_fired( result = runner.invoke(cli, ["snooze", "evt-abc", "--skip"]) assert result.exit_code == 0, result.output - pending = store.pending_pings(now_nyc() + timedelta(hours=4)) + pending = store.pending_pings(now_nyc() + timedelta(hours=24)) assert not any(p.event_id == "evt-abc" and p.kind == "prep" for p in pending) def test_snooze_requires_one_of_minutes_or_skip( @@ -897,7 +897,7 @@ def test_mute_event_sets_mute_and_cancels_pings( assert store.is_muted("evt-abc") is True from datetime import timedelta from commutecompass.timeutil import now_nyc - pending = store.pending_pings(now_nyc() + timedelta(hours=4)) + pending = store.pending_pings(now_nyc() + timedelta(hours=24)) assert not any(p.event_id == "evt-abc" for p in pending) def test_mute_today_mutes_all_plans( From 8b4730135a2e90f55751f49f656bc317a52fc55f Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:15:18 -0400 Subject: [PATCH 02/15] feat(reliability): re-fire actionable pings after transient send failure Previously a claimed ping was marked fired before the network send, and a failed send was logged but never retried -- a transient Telegram/relay blip silently dropped the one notification that matters (the leave/prep alarm). Keep the atomic cross-process claim, but on send failure of a prep/leave ping hand the row back (release_ping: 1->0, bump send_attempts) so the next poll re-attempts. Bounded by a grace window (no stale alarms) and an attempt cap (no storm from a persistently-broken notifier). --- AGENTS.md | 10 +- src/commutecompass/jobs/poll.py | 38 +++++++- src/commutecompass/models.py | 4 + src/commutecompass/store.py | 39 +++++++- tests/test_jobs.py | 164 ++++++++++++++++++++++++-------- 5 files changed, 206 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8a494a..ccf7d32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,8 +106,14 @@ commutecompass --config examples/config.toml tomorrow --dry-run 6. **Ping firing contract** - Use `store.claim_ping(id, now)` (atomic 0→1 transition) before sending a notification, not `mark_fired` after. This is the race - protection against overlapping poll cycles. Send failures are - logged but NOT retried — claim-and-send is one-shot by design. + protection against overlapping poll cycles. + - On send **failure** of an actionable ping (`prep`/`leave`), the poll + loop hands the row back with `store.release_ping(id)` (atomic 1→0, + bumps `send_attempts`) so a later poll re-fires it. Re-fire is bounded: + only within `_SEND_RETRY_GRACE_SECONDS` of the scheduled `fire_at` (a + stale alarm is worse than none) and only up to `_MAX_SEND_ATTEMPTS` + (a broken notifier can never storm). Other kinds, and pings past the + grace window or attempt cap, stay fired (give up) — never retried. 7. **OpenClaw stdout protocol** - `notify.StdoutNotifier` escapes any body line that exactly matches diff --git a/src/commutecompass/jobs/poll.py b/src/commutecompass/jobs/poll.py index 2991b01..f07897c 100644 --- a/src/commutecompass/jobs/poll.py +++ b/src/commutecompass/jobs/poll.py @@ -24,6 +24,15 @@ # Only applied when the caller did not inject a fetch_alerts_fn (tests bypass). _MTA_CACHE_TTL_SECONDS = 180 +# Re-fire policy for actionable pings whose send failed transiently. The claim +# already consumed the row; on failure we hand it back (release_ping) so the +# next poll re-attempts — but only for these kinds, only within the grace window +# of their scheduled time (a stale alarm is worse than none), and only up to the +# attempt cap so a persistently-broken notifier can never storm. +_RETRYABLE_PING_KINDS = frozenset({"prep", "leave"}) +_MAX_SEND_ATTEMPTS = 5 +_SEND_RETRY_GRACE_SECONDS = 15 * 60 + # Module-level memo: (captured_at, (subway_url, lirr_url, bus_url), alerts). _alerts_cache: "tuple[datetime, tuple[str, str, str], list[Alert]] | None" = None @@ -194,11 +203,32 @@ def run( if sent_ok: logger.info("Fired ping %s (%s)", ping.id, ping.kind) else: - logger.warning( - "Send failed for claimed ping %s (%s) — not retrying", - ping.id, - ping.kind, + # The claim already set fired=1. For actionable pings still within + # their grace window and under the attempt cap, hand the row back so + # the next poll retries; otherwise leave it fired (give up) so a + # broken notifier can't storm or deliver a stale alarm. + attempt = ping.send_attempts + 1 + within_grace = (now - ping.fire_at).total_seconds() <= _SEND_RETRY_GRACE_SECONDS + retryable = ( + ping.kind in _RETRYABLE_PING_KINDS + and attempt < _MAX_SEND_ATTEMPTS + and within_grace ) + if retryable and _store.release_ping(ping.id): + logger.warning( + "Send failed for %s ping %s (attempt %d/%d) — released for retry", + ping.kind, + ping.id, + attempt, + _MAX_SEND_ATTEMPTS, + ) + else: + logger.warning( + "Send failed for claimed ping %s (%s) after %d attempt(s) — giving up", + ping.id, + ping.kind, + attempt, + ) # Additive HA alarm: fire AFTER the primary send attempt regardless of # its outcome (claim already consumed the row). An HA outage cannot diff --git a/src/commutecompass/models.py b/src/commutecompass/models.py index ff206bb..0f8491b 100644 --- a/src/commutecompass/models.py +++ b/src/commutecompass/models.py @@ -236,6 +236,10 @@ class PingEntry(BaseModel): fired: bool = False fired_at: Optional[datetime] = None message: str + # Number of send attempts that have already failed for this ping. Used by + # the poll loop to bound cross-tick re-fire of actionable pings whose send + # failed transiently (see ``Store.release_ping``). + send_attempts: int = 0 class CurrentLocation(BaseModel): diff --git a/src/commutecompass/store.py b/src/commutecompass/store.py index 738e852..d6ce286 100644 --- a/src/commutecompass/store.py +++ b/src/commutecompass/store.py @@ -157,6 +157,13 @@ def init_schema(self) -> None: cl_cols = {row[1] for row in conn.execute("PRAGMA table_info(current_location)").fetchall()} if "accuracy_m" not in cl_cols: conn.execute("ALTER TABLE current_location ADD COLUMN accuracy_m REAL") + # Add send_attempts to pings: counts failed sends so the poll loop + # can bound cross-tick re-fire of actionable pings (see release_ping). + ping_cols = {row[1] for row in conn.execute("PRAGMA table_info(pings)").fetchall()} + if "send_attempts" not in ping_cols: + conn.execute( + "ALTER TABLE pings ADD COLUMN send_attempts INTEGER NOT NULL DEFAULT 0" + ) # ── Plan CRUD ────────────────────────────────────────────────────────────── @@ -268,7 +275,7 @@ def pending_pings(self, before: datetime) -> list[PingEntry]: with self._connect() as conn: rows = conn.execute( """ - SELECT id, event_id, kind, fire_at, fired, fired_at, message + SELECT id, event_id, kind, fire_at, fired, fired_at, message, send_attempts FROM pings WHERE fired = 0 AND fire_at <= ? ORDER BY fire_at @@ -287,6 +294,7 @@ def pending_pings(self, before: datetime) -> list[PingEntry]: fired=bool(row[4]), fired_at=datetime.fromisoformat(row[5]) if row[5] else None, message=row[6], + send_attempts=row[7], ) ) return pings @@ -307,9 +315,11 @@ def claim_ping(self, ping_id: str, fired_at: datetime) -> bool: caller (or a previous run) already claimed it — in which case the caller MUST NOT send, to avoid duplicate notifications. - Marking happens *before* the network send, so a failed send does not - cause a retry storm: a single attempt is the contract. Observability - is provided by the caller (log + summary line). + Marking happens *before* the network send so two concurrent runners + cannot both send. If the send then fails, the caller may hand the row + back with ``release_ping`` so a later poll re-attempts it (bounded by an + attempt cap + grace window). Observability is provided by the caller + (log + summary line). """ with self._connect() as conn: cursor = conn.execute( @@ -318,6 +328,27 @@ def claim_ping(self, ping_id: str, fired_at: datetime) -> bool: ) return cursor.rowcount == 1 + def release_ping(self, ping_id: str) -> bool: + """Hand a claimed ping back to the unfired pool after a failed send. + + Atomically transitions ``fired = 1 -> 0`` and increments + ``send_attempts`` so the next poll picks the row up again. Returns True + only when the row was actually claimed (``fired = 1``); a row already + re-fired or never claimed is left untouched. The caller is responsible + for bounding re-fire (attempt cap + grace window) so this can never + storm. + """ + with self._connect() as conn: + cursor = conn.execute( + """ + UPDATE pings + SET fired = 0, fired_at = NULL, send_attempts = send_attempts + 1 + WHERE id = ? AND fired = 1 + """, + (ping_id,), + ) + return cursor.rowcount == 1 + # ── Geocode cache ─────────────────────────────────────────────────────────── def cache_geocode(self, raw: str, resolved: ResolvedLocation) -> None: diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 564be50..74cbd3b 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -1754,68 +1754,154 @@ def planner(event: Event, **_kw: object) -> Plan: # ── Atomic claim semantics ──────────────────────────────────────────────────── -def test_poll_ping_marked_fired_even_when_primary_send_fails( - minimal_config: Config, - today_events: list[Event], - sample_route: Route, -) -> None: - """When the primary notifier returns False, the ping is still consumed. +class FlakyNotifier: + """Notifier that fails its first ``fail_times`` sends, then succeeds.""" - The claim-then-send contract is "one attempt per ping": a flaky network - must not turn a single missed send into a retry storm on every subsequent - poll cycle. - """ - now = now_nyc() + def __init__(self, fail_times: int) -> None: + self.sent: list[str] = [] + self._fail_times = fail_times + def send(self, text: str) -> bool: + self.sent.append(text) + ok = len(self.sent) > self._fail_times + return ok + + +def _seed_due_leave_plan( + config: Config, + event: Event, + route: Route, + now: "datetime", + *, + fire_offset_minutes: int = 5, + kind: str = "leave", +) -> Store: leave_at = (now + timedelta(hours=3)) - timedelta(minutes=45) plan = Plan( - event=today_events[0], - route=sample_route, + event=event, + route=route, leave_at=leave_at, prep_at=leave_at - timedelta(minutes=20), ) - due_ping = PingEntry( - id="ping-one-shot", - event_id=today_events[0].id, - kind="leave", - fire_at=now - timedelta(minutes=5), - fired=False, - message="leave now", - ) - - store = Store(minimal_config.paths.db_path) + store = Store(config.paths.db_path) store.init_schema() store.upsert_plan(plan) - store.schedule_ping(due_ping) + store.schedule_ping( + PingEntry( + id="ping-flaky", + event_id=event.id, + kind=cast("Any", kind), + fire_at=now - timedelta(minutes=fire_offset_minutes), + fired=False, + message="leave now", + ) + ) + return store - failing = SpyNotifier(return_value=False) +def _poll_at(config: Config, store: Store, plan: Plan, notifier: object, at: "datetime") -> None: poll_run( - minimal_config, + config, store=store, fetch_alerts_fn=lambda **kw: [], alerts_affecting_route_fn=lambda *a, **kw: [], - notifier=cast(TelegramNotifier, failing), + notifier=cast(TelegramNotifier, notifier), plan_event_fn=MockPlanner(plan), - now_fn=lambda: now, + now_fn=lambda: at, ha_fetch_fn=lambda *a, **kw: None, ) - # The notifier was invoked exactly once with the message. + +def test_poll_releases_leave_ping_for_retry_when_send_fails( + minimal_config: Config, + today_events: list[Event], + sample_route: Route, +) -> None: + """A leave ping whose send fails is handed back so the next poll retries. + + Losing the one notification that matters is worse than a second attempt: an + actionable ping within its grace window is re-fired on the following poll. + """ + now = now_nyc() + store = _seed_due_leave_plan(minimal_config, today_events[0], sample_route, now) + plan = store.get_plan(today_events[0].id) + assert plan is not None + + failing = SpyNotifier(return_value=False) + _poll_at(minimal_config, store, plan, failing, now) assert failing.sent == ["leave now"] + # Released back to the pending pool with the attempt recorded. + pending = store.pending_pings(before=now + timedelta(minutes=1)) + assert [p.id for p in pending] == ["ping-flaky"] + assert pending[0].send_attempts == 1 - # A second poll a minute later must NOT retry: claim already consumed. - poll_run( - minimal_config, - store=store, - fetch_alerts_fn=lambda **kw: [], - alerts_affecting_route_fn=lambda *a, **kw: [], - notifier=cast(TelegramNotifier, failing), - plan_event_fn=MockPlanner(plan), - now_fn=lambda: now + timedelta(minutes=1), - ha_fetch_fn=lambda *a, **kw: None, + # A minute later (still inside the grace window) it retries. + _poll_at(minimal_config, store, plan, failing, now + timedelta(minutes=1)) + assert failing.sent == ["leave now", "leave now"] + + +def test_poll_stops_retrying_once_send_succeeds( + minimal_config: Config, + today_events: list[Event], + sample_route: Route, +) -> None: + now = now_nyc() + store = _seed_due_leave_plan(minimal_config, today_events[0], sample_route, now) + plan = store.get_plan(today_events[0].id) + assert plan is not None + + flaky = FlakyNotifier(fail_times=1) # first send fails, second succeeds + _poll_at(minimal_config, store, plan, flaky, now) + _poll_at(minimal_config, store, plan, flaky, now + timedelta(minutes=1)) + assert flaky.sent == ["leave now", "leave now"] + + # Now fired for good — a third poll does not send again. + _poll_at(minimal_config, store, plan, flaky, now + timedelta(minutes=2)) + assert flaky.sent == ["leave now", "leave now"] + assert store.pending_pings(before=now + timedelta(minutes=5)) == [] + + +def test_poll_gives_up_after_attempt_cap( + minimal_config: Config, + today_events: list[Event], + sample_route: Route, +) -> None: + """A persistently-broken notifier must not retry forever (no storm).""" + now = now_nyc() + store = _seed_due_leave_plan(minimal_config, today_events[0], sample_route, now) + plan = store.get_plan(today_events[0].id) + assert plan is not None + + failing = SpyNotifier(return_value=False) + # Poll once per minute; after _MAX_SEND_ATTEMPTS the row is abandoned. + for i in range(8): + _poll_at(minimal_config, store, plan, failing, now + timedelta(minutes=i)) + + from commutecompass.jobs.poll import _MAX_SEND_ATTEMPTS + + assert len(failing.sent) == _MAX_SEND_ATTEMPTS + assert store.pending_pings(before=now + timedelta(minutes=10)) == [] + + +def test_poll_does_not_retry_stale_leave_ping( + minimal_config: Config, + today_events: list[Event], + sample_route: Route, +) -> None: + """Outside the grace window a failed leave send is abandoned, not re-fired.""" + now = now_nyc() + # fire_at is well past the grace window already. + store = _seed_due_leave_plan( + minimal_config, today_events[0], sample_route, now, fire_offset_minutes=30 ) + plan = store.get_plan(today_events[0].id) + assert plan is not None + + failing = SpyNotifier(return_value=False) + _poll_at(minimal_config, store, plan, failing, now) assert failing.sent == ["leave now"] + # Stale: consumed, not released. + assert store.pending_pings(before=now + timedelta(minutes=1)) == [] def test_poll_quiet_hours_leaves_unclaimed_for_later( From 6fb5859d943d6b6e1a3263b152ac827253e4bf58 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:17:10 -0400 Subject: [PATCH 03/15] feat(reliability): retry transient LLM failures geocode and MTA fetches already retry transient HTTP errors; the opencode-go client did not, so a single 5xx/timeout dropped a location resolution or alert classification for that run. Wrap the chat-completion request in retry() (one retry, transient-only). Also collapses the duplicate _call/_chat_completion network logic into one path. --- src/commutecompass/llm.py | 54 ++++++++++++++------------------------- tests/test_llm.py | 17 ++++++++++++ 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/commutecompass/llm.py b/src/commutecompass/llm.py index 09e56be..fe01647 100644 --- a/src/commutecompass/llm.py +++ b/src/commutecompass/llm.py @@ -11,6 +11,7 @@ import httpx from commutecompass.models import ResolvedLocation +from commutecompass.retry import retry if TYPE_CHECKING: from commutecompass.models import Alert, Route @@ -69,37 +70,12 @@ def resolve_location(self, raw: str, hints: dict[str, Any]) -> Optional[Resolved return location def _call(self, raw: str, hints: dict[str, Any]) -> str: - """Make the chat completion request and return the content string.""" - payload: dict[str, Any] = { - "model": self.model, - "messages": [ - {"role": "system", "content": _SYSTEM_PROMPT}, - {"role": "user", "content": raw}, - ], - "temperature": 0.0, - } - headers: dict[str, str] = { - "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json", - } - with httpx.Client(timeout=8.0) as client: - resp = client.post(self.endpoint, json=payload, headers=headers) - resp.raise_for_status() - data = resp.json() - if not isinstance(data, dict): - return "" - # OpenAI-compatible chat completion shape - choices = data.get("choices") - if not isinstance(choices, list) or not choices: - return "" - first = choices[0] - if not isinstance(first, dict): - return "" - message = first.get("message") - if not isinstance(message, dict): - return "" - content = message.get("content") - return content if isinstance(content, str) else "" + """Make the resolution chat completion request and return the content. + + ``hints`` is accepted for API stability but the location prompt is + self-contained, so it is not currently threaded into the request. + """ + return self._chat_completion(_SYSTEM_PROMPT, raw, timeout_seconds=8.0) def _chat_completion(self, system_prompt: str, user_content: str, *, timeout_seconds: float = 8.0) -> str: payload: dict[str, Any] = { @@ -114,10 +90,18 @@ def _chat_completion(self, system_prompt: str, user_content: str, *, timeout_sec "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", } - with httpx.Client(timeout=timeout_seconds) as client: - resp = client.post(self.endpoint, json=payload, headers=headers) - resp.raise_for_status() - data = resp.json() + + def _do_request() -> object: + # Retry transient blips (timeouts / network / 5xx / 429); a single + # flaky response should not cost the whole resolution. Non-transient + # errors (4xx, bad JSON) raise straight through to the caller, which + # logs and returns None. + with httpx.Client(timeout=timeout_seconds) as client: + resp = client.post(self.endpoint, json=payload, headers=headers) + resp.raise_for_status() + return resp.json() + + data = retry(_do_request, attempts=2, label="opencode-go") if not isinstance(data, dict): return "" choices = data.get("choices") diff --git a/tests/test_llm.py b/tests/test_llm.py index 3af6a54..a957da8 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -74,6 +74,23 @@ def test_unknown_kind_returns_none(self) -> None: ) assert result is None + def test_transient_failure_is_retried_then_succeeds(self) -> None: + """A single transient blip must not lose the resolution (retry recovers).""" + with patch("commutecompass.llm.httpx.Client") as mock_client_cls, patch( + "commutecompass.retry.time.sleep" + ): + mock_instance = mock_client_cls.return_value.__enter__.return_value + mock_instance.post.side_effect = [ + httpx.TimeoutException("blip"), + _make_response('{"kind": "address", "value": "200 Example St, NY"}'), + ] + client = _make_client() + result = client.resolve_location("200 Example St", {}) + + assert result is not None + assert result.value == "200 Example St, NY" + assert mock_instance.post.call_count == 2 + def test_fenced_json_parsed_correctly(self) -> None: fenced = """```json {"kind": "address", "value": "200 W 41st St, New York, NY 10036"} From 24356024a94c7b4fb9641697f1b2a4c3b1a4d6f8 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:19:40 -0400 Subject: [PATCH 04/15] feat(reliability): surface calendar auth failure in morning digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An expired/invalid OAuth refresh token was caught by the generic except and degraded to 'no events' — the user saw an empty day with no hint to re-auth. Catch AuthError distinctly, set an auth_failed flag, and prepend a loud 're-run commutecompass oauth' note to the digest's Operations footer (which is sent even when there are zero events). --- src/commutecompass/jobs/morning.py | 29 +++++++++++++++++++++++------ tests/test_jobs.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/commutecompass/jobs/morning.py b/src/commutecompass/jobs/morning.py index 6ab8b94..3650af7 100644 --- a/src/commutecompass/jobs/morning.py +++ b/src/commutecompass/jobs/morning.py @@ -19,7 +19,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from commutecompass.calendar_client import CalendarClient +from commutecompass.calendar_client import AuthError, CalendarClient from commutecompass.config import Config from commutecompass.format import format_digest, format_leave_ping, format_prep_ping from commutecompass.mta import fetch_alerts @@ -59,6 +59,7 @@ def run(config: Config) -> None: # noqa: C901 token_path=config.paths.oauth_token_path, ) events: list[Event] = [] + auth_failed = False try: events = calendar_client.fetch_events( calendars=[ @@ -68,6 +69,13 @@ def run(config: Config) -> None: # noqa: C901 start=today_start, end=today_end, ) + except AuthError as exc: + # An expired/invalid OAuth token degrades to "no events" — but unlike a + # transient API blip it will NOT fix itself, so surface it loudly in the + # digest footer instead of letting the user think their day is empty. + logger.error("Calendar auth failed — re-auth needed: %s", exc) + auth_failed = True + events = [] except Exception as exc: logger.error("Failed to fetch calendar events: %s", exc) # Continue with empty events list — digest will reflect no events @@ -222,7 +230,7 @@ def run(config: Config) -> None: # noqa: C901 ) # ── 6. Build and send digest ────────────────────────────────────────────── - ops_notes = _operations_notes(plans, all_alerts) + ops_notes = _operations_notes(plans, all_alerts, auth_failed=auth_failed) digest = format_digest(plans, affecting_alerts, operations_notes=ops_notes) notifier = build_notifier(config) sent = notifier.send(digest) @@ -237,7 +245,7 @@ def run(config: Config) -> None: # noqa: C901 too_imminent = sum(1 for p in plans if p.error == "too_imminent") logger.info( "morning_run_summary: events=%d plans=%d unresolved=%d no_route=%d " - "too_imminent=%d alerts=%d digest_sent=%s", + "too_imminent=%d alerts=%d digest_sent=%s auth_failed=%s", len(events), len(plans), unresolved, @@ -245,18 +253,27 @@ def run(config: Config) -> None: # noqa: C901 too_imminent, len(affecting_alerts), sent, + auth_failed, ) -def _operations_notes(plans: list[Plan], all_alerts: list[Alert]) -> list[str]: +def _operations_notes( + plans: list[Plan], all_alerts: list[Alert], *, auth_failed: bool = False +) -> list[str]: """Build the "Operations:" footer items for the morning digest. Surfaces degraded-service signals that today would only land in stderr: - MTA feeds that went silent after retries, plans whose location couldn't - be resolved, and plans that were stored with "too_imminent" / "no_route". + calendar auth that needs re-running, MTA feeds that went silent after + retries, plans whose location couldn't be resolved, and plans that were + stored with "too_imminent" / "no_route". """ notes: list[str] = [] + # Calendar auth failure first — without it the whole digest is empty and the + # user would otherwise have no idea their token lapsed. + if auth_failed: + notes.append("Calendar auth expired — re-run `commutecompass oauth`") + # Per-feed MTA failures reported by fetch_alerts (set as an attribute). failed_feeds: list[str] = getattr(fetch_alerts, "last_failed_systems", []) for system in failed_feeds: diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 74cbd3b..3c09a66 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -200,6 +200,34 @@ def sample_route() -> Route: # ─────────── Morning job tests ──────────────────────────────────────────────── +def test_morning_run_surfaces_calendar_auth_failure( + minimal_config: Config, + tmp_path: Path, +) -> None: + """An expired OAuth token must be reported in the digest, not silently empty.""" + from commutecompass.calendar_client import AuthError + + with patch("commutecompass.jobs.morning.CalendarClient") as mock_cal_class, patch( + "commutecompass.jobs.morning.fetch_alerts" + ) as mock_fetch_alerts, patch( + "commutecompass.jobs.morning.build_notifier" + ) as mock_notifier_class: + mock_cal = MagicMock() + mock_cal.fetch_events.side_effect = AuthError("token refresh failed") + mock_cal_class.return_value = mock_cal + mock_fetch_alerts.return_value = [] + mock_notifier = MagicMock() + mock_notifier.send.return_value = True + mock_notifier_class.return_value = mock_notifier + + morning_run(minimal_config) + + # Digest still sent, and it tells the user to re-auth. + mock_notifier.send.assert_called_once() + sent_text = mock_notifier.send.call_args.args[0] + assert "oauth" in sent_text.lower() + + def test_morning_run_fetches_and_plans( minimal_config: Config, tmp_path: Path, From 05a4016ad78eec0706815e7a96f627c58e6ed034 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:25:30 -0400 Subject: [PATCH 05/15] feat(reliability): cache routes + fall back when live routing is down Previously a Directions outage (or missing key) produced no route -> no plan -> no alarms for the entire day, and every plan hit the API afresh. Now a successful route is cached per (origin, dest, mode); when live routing fails the planner reuses the last good cached route, then a coarse haversine distance/speed estimate, before giving up with no_route. Fallback routes are flagged approximate and labelled '(estimated)' in the digest so timing is known to be best-effort. --- src/commutecompass/format.py | 5 ++- src/commutecompass/models.py | 5 +++ src/commutecompass/planner.py | 16 ++++++- src/commutecompass/routing.py | 76 +++++++++++++++++++++++++++++++- src/commutecompass/store.py | 59 ++++++++++++++++++++++++- tests/test_format.py | 15 +++++++ tests/test_planner.py | 83 +++++++++++++++++++++++++++++++++-- tests/test_routing.py | 53 +++++++++++++++++++++- tests/test_store.py | 42 ++++++++++++++++++ 9 files changed, 345 insertions(+), 9 deletions(-) diff --git a/src/commutecompass/format.py b/src/commutecompass/format.py index 694cf8e..7ee7e63 100644 --- a/src/commutecompass/format.py +++ b/src/commutecompass/format.py @@ -393,7 +393,10 @@ def _route_summary(route: Route) -> str: else: transfer_suffix = "" - return f"{mode_label} ({total_min} min{transfer_suffix})" + # Mark routes that came from cache/estimate rather than live routing. + estimate_suffix = ", estimated" if route.approximate else "" + + return f"{mode_label} ({total_min} min{transfer_suffix}{estimate_suffix})" def _route_summary_detailed(route: Route) -> str: diff --git a/src/commutecompass/models.py b/src/commutecompass/models.py index 0f8491b..6f29212 100644 --- a/src/commutecompass/models.py +++ b/src/commutecompass/models.py @@ -207,6 +207,11 @@ class Route(BaseModel): transfers: int = 0 fare_estimate_cents: Optional[int] = None raw_provider_payload: Optional[dict[str, Any]] = None + # True when the route did not come from a live Directions response — either + # a previously-cached route reused during an API outage, or a coarse + # distance/speed estimate. Surfaced in the digest so the user knows the + # timing is best-effort rather than schedule-accurate. + approximate: bool = False class Plan(BaseModel): diff --git a/src/commutecompass/planner.py b/src/commutecompass/planner.py index d7e19af..d58f7e7 100644 --- a/src/commutecompass/planner.py +++ b/src/commutecompass/planner.py @@ -130,7 +130,7 @@ def plan_event( Returns a Plan with route and timing, or an error Plan on failure. """ from commutecompass.resolver import resolve - from commutecompass.routing import plan_route + from commutecompass.routing import estimate_route, plan_route, route_cache_key from commutecompass.geocode import geocode # Step 1: resolve location (override applied first) @@ -158,8 +158,12 @@ def geocoder(addr: str) -> Optional[GeocodeResult]: if resolved is None: return Plan(event=event, error="location_unresolved") - # Step 2: plan route + # Step 2: plan route. A live route is cached for reuse; if live routing is + # unavailable we fall back to the last good cached route, then to a coarse + # distance estimate — so an API outage degrades to "approximate" rather than + # silently producing no plan (and therefore no alarm) for the whole day. route_origin = effective_origin(config, store, override=origin_override) + cache_key = route_cache_key(route_origin) route = plan_route( origin=route_origin, @@ -168,6 +172,14 @@ def geocoder(addr: str) -> Optional[GeocodeResult]: mode=mode, api_key=config.google_maps_api_key, ) + if route is not None: + store.cache_route(cache_key, resolved.value, mode, route) + else: + route = store.get_cached_route(cache_key, resolved.value, mode) + if route is not None: + route = route.model_copy(update={"approximate": True}) + else: + route = estimate_route(route_origin, resolved, event.start, mode) if route is None: return Plan(event=event, error="no_route") diff --git a/src/commutecompass/routing.py b/src/commutecompass/routing.py index 12524a8..04fb05c 100644 --- a/src/commutecompass/routing.py +++ b/src/commutecompass/routing.py @@ -3,7 +3,7 @@ from __future__ import annotations import math -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Literal, Optional import httpx @@ -17,6 +17,80 @@ def _unix(dt: datetime) -> int: return int(dt.timestamp()) +def route_cache_key(origin: Origin) -> str: + """Stable cache key for an origin. + + Rounds coordinates to ~11 m (4 decimals) so jitter in GPS-derived origins + doesn't fragment the cache while still distinguishing real start points. + """ + return f"{origin.lat:.4f},{origin.lon:.4f}" + + +# Effective door-to-door speeds (km/h) for the coarse fallback estimate. These +# bake in waiting/transfers/parking, so they are deliberately well below vehicle +# cruising speed — the goal is a leave-time that is roughly right, not a schedule. +_FALLBACK_SPEED_KMH: dict[str, float] = { + "transit": 18.0, + "driving": 25.0, + "bicycling": 14.0, + "walking": 4.8, +} + + +def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Great-circle distance between two WGS84 points, in kilometers.""" + r = 6371.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlambda = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2 + return 2 * r * math.asin(min(1.0, math.sqrt(a))) + + +def estimate_route( + origin: Origin, + destination: ResolvedLocation, + arrival_time: datetime, + mode: Literal["transit", "driving", "walking", "bicycling"] = "transit", +) -> Optional[Route]: + """Build a coarse distance/speed route estimate when live routing is down. + + Returns None when the destination has no coordinates (e.g. an unresolved + station name) — there is nothing to measure against, so the caller should + fall back to ``no_route`` rather than fabricate a number. The returned + route is flagged ``approximate``. + """ + if destination.lat is None or destination.lon is None: + return None + + distance_km = _haversine_km(origin.lat, origin.lon, destination.lat, destination.lon) + # Crow-flies underestimates real path length; pad by 30% as a rough detour + # factor before dividing by the mode speed. + speed_kmh = _FALLBACK_SPEED_KMH.get(mode, _FALLBACK_SPEED_KMH["transit"]) + hours = (distance_km * 1.3) / speed_kmh + duration_seconds = max(60, int(hours * 3600)) + + depart_at = arrival_time - timedelta(seconds=duration_seconds) + leg = TransitLeg( + mode=mode.upper(), # type: ignore[arg-type] + system=None, + line=None, + headsign=None, + depart_at=depart_at, + arrive_at=arrival_time, + duration_seconds=duration_seconds, + summary=f"Estimated {mode} (~{distance_km:.1f} km, live routing unavailable)", + ) + return Route( + legs=[leg], + depart_at=depart_at, + arrive_at=arrival_time, + total_duration_seconds=duration_seconds, + transfers=0, + approximate=True, + ) + + def _parse_step(step: dict[str, Any], nyc_tz: Any) -> Optional[TransitLeg]: """Parse a single step from a Directions leg into a TransitLeg. diff --git a/src/commutecompass/store.py b/src/commutecompass/store.py index d6ce286..a5d521b 100644 --- a/src/commutecompass/store.py +++ b/src/commutecompass/store.py @@ -10,7 +10,14 @@ import sqlite3 -from commutecompass.models import AdjustRow, CurrentLocation, Plan, PingEntry, ResolvedLocation +from commutecompass.models import ( + AdjustRow, + CurrentLocation, + Plan, + PingEntry, + ResolvedLocation, + Route, +) def _now_iso() -> str: @@ -100,6 +107,14 @@ def init_schema(self) -> None: resolved_json TEXT NOT NULL, cached_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS route_cache ( + origin_key TEXT NOT NULL, + dest_value TEXT NOT NULL, + mode TEXT NOT NULL, + route_json TEXT NOT NULL, + cached_at TEXT NOT NULL, + PRIMARY KEY (origin_key, dest_value, mode) + ); CREATE TABLE IF NOT EXISTS alerts_seen ( alert_id TEXT NOT NULL, event_id TEXT NOT NULL, @@ -387,6 +402,48 @@ def get_geocode(self, raw: str, max_age_days: int = 30) -> Optional[ResolvedLoca data = _json_loads(row[0]) return ResolvedLocation.model_validate(data) + # ── Route cache ────────────────────────────────────────────────────────────── + + def cache_route(self, origin_key: str, dest_value: str, mode: str, route: Route) -> None: + """Store the latest successful route for an (origin, dest, mode) triple. + + Used as a fallback when live routing is unavailable. Only one row per + triple is kept (latest wins); the cached travel duration is what lets + the planner still compute a leave time during an API outage. + """ + with self._connect() as conn: + conn.execute( + """ + INSERT INTO route_cache (origin_key, dest_value, mode, route_json, cached_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(origin_key, dest_value, mode) DO UPDATE SET + route_json = excluded.route_json, + cached_at = excluded.cached_at + """, + (origin_key, dest_value, mode, _json_dumps(route.model_dump()), _now_iso()), + ) + + def get_cached_route( + self, origin_key: str, dest_value: str, mode: str, max_age_days: int = 30 + ) -> Optional[Route]: + """Return the most recent cached route for the triple, if fresh enough.""" + from datetime import timedelta + + from commutecompass.timeutil import now_nyc + + cutoff = now_nyc() - timedelta(days=max_age_days) + with self._connect() as conn: + row = conn.execute( + """ + SELECT route_json FROM route_cache + WHERE origin_key = ? AND dest_value = ? AND mode = ? AND cached_at >= ? + """, + (origin_key, dest_value, mode, cutoff.isoformat()), + ).fetchone() + if row is None: + return None + return Route.model_validate(_json_loads(row[0])) + # ── Alert ledger ──────────────────────────────────────────────────────────── def mark_alert_seen(self, alert_id: str, event_id: str) -> None: diff --git a/tests/test_format.py b/tests/test_format.py index 925066e..fab0ffa 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -1027,6 +1027,21 @@ def test_route_summary_omits_zero_transfer_text(self) -> None: summary = _route_summary(route) assert summary == "Subway (C) (48 min)" + def test_route_summary_marks_approximate_routes(self) -> None: + """An approximate (cached/estimated) route is labelled in the digest.""" + depart = datetime(2026, 5, 12, 8, 0, tzinfo=timezone.utc) + arrive = datetime(2026, 5, 12, 8, 48, tzinfo=timezone.utc) + legs = [ + TransitLeg( + mode="TRANSIT", system="MTA Subway", line="C", headsign="Fulton St", + depart_at=depart, arrive_at=arrive, + duration_seconds=2880, summary="C train", + ), + ] + route = Route(legs=legs, depart_at=depart, arrive_at=arrive, + total_duration_seconds=2880, transfers=0, approximate=True) + assert _route_summary(route) == "Subway (C) (48 min, estimated)" + def test_route_summary_includes_transfer_text_when_present(self) -> None: """_route_summary includes transfer text when transfers are present.""" depart = datetime(2026, 5, 12, 8, 0, tzinfo=timezone.utc) diff --git a/tests/test_planner.py b/tests/test_planner.py index 9dfdbe2..9150e56 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -255,9 +255,12 @@ def test_plan_event_no_route( resolved_location: ResolvedLocation, nyc_now: datetime, ) -> None: - """Returns error='no_route' when plan_route returns None.""" + """Returns error='no_route' only when live routing AND every fallback fail.""" + store = MagicMock() + store.get_cached_route.return_value = None with patch("commutecompass.resolver.resolve") as mock_resolve, \ - patch("commutecompass.routing.plan_route") as mock_plan_route: + patch("commutecompass.routing.plan_route") as mock_plan_route, \ + patch("commutecompass.routing.estimate_route", return_value=None): mock_resolve.return_value = resolved_location mock_plan_route.return_value = None @@ -265,7 +268,7 @@ def test_plan_event_no_route( event, config, MagicMock(spec=VenueRegistry), - MagicMock(), + store, MagicMock(spec=OpencodeGoClient), ) @@ -275,6 +278,80 @@ def test_plan_event_no_route( assert result.prep_at is None +def test_plan_event_reuses_cached_route_when_live_routing_down( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """When the Directions call fails, the last good cached route still plans the day.""" + store = MagicMock() + store.get_cached_route.return_value = mock_route.model_copy() + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route", return_value=None), \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location + + result = plan_event( + event, config, MagicMock(spec=VenueRegistry), store, MagicMock(spec=OpencodeGoClient) + ) + + assert result.error is None + assert result.route is not None + assert result.route.approximate is True + assert result.leave_at is not None + store.get_cached_route.assert_called_once() + + +def test_plan_event_falls_back_to_distance_estimate( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + nyc_now: datetime, +) -> None: + """No live route and no cache → a coarse distance estimate keeps alarms alive.""" + store = MagicMock() + store.get_cached_route.return_value = None + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route", return_value=None), \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location # has lat/lon + + result = plan_event( + event, config, MagicMock(spec=VenueRegistry), store, MagicMock(spec=OpencodeGoClient) + ) + + assert result.error is None + assert result.route is not None + assert result.route.approximate is True + assert result.route.total_duration_seconds > 0 + + +def test_plan_event_caches_live_route( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """A successful live route is written to the cache for future fallback use.""" + store = MagicMock() + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route", return_value=mock_route), \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location + + result = plan_event( + event, config, MagicMock(spec=VenueRegistry), store, MagicMock(spec=OpencodeGoClient) + ) + + assert result.error is None + assert result.route is not None + assert result.route.approximate is False + store.cache_route.assert_called_once() + + def test_plan_event_mode_override( event: Event, config: Config, diff --git a/tests/test_routing.py b/tests/test_routing.py index e97df69..74926b7 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -10,10 +10,61 @@ import pytest from commutecompass.models import Origin, ResolvedLocation, Route -from commutecompass.routing import _parse_route, _unix, plan_route +from commutecompass.routing import ( + _parse_route, + _unix, + estimate_route, + plan_route, + route_cache_key, +) from commutecompass.timeutil import NYC_TZ +# ─── Fallback estimate ───────────────────────────────────────────────────────── + +def test_route_cache_key_rounds_coordinates() -> None: + a = Origin(address="a", lat=40.69501, lon=-73.98904) + b = Origin(address="b", lat=40.69499, lon=-73.98897) # within ~11m + assert route_cache_key(a) == route_cache_key(b) + far = Origin(address="c", lat=40.75, lon=-73.99) + assert route_cache_key(far) != route_cache_key(a) + + +def test_estimate_route_produces_approximate_route() -> None: + origin = Origin(address="home", lat=40.6950, lon=-73.9890) + dest = ResolvedLocation( + kind="address", value="Midtown", lat=40.7549, lon=-73.9840, source="geocode" + ) + arrival = datetime(2026, 5, 8, 14, 30, tzinfo=NYC_TZ) + + route = estimate_route(origin, dest, arrival, "transit") + assert route is not None + assert route.approximate is True + assert route.total_duration_seconds > 0 + # arrive_at is the requested time; depart_at precedes it by the estimate. + assert route.arrive_at == arrival + assert route.depart_at < arrival + + +def test_estimate_route_none_without_destination_coords() -> None: + origin = Origin(address="home", lat=40.6950, lon=-73.9890) + dest = ResolvedLocation(kind="station", value="Somewhere LIRR", source="llm") + arrival = datetime(2026, 5, 8, 14, 30, tzinfo=NYC_TZ) + assert estimate_route(origin, dest, arrival, "transit") is None + + +def test_estimate_route_slower_modes_take_longer() -> None: + origin = Origin(address="home", lat=40.6950, lon=-73.9890) + dest = ResolvedLocation( + kind="address", value="Midtown", lat=40.7549, lon=-73.9840, source="geocode" + ) + arrival = datetime(2026, 5, 8, 14, 30, tzinfo=NYC_TZ) + walking = estimate_route(origin, dest, arrival, "walking") + driving = estimate_route(origin, dest, arrival, "driving") + assert walking is not None and driving is not None + assert walking.total_duration_seconds > driving.total_duration_seconds + + # ─── Helper fixtures ─────────────────────────────────────────────────────────── @pytest.fixture diff --git a/tests/test_store.py b/tests/test_store.py index 1236837..fe43b18 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -529,6 +529,48 @@ def test_get_geocode_miss(tmp_db_path: Path) -> None: assert store.get_geocode("never-cached-address") is None +# ── Route cache tests ─────────────────────────────────────────────────────────── + +def test_cache_route_round_trip(tmp_db_path: Path) -> None: + """A cached route is retrievable for the same (origin, dest, mode) triple.""" + store = Store(tmp_db_path) + store.init_schema() + route = make_route(datetime(2026, 5, 8, 14, 0, tzinfo=timezone.utc)) + + store.cache_route("40.6950,-73.9890", "Midtown", "transit", route) + got = store.get_cached_route("40.6950,-73.9890", "Midtown", "transit") + assert got is not None + assert got.total_duration_seconds == route.total_duration_seconds + # Different mode / dest / origin are cache misses. + assert store.get_cached_route("40.6950,-73.9890", "Midtown", "driving") is None + assert store.get_cached_route("40.6950,-73.9890", "Brooklyn", "transit") is None + + +def test_cache_route_keeps_latest(tmp_db_path: Path) -> None: + """Re-caching the same triple overwrites the previous route.""" + store = Store(tmp_db_path) + store.init_schema() + older = make_route(datetime(2026, 5, 8, 14, 0, tzinfo=timezone.utc)) + older.total_duration_seconds = 1000 + newer = make_route(datetime(2026, 5, 8, 14, 0, tzinfo=timezone.utc)) + newer.total_duration_seconds = 2000 + + store.cache_route("k", "d", "transit", older) + store.cache_route("k", "d", "transit", newer) + got = store.get_cached_route("k", "d", "transit") + assert got is not None + assert got.total_duration_seconds == 2000 + + +def test_get_cached_route_respects_max_age(tmp_db_path: Path) -> None: + """A stale cached route past max_age is treated as a miss.""" + store = Store(tmp_db_path) + store.init_schema() + route = make_route(datetime(2026, 5, 8, 14, 0, tzinfo=timezone.utc)) + store.cache_route("k", "d", "transit", route) + assert store.get_cached_route("k", "d", "transit", max_age_days=0) is None + + # ── Alert ledger tests ───────────────────────────────────────────────────────── def test_mark_alert_seen_and_is_alert_seen(tmp_db_path: Path) -> None: From a95711809ef6161400a0dfa9a0c8866d43f58708 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:30:10 -0400 Subject: [PATCH 06/15] feat(observability): heartbeat dead-man's-switch for the poll timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-hosted alarm fails silently if the per-minute poll timer dies — the user just stops getting notifications. Record a per-job heartbeat in SQLite; the morning digest now warns when the poll loop has gone stale (timer dead = no alarms today). Add an optional [monitoring].heartbeat_url (healthchecks.io-style) that poll/morning ping on success for an off-host dead-man's-switch. --- examples/config.toml | 8 +++++ src/commutecompass/config.py | 23 ++++++++++++++ src/commutecompass/jobs/morning.py | 45 ++++++++++++++++++++++++---- src/commutecompass/jobs/poll.py | 9 ++++++ src/commutecompass/monitoring.py | 39 ++++++++++++++++++++++++ src/commutecompass/store.py | 28 +++++++++++++++++ tests/test_jobs.py | 48 ++++++++++++++++++++++++++++++ tests/test_monitoring.py | 36 ++++++++++++++++++++++ tests/test_store.py | 18 +++++++++++ 9 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/commutecompass/monitoring.py create mode 100644 tests/test_monitoring.py diff --git a/examples/config.toml b/examples/config.toml index 186663c..03a613e 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -25,6 +25,14 @@ poll_interval_seconds = 60 quiet_hours_start = "22:00" quiet_hours_end = "07:00" +[monitoring] +# Optional dead-man's-switch. poll_staleness_minutes controls how long the poll +# loop may be silent before the morning digest warns the timer is dead. Set +# heartbeat_url to a healthchecks.io-style URL to get an off-host alert when the +# per-minute poll stops pinging it entirely. +poll_staleness_minutes = 15 +# heartbeat_url = "https://hc-ping.com/your-uuid-here" + [paths] venues_file = "/etc/commutecompass/known_venues.yaml" db_path = "/var/lib/commutecompass/state.db" diff --git a/src/commutecompass/config.py b/src/commutecompass/config.py index 62f54d9..4116f9a 100644 --- a/src/commutecompass/config.py +++ b/src/commutecompass/config.py @@ -134,6 +134,28 @@ class NotifyConfig(BaseModel): mode: Literal["stdout", "telegram"] = "stdout" +class MonitoringConfig(BaseModel): + """Dead-man's-switch / heartbeat configuration. + + ``heartbeat_url`` is an optional healthchecks.io-style endpoint that the + poll job pings on every successful run; the external service alerts when the + pings stop (i.e. the per-minute timer died). ``poll_staleness_minutes`` is + the threshold past which the morning digest flags that poll has not run. + """ + + heartbeat_url: Optional[str] = None + poll_staleness_minutes: int = Field(default=15, ge=1, le=24 * 60) + + @field_validator("heartbeat_url") + @classmethod + def _validate_heartbeat_url(cls, v: Optional[str]) -> Optional[str]: + if v and not (v.startswith("http://") or v.startswith("https://")): + raise ValueError( + f"monitoring.heartbeat_url must start with http(s)://, got {v!r}" + ) + return v + + class Config(BaseModel): origin: Origin calendars: list[CalendarSpec] @@ -146,6 +168,7 @@ class Config(BaseModel): mode_overrides: list[ModeOverride] = [] home_assistant: HomeAssistantConfig = HomeAssistantConfig() notify: NotifyConfig = NotifyConfig() + monitoring: MonitoringConfig = MonitoringConfig() # Loaded from env, not TOML: google_maps_api_key: str = "" google_oauth_client_secret_json: str = "" diff --git a/src/commutecompass/jobs/morning.py b/src/commutecompass/jobs/morning.py index 3650af7..ab4ee44 100644 --- a/src/commutecompass/jobs/morning.py +++ b/src/commutecompass/jobs/morning.py @@ -16,6 +16,7 @@ import logging import uuid +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING @@ -230,7 +231,10 @@ def run(config: Config) -> None: # noqa: C901 ) # ── 6. Build and send digest ────────────────────────────────────────────── - ops_notes = _operations_notes(plans, all_alerts, auth_failed=auth_failed) + poll_stale = _poll_heartbeat_stale(store, config, _now) + ops_notes = _operations_notes( + plans, all_alerts, auth_failed=auth_failed, poll_stale=poll_stale + ) digest = format_digest(plans, affecting_alerts, operations_notes=ops_notes) notifier = build_notifier(config) sent = notifier.send(digest) @@ -239,6 +243,13 @@ def run(config: Config) -> None: # noqa: C901 else: logger.warning("morning job: digest send failed") + # Record morning's own heartbeat and ping the external dead-man's-switch. + store.record_job_success("morning", _now) + if config.monitoring.heartbeat_url: + from commutecompass.monitoring import ping_heartbeat + + ping_heartbeat(config.monitoring.heartbeat_url) + # ── 7. Log structured summary ──────────────────────────────────────────── unresolved = sum(1 for p in plans if p.error == "location_unresolved") no_route = sum(1 for p in plans if p.error == "no_route") @@ -257,15 +268,35 @@ def run(config: Config) -> None: # noqa: C901 ) +def _poll_heartbeat_stale(store: Store, config: Config, now: datetime) -> bool: + """True if the poll loop has not completed within the staleness threshold. + + Poll runs every minute, so by morning a healthy poll heartbeat is seconds + old. A stale (or missing) heartbeat means the per-minute timer is dead and + no leave/prep alarms will fire today — worth shouting about in the digest. + """ + from datetime import timedelta + + last = store.get_job_heartbeat("poll") + if last is None: + return True + threshold = timedelta(minutes=config.monitoring.poll_staleness_minutes) + return (now - last) > threshold + + def _operations_notes( - plans: list[Plan], all_alerts: list[Alert], *, auth_failed: bool = False + plans: list[Plan], + all_alerts: list[Alert], + *, + auth_failed: bool = False, + poll_stale: bool = False, ) -> list[str]: """Build the "Operations:" footer items for the morning digest. Surfaces degraded-service signals that today would only land in stderr: - calendar auth that needs re-running, MTA feeds that went silent after - retries, plans whose location couldn't be resolved, and plans that were - stored with "too_imminent" / "no_route". + calendar auth that needs re-running, a dead poll timer, MTA feeds that went + silent after retries, plans whose location couldn't be resolved, and plans + that were stored with "too_imminent" / "no_route". """ notes: list[str] = [] @@ -274,6 +305,10 @@ def _operations_notes( if auth_failed: notes.append("Calendar auth expired — re-run `commutecompass oauth`") + # A dead poll timer means no alarms will fire today. + if poll_stale: + notes.append("Poll loop has not run recently — alarms may not fire (check the timer)") + # Per-feed MTA failures reported by fetch_alerts (set as an attribute). failed_feeds: list[str] = getattr(fetch_alerts, "last_failed_systems", []) for system in failed_feeds: diff --git a/src/commutecompass/jobs/poll.py b/src/commutecompass/jobs/poll.py index f07897c..fa02808 100644 --- a/src/commutecompass/jobs/poll.py +++ b/src/commutecompass/jobs/poll.py @@ -382,6 +382,15 @@ def run( _store.cancel_pings(plan.event.id) _schedule_pings_for_plan(new_plan, _store, now) + # ── Phase 6: heartbeat ──────────────────────────────────────────────────── + # Record that poll completed, and ping the external dead-man's-switch (if + # configured) — the per-minute poll is the natural liveness signal. + _store.record_job_success("poll", now) + if config.monitoring.heartbeat_url: + from commutecompass.monitoring import ping_heartbeat + + ping_heartbeat(config.monitoring.heartbeat_url) + def _location_update_significant(old_plan: Plan, new_plan: Plan) -> bool: """Stricter check used only for Phase 5 location-driven updates. diff --git a/src/commutecompass/monitoring.py b/src/commutecompass/monitoring.py new file mode 100644 index 0000000..a0524fc --- /dev/null +++ b/src/commutecompass/monitoring.py @@ -0,0 +1,39 @@ +"""Dead-man's-switch heartbeat. + +A self-hosted alarm has a silent failure mode: if the per-minute poll timer +stops firing, the user just stops getting notifications and has no way to know. +The internal `job_heartbeat` table lets the morning digest report that poll went +stale, and an optional external healthchecks.io-style URL provides an off-host +safety net that alerts when the pings stop entirely. +""" + +from __future__ import annotations + +import logging + +import httpx + +from commutecompass.retry import retry + +logger = logging.getLogger(__name__) + + +def ping_heartbeat(url: str, *, timeout: float = 5.0) -> bool: + """GET a healthcheck URL to signal liveness. Returns True on 2xx. + + Failures are swallowed (logged at debug): a monitoring blip must never break + the job whose health it is reporting. + """ + if not url: + return False + + def _do() -> None: + with httpx.Client(timeout=timeout) as client: + client.get(url).raise_for_status() + + try: + retry(_do, attempts=2, label="heartbeat") + return True + except Exception as exc: # pragma: no cover - exercised via swallow path + logger.debug("heartbeat ping failed for %s: %s", url, exc) + return False diff --git a/src/commutecompass/store.py b/src/commutecompass/store.py index a5d521b..4729dfe 100644 --- a/src/commutecompass/store.py +++ b/src/commutecompass/store.py @@ -139,6 +139,10 @@ def init_schema(self) -> None: muted_at TEXT NOT NULL, expires_at TEXT ); + CREATE TABLE IF NOT EXISTS job_heartbeat ( + job TEXT PRIMARY KEY, + last_success TEXT NOT NULL + ); """) # Migrate adjust_log: add columns required for `undo` (single-step # restoration of the exact previous prep_at + undone flag). @@ -444,6 +448,30 @@ def get_cached_route( return None return Route.model_validate(_json_loads(row[0])) + # ── Job heartbeat ──────────────────────────────────────────────────────────── + + def record_job_success(self, job: str, at: datetime) -> None: + """Record the most recent successful completion of a named job.""" + with self._connect() as conn: + conn.execute( + """ + INSERT INTO job_heartbeat (job, last_success) + VALUES (?, ?) + ON CONFLICT(job) DO UPDATE SET last_success = excluded.last_success + """, + (job, at.isoformat()), + ) + + def get_job_heartbeat(self, job: str) -> Optional[datetime]: + """Return the last successful run time for a job, or None if never run.""" + with self._connect() as conn: + row = conn.execute( + "SELECT last_success FROM job_heartbeat WHERE job = ?", (job,) + ).fetchone() + if row is None: + return None + return datetime.fromisoformat(row[0]) + # ── Alert ledger ──────────────────────────────────────────────────────────── def mark_alert_seen(self, alert_id: str, event_id: str) -> None: diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 3c09a66..77f6df6 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -228,6 +228,39 @@ def test_morning_run_surfaces_calendar_auth_failure( assert "oauth" in sent_text.lower() +def _run_morning_capture_digest(config: Config) -> str: + with patch("commutecompass.jobs.morning.CalendarClient") as mock_cal_class, patch( + "commutecompass.jobs.morning.fetch_alerts" + ) as mock_fetch_alerts, patch( + "commutecompass.jobs.morning.build_notifier" + ) as mock_notifier_class: + mock_cal = MagicMock() + mock_cal.fetch_events.return_value = [] + mock_cal_class.return_value = mock_cal + mock_fetch_alerts.return_value = [] + mock_notifier = MagicMock() + mock_notifier.send.return_value = True + mock_notifier_class.return_value = mock_notifier + morning_run(config) + return str(mock_notifier.send.call_args.args[0]) + + +def test_morning_flags_dead_poll_timer(minimal_config: Config) -> None: + """A missing/stale poll heartbeat is surfaced in the digest.""" + # No poll heartbeat recorded → stale. + text = _run_morning_capture_digest(minimal_config) + assert "poll loop has not run" in text.lower() + + +def test_morning_silent_when_poll_heartbeat_fresh(minimal_config: Config) -> None: + """A fresh poll heartbeat means no dead-timer warning.""" + store = Store(minimal_config.paths.db_path) + store.init_schema() + store.record_job_success("poll", now_nyc()) + text = _run_morning_capture_digest(minimal_config) + assert "poll loop has not run" not in text.lower() + + def test_morning_run_fetches_and_plans( minimal_config: Config, tmp_path: Path, @@ -1911,6 +1944,21 @@ def test_poll_gives_up_after_attempt_cap( assert store.pending_pings(before=now + timedelta(minutes=10)) == [] +def test_poll_records_heartbeat( + minimal_config: Config, + today_events: list[Event], + sample_route: Route, +) -> None: + """Every poll run records a 'poll' heartbeat for the dead-man's-switch.""" + now = now_nyc() + store = Store(minimal_config.paths.db_path) + store.init_schema() + assert store.get_job_heartbeat("poll") is None + + _poll_at(minimal_config, store, Plan(event=today_events[0]), SpyNotifier(), now) + assert store.get_job_heartbeat("poll") == now + + def test_poll_does_not_retry_stale_leave_ping( minimal_config: Config, today_events: list[Event], diff --git a/tests/test_monitoring.py b/tests/test_monitoring.py new file mode 100644 index 0000000..8734caa --- /dev/null +++ b/tests/test_monitoring.py @@ -0,0 +1,36 @@ +"""Tests for the heartbeat dead-man's-switch.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import httpx + +from commutecompass.monitoring import ping_heartbeat + + +def _response(status: int) -> httpx.Response: + return httpx.Response(status, request=MagicMock(spec=httpx.Request)) + + +def test_ping_heartbeat_success() -> None: + with patch("commutecompass.monitoring.httpx.Client") as mock_cls: + inst = mock_cls.return_value.__enter__.return_value + inst.get.return_value = _response(200) + assert ping_heartbeat("https://hc-ping.example/abc") is True + + +def test_ping_heartbeat_empty_url_is_noop() -> None: + with patch("commutecompass.monitoring.httpx.Client") as mock_cls: + assert ping_heartbeat("") is False + mock_cls.assert_not_called() + + +def test_ping_heartbeat_swallows_failure() -> None: + """A monitoring blip must never raise into the calling job.""" + with patch("commutecompass.monitoring.httpx.Client") as mock_cls, patch( + "commutecompass.retry.time.sleep" + ): + inst = mock_cls.return_value.__enter__.return_value + inst.get.side_effect = httpx.ConnectError("down") + assert ping_heartbeat("https://hc-ping.example/abc") is False diff --git a/tests/test_store.py b/tests/test_store.py index fe43b18..d49e7c4 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -571,6 +571,24 @@ def test_get_cached_route_respects_max_age(tmp_db_path: Path) -> None: assert store.get_cached_route("k", "d", "transit", max_age_days=0) is None +# ── Job heartbeat tests ───────────────────────────────────────────────────────── + +def test_job_heartbeat_round_trip(tmp_db_path: Path) -> None: + """record_job_success / get_job_heartbeat round-trip; latest wins.""" + store = Store(tmp_db_path) + store.init_schema() + assert store.get_job_heartbeat("poll") is None + + t1 = datetime(2026, 5, 8, 6, 0, tzinfo=timezone.utc) + t2 = datetime(2026, 5, 8, 6, 1, tzinfo=timezone.utc) + store.record_job_success("poll", t1) + assert store.get_job_heartbeat("poll") == t1 + store.record_job_success("poll", t2) + assert store.get_job_heartbeat("poll") == t2 + # Distinct jobs are tracked independently. + assert store.get_job_heartbeat("morning") is None + + # ── Alert ledger tests ───────────────────────────────────────────────────────── def test_mark_alert_seen_and_is_alert_seen(tmp_db_path: Path) -> None: From db2ca79db93c3af72631a788d3ff6366ac266a25 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:32:40 -0400 Subject: [PATCH 07/15] fix(venues): replace char-bag fuzzy match with edit-distance + digit guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fuzzy matcher built set(collapsed_input) — a set of individual characters — so it compared character bags: anagrams matched perfectly and 'studio100' vs 'studio200' (different rooms) nearly matched. Use rapidfuzz edit-distance on the collapsed strings (order-sensitive), gated on digit runs matching exactly so room/studio numbers can't collide. --- src/commutecompass/mta.py | 25 ++++++++++++------ src/commutecompass/venues.py | 42 ++++++++++++++++++------------ tests/test_mta.py | 50 +++++++++++++++++++++++++++++++++--- tests/test_venues.py | 22 ++++++++++++++++ 4 files changed, 111 insertions(+), 28 deletions(-) diff --git a/src/commutecompass/mta.py b/src/commutecompass/mta.py index add9fd0..06e9c89 100644 --- a/src/commutecompass/mta.py +++ b/src/commutecompass/mta.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import logging import re from datetime import datetime @@ -126,14 +127,14 @@ def _fetch_feed(url: str, system: str, client: httpx.Client) -> list[Alert]: alerts: list[Alert] = [] for entity in feed.entity: if entity.HasField("alert"): - parsed = _parse_alert(entity.alert, system) + parsed = _parse_alert(entity.alert, system, entity_id=entity.id) if parsed: alerts.append(parsed) return alerts -def _parse_alert(gtfs_alert: GtfsAlert, system: str) -> Optional[Alert]: +def _parse_alert(gtfs_alert: GtfsAlert, system: str, *, entity_id: str = "") -> Optional[Alert]: """Map a GTFS-RT Alert proto into our Alert model.""" if not gtfs_alert.informed_entity: return None @@ -186,12 +187,20 @@ def _parse_alert(gtfs_alert: GtfsAlert, system: str) -> Optional[Alert]: if translations: url = translations[0].text if translations[0].text else None - # Generate stable alert id from affected routes + start time of first period - first_period = active_periods[0] if active_periods else (None, None) - id_base = f"{system}:{','.join(sorted(affected_routes)) if affected_routes else 'unknown'}" - if first_period[0]: - id_base += f":{first_period[0].strftime('%Y%m%d%H%M')}" - alert_id = id_base[:128] + # Prefer the feed's own entity id — it is the canonical stable identifier. + # Fall back to a derived id (routes + first-period start) only when the feed + # omits it, and disambiguate that fallback with a short hash of the alert + # text so two distinct alerts sharing routes + start minute don't collide + # into one ledger entry (which would suppress the second). + if entity_id: + alert_id = f"{system}:{entity_id}"[:128] + else: + first_period = active_periods[0] if active_periods else (None, None) + id_base = f"{system}:{','.join(sorted(affected_routes)) if affected_routes else 'unknown'}" + if first_period[0]: + id_base += f":{first_period[0].strftime('%Y%m%d%H%M')}" + text_digest = hashlib.sha1(f"{header}\n{description}".encode()).hexdigest()[:8] + alert_id = f"{id_base}:{text_digest}"[:128] return Alert( id=alert_id, diff --git a/src/commutecompass/venues.py b/src/commutecompass/venues.py index 9b72da5..f9c00f6 100644 --- a/src/commutecompass/venues.py +++ b/src/commutecompass/venues.py @@ -23,15 +23,18 @@ def _normalize(s: str) -> str: return s.strip() -def _jaccard(tokens_a: set[str], tokens_b: set[str]) -> float: - """Compute Jaccard similarity between two token sets.""" - if not tokens_a and not tokens_b: - return 1.0 - intersection = len(tokens_a & tokens_b) - union = len(tokens_a | tokens_b) - if union == 0: - return 0.0 - return intersection / union +# rapidfuzz ratios run 0-100; require a strong overlap before claiming a match. +_FUZZY_THRESHOLD = 85.0 + + +def _digit_runs(s: str) -> set[str]: + """Extract digit sequences (e.g. room/studio numbers) from a string. + + Numbers carry meaning that edit-distance smears over: 'studio 100' and + 'studio 200' are 90% similar as strings but are different rooms. Requiring + digit runs to match exactly before a fuzzy comparison keeps those apart. + """ + return set(re.findall(r"\d+", s)) class VenueEntry(BaseModel): @@ -77,12 +80,17 @@ def match(self, raw: str) -> Optional[ResolvedLocation]: Matching strategy: 1. Normalize input 2. Exact alias match (normalized) → return resolution - 3. Fuzzy token-overlap match (Jaccard >= 0.85 on whitespace-collapsed alias vs input) → return resolution + 3. Fuzzy match: edit-distance ratio >= threshold on the whitespace- + collapsed strings (so "Studio 100" and "Studio100" match), but only + when both sides have the *same* digit runs — so different room + numbers ("Studio 100" vs "Studio 200") never collide. 4. Otherwise None """ if not raw: return None + from rapidfuzz import fuzz + norm = _normalize(raw) # Step 1: exact match @@ -90,15 +98,15 @@ def match(self, raw: str) -> Optional[ResolvedLocation]: idx = self._exact[norm] return self.entries[idx].resolves_to - # Step 2: fuzzy — compare whitespace-collapsed input against stored collapsed aliases + # Step 2: fuzzy — edit-distance over whitespace-collapsed strings, gated + # on matching digit runs. Unlike the previous character-set Jaccard this + # respects order (anagrams no longer match) and room numbers. collapsed_input = re.sub(r"\s+", "", norm) + input_digits = _digit_runs(collapsed_input) for stored_collapsed, idx in self._fuzzy: - # Jaccard over character bigrams (or fallback to simple overlap ratio) - # Simple ratio: number of shared tokens / total tokens - # Build token sets by splitting on whitespace after collapsing - input_tokens = set(collapsed_input) - stored_tokens = set(stored_collapsed) - if _jaccard(input_tokens, stored_tokens) >= 0.85: + if _digit_runs(stored_collapsed) != input_digits: + continue + if fuzz.ratio(collapsed_input, stored_collapsed) >= _FUZZY_THRESHOLD: return self.entries[idx].resolves_to return None diff --git a/tests/test_mta.py b/tests/test_mta.py index b8c456a..7705d83 100644 --- a/tests/test_mta.py +++ b/tests/test_mta.py @@ -129,9 +129,11 @@ def test_parses_valid_protobuf_fixture(self) -> None: # Subway feed alerts are first (system = "MTA Subway") subway_alerts = [a for a in alerts if a.id.startswith("MTA Subway")] assert len(subway_alerts) == 2, f"Expected 2 subway alerts, got {len(subway_alerts)}: {subway_alerts}" - ids = {a.id for a in subway_alerts} - assert any("C" in id_ for id_ in ids) - assert any("A" in id_ for id_ in ids) + # The two subway alerts cover the C and A lines (check the structured + # field, not the id string, which now derives from the feed entity id). + all_routes = set().union(*(a.affected_routes for a in subway_alerts)) + assert "C" in all_routes + assert "A" in all_routes def test_filters_entities_without_alerts(self) -> None: """Feed entities without alert payload are ignored.""" @@ -170,6 +172,48 @@ def test_filters_entities_without_alerts(self) -> None: assert alerts == [] + def test_distinct_alerts_same_route_and_time_get_distinct_ids(self) -> None: + """Two different alerts on the same route/time must not collapse to one id. + + Exercises the derived-id fallback (feed omits entity ids): the alert text + is hashed into the id so the ledger doesn't suppress the second alert. + """ + from google.transit.gtfs_realtime_pb2 import FeedEntity, FeedMessage + + start = int(time.time()) + + def _make_entity(header: str) -> FeedEntity: + ent = FeedEntity() + ent.id = "" # force the derived-id fallback + informed = ent.alert.informed_entity.add() + informed.route_id = "C" + period = ent.alert.active_period.add() + period.start = start + tr = ent.alert.header_text.translation.add() + tr.text = header + return ent + + feed = FeedMessage() + feed.header.gtfs_realtime_version = "2.0" + feed.header.timestamp = start + feed.entity.append(_make_entity("Signal problems at Jay St")) + feed.entity.append(_make_entity("Sick passenger at Hoyt St")) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = feed.SerializeToString() + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=None) + + with patch("commutecompass.mta.httpx.Client", MagicMock(return_value=mock_client)): + alerts = fetch_alerts("u", "", "", client=mock_client) + + subway = [a for a in alerts if a.id.startswith("MTA Subway")] + assert len(subway) == 2 + assert subway[0].id != subway[1].id + def test_url_construction_with_empty_strings(self) -> None: """Empty strings fall back to canonical MTA URLs.""" with patch("commutecompass.mta.httpx.Client") as mock_client_cls: diff --git a/tests/test_venues.py b/tests/test_venues.py index 72d87c7..b34e8fc 100644 --- a/tests/test_venues.py +++ b/tests/test_venues.py @@ -71,6 +71,28 @@ def test_fuzzy_match_theater() -> None: assert result.kind == "station" +def test_fuzzy_match_collapsed_whitespace_variant() -> None: + """'Studio100' (no space) fuzzy-matches the 'Studio 100' alias.""" + registry = VenueRegistry.load(FIXTURE_PATH) + result = registry.match("Studio100") + assert result is not None + assert result.value == "200 Example St, New York, NY 10001" + + +def test_fuzzy_does_not_match_different_room_number() -> None: + """'Studio 200' must NOT match 'Studio 100' — different rooms (regression).""" + registry = VenueRegistry.load(FIXTURE_PATH) + assert registry.match("Studio 200") is None + assert registry.match("Studio200") is None + + +def test_fuzzy_does_not_match_anagram() -> None: + """A character anagram must not match (the old char-set Jaccard bug).""" + registry = VenueRegistry.load(FIXTURE_PATH) + # "loohcs elpmaxe" has the same characters as "example school" reversed. + assert registry.match("loohcs elpmaxe") is None + + def test_no_match_returns_none() -> None: """Unknown venue returns None.""" registry = VenueRegistry.load(FIXTURE_PATH) From 895d2ecc4255b98e81f421f70295ec678040425b Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:37:38 -0400 Subject: [PATCH 08/15] test(routing): lock in transfer counting across walking transfers Documents that transit->walk->transit counts as one transfer (the WALKING branch intentionally does not reset the prev-transit flag); a reported bug here was a misread of the branch. --- tests/test_routing.py | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_routing.py b/tests/test_routing.py index 74926b7..15fa42d 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -20,6 +20,58 @@ from commutecompass.timeutil import NYC_TZ +# ─── Transfer counting ───────────────────────────────────────────────────────── + +def _transit_step(line: str, dur: int = 1500) -> dict[str, Any]: + return { + "travel_mode": "TRANSIT", + "duration": {"value": dur}, + "transit_details": { + "line": {"short_name": line, "vehicle": {"type": "SUBWAY"}}, + "departure_stop": {"name": "A"}, + "arrival_stop": {"name": "B"}, + }, + } + + +def _walk_step(dur: int = 300) -> dict[str, Any]: + return {"travel_mode": "WALKING", "duration": {"value": dur}} + + +def _route_from_steps(steps: list[dict[str, Any]]) -> Route: + resp = { + "status": "OK", + "routes": [ + { + "legs": [ + { + "departure_time": {"value": 1000}, + "arrival_time": {"value": 5000}, + "duration": {"value": 4000}, + "steps": steps, + } + ] + } + ], + } + route = _parse_route(resp) + assert route is not None + return route + + +def test_transfer_count_includes_walking_transfer() -> None: + """A walk between two trains is still one transfer, not zero.""" + route = _route_from_steps( + [_walk_step(), _transit_step("A"), _walk_step(), _transit_step("C"), _walk_step()] + ) + assert route.transfers == 1 + + +def test_transfer_count_single_train_is_zero() -> None: + route = _route_from_steps([_walk_step(), _transit_step("A"), _walk_step()]) + assert route.transfers == 0 + + # ─── Fallback estimate ───────────────────────────────────────────────────────── def test_route_cache_key_rounds_coordinates() -> None: From 654dab0a14e895db629eab4302a99a96d79cd524 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:39:42 -0400 Subject: [PATCH 09/15] fix(mta): match alert routes on whole line codes, not substrings Substring matching made affected route '1' match bus lines 'B41'/'M15' and any line containing the digit. Match case-insensitively against the leg line and its alphanumeric tokens instead, so 'C' still matches a decorated 'C-local' while '1' no longer matches 'B41'. --- src/commutecompass/mta.py | 26 ++++++++++++++------------ tests/test_mta.py | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/commutecompass/mta.py b/src/commutecompass/mta.py index 06e9c89..5a5c9fa 100644 --- a/src/commutecompass/mta.py +++ b/src/commutecompass/mta.py @@ -521,24 +521,26 @@ def _systems_lines_overlap(alert: Alert, route: Route) -> bool: def _line_matches(alert: Alert, leg: TransitLeg) -> bool: - """Check if a transit leg's line/route matches alert's affected_routes.""" + """Check if a transit leg's line/route matches alert's affected_routes. + + Matches on whole line designators (case-insensitive), not substrings: bare + substring matching made affected route "1" match leg lines "B41", "M15", or + a hypothetical "10". A decorated line like "C-local" still matches affected + route "C" because we also compare against the line's alphanumeric tokens. + """ if not alert.affected_routes: # No specific routes means whole system is affected return True - if leg.line: - # Direct line match - if leg.line in alert.affected_routes: - return True + if not leg.line: + return False - # Also check if any affected route is a substring of the line (route IDs - # sometimes have prefixes like "ABC" for the C line) - if leg.line: - for affected in alert.affected_routes: - if affected in leg.line: - return True + line = leg.line.upper().strip() + line_tokens = {tok for tok in re.split(r"[^A-Z0-9]+", line) if tok} + line_tokens.add(line) # also match multi-word lines as a whole - return False + affected = {route.upper().strip() for route in alert.affected_routes} + return bool(affected & line_tokens) def _time_overlaps(alert: Alert, route: Route, at_time: datetime) -> bool: diff --git a/tests/test_mta.py b/tests/test_mta.py index 7705d83..da0a75a 100644 --- a/tests/test_mta.py +++ b/tests/test_mta.py @@ -635,6 +635,28 @@ def test_line_substring_match(self) -> None: # "C" does not contain "ABC" and "ABC" does not contain "C" — no match assert _systems_lines_overlap(alert, route) is False + def test_line_no_substring_overmatch(self) -> None: + """Affected route '1' must NOT match bus line 'B41' (substring regression).""" + now = make_aware(datetime.now(NYC_TZ)) + alert = alert_with_period( + "x", {"1"}, {"MTA Bus"}, + now - timedelta(hours=1), now + timedelta(hours=1), + ) + leg = subway_leg("B41", system="MTA Bus") + route = sample_route([leg]) + assert _systems_lines_overlap(alert, route) is False + + def test_line_matches_decorated_token(self) -> None: + """Affected route 'C' matches a decorated line like 'C-local'.""" + now = make_aware(datetime.now(NYC_TZ)) + alert = alert_with_period( + "x", {"C"}, {"MTA Subway"}, + now - timedelta(hours=1), now + timedelta(hours=1), + ) + leg = subway_leg("C-local") + route = sample_route([leg]) + assert _systems_lines_overlap(alert, route) is True + def test_wildcard_affected_routes(self) -> None: """Wildcard in affected_routes matches any line in system.""" now = make_aware(datetime.now(NYC_TZ)) From 24715dac165f7dfa862990f996775c88cd22eb5c Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:42:33 -0400 Subject: [PATCH 10/15] fix(mta): carry structured stop names instead of re-parsing the summary _build_route_context recovered boarding/alighting stops by splitting the human '{line} from {dep} to {arr}' summary on ' from '/' to '/' and ', which shredded stop names that themselves contain those words. Add departure_stop/arrival_stop fields to TransitLeg (populated in routing), and read them directly. --- src/commutecompass/models.py | 5 +++++ src/commutecompass/mta.py | 21 +++++++++++---------- src/commutecompass/routing.py | 6 ++++++ tests/test_mta.py | 20 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/commutecompass/models.py b/src/commutecompass/models.py index 6f29212..f1f4bd7 100644 --- a/src/commutecompass/models.py +++ b/src/commutecompass/models.py @@ -197,6 +197,11 @@ class TransitLeg(BaseModel): arrive_at: datetime duration_seconds: int summary: str + # Structured boarding/alighting stop names. Kept separately from ``summary`` + # so consumers (e.g. MTA alert relevance) don't have to re-parse the + # human-readable string, which breaks on stop names containing "to"/"and". + departure_stop: Optional[str] = None + arrival_stop: Optional[str] = None class Route(BaseModel): diff --git a/src/commutecompass/mta.py b/src/commutecompass/mta.py index 5a5c9fa..312a644 100644 --- a/src/commutecompass/mta.py +++ b/src/commutecompass/mta.py @@ -370,16 +370,17 @@ def _build_route_context(route: Route) -> tuple[set[str], set[str]]: line_ids.add(leg.line.lower().strip()) if leg.headsign: stop_names.add(leg.headsign.lower().strip()) - # Extract origin/destination stop names from summary (e.g. "C from A to B") - if leg.summary: - parts = leg.summary.split(" from ") - if len(parts) >= 2: - # left side is the line; right side is "A to B" - right = parts[1] - for stop in right.replace(" to ", " ").replace(" and ", " ").split(): - stop = stop.strip(",. ") - if stop and stop not in ("to", "and"): - stop_names.add(stop.lower()) + # Use the structured boarding/alighting stops rather than re-parsing the + # human summary (which breaks on stop names containing "to"/"and"). Add + # both the whole name and its word tokens for keyword matching. + for stop in (leg.departure_stop, leg.arrival_stop): + if not stop: + continue + normalized = stop.lower().strip() + stop_names.add(normalized) + for word in re.split(r"[^\w]+", normalized): + if word: + stop_names.add(word) return stop_names, line_ids diff --git a/src/commutecompass/routing.py b/src/commutecompass/routing.py index 04fb05c..2062db0 100644 --- a/src/commutecompass/routing.py +++ b/src/commutecompass/routing.py @@ -129,6 +129,8 @@ def _parse_step(step: dict[str, Any], nyc_tz: Any) -> Optional[TransitLeg]: system: Optional[str] = None line: Optional[str] = None headsign: Optional[str] = None + departure_stop_name: Optional[str] = None + arrival_stop_name: Optional[str] = None summary = "" if mode == "TRANSIT": @@ -172,6 +174,8 @@ def _parse_step(step: dict[str, Any], nyc_tz: Any) -> Optional[TransitLeg]: dep_name = departure_stop.get("name", "Unknown") arr_name = arrival_stop.get("name", "Unknown") + departure_stop_name = dep_name if dep_name != "Unknown" else None + arrival_stop_name = arr_name if arr_name != "Unknown" else None summary = f"{line or 'Transit'} from {dep_name} to {arr_name}" elif mode == "WALKING": html_inst = step.get("html_instructions", "") @@ -194,6 +198,8 @@ def _parse_step(step: dict[str, Any], nyc_tz: Any) -> Optional[TransitLeg]: arrive_at=arrive_at, duration_seconds=duration_sec, summary=summary, + departure_stop=departure_stop_name, + arrival_stop=arrival_stop_name, ) diff --git a/tests/test_mta.py b/tests/test_mta.py index da0a75a..8afa3fb 100644 --- a/tests/test_mta.py +++ b/tests/test_mta.py @@ -792,13 +792,33 @@ def test_extracts_line_ids_and_stops(self) -> None: mode="TRANSIT", system="MTA Subway", line="C", headsign="Fulton St", depart_at=now, arrive_at=now + timedelta(minutes=30), duration_seconds=1800, summary="C from Jay St-MetroTech to Fulton St", + departure_stop="Jay St-MetroTech", arrival_stop="Fulton St", ), ] route = sample_route(legs) stop_names, line_ids = _build_route_context(route) assert "fulton st" in stop_names + assert "jay st-metrotech" in stop_names # whole structured stop name + assert "metrotech" in stop_names # word token assert "c" in line_ids + def test_stops_with_to_and_and_in_name_are_intact(self) -> None: + """Structured stops avoid the summary round-trip that split on to/and.""" + now = make_aware(datetime.now(NYC_TZ)) + legs = [ + TransitLeg( + mode="TRANSIT", system="LIRR", line="Babylon", + depart_at=now, arrive_at=now + timedelta(minutes=40), + duration_seconds=2400, summary="Babylon from Atlantic Terminal to Wantagh", + departure_stop="Atlantic Terminal", + arrival_stop="Forest Hills and Kew Gardens", + ), + ] + stop_names, _ = _build_route_context(sample_route(legs)) + # The whole name survives intact rather than being shredded on " and ". + assert "forest hills and kew gardens" in stop_names + assert "atlantic terminal" in stop_names + def test_empty_route(self) -> None: route = Route(legs=[], depart_at=make_aware(datetime.now(NYC_TZ)), arrive_at=make_aware(datetime.now(NYC_TZ)), total_duration_seconds=0) From 672c4b42eeef34881d5c6476fa10ff5c551a1545 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:47:34 -0400 Subject: [PATCH 11/15] chore(cli): dedup LLM client, warn on inert scheduling keys, english strings - morning built OpencodeGoClient twice; reuse the planning client for alerts. - config set scheduling.{morning_run_time,poll_interval_seconds} is inert under systemd/cron (an external scheduler drives the jobs); warn so a chat user isn't misled into thinking it changed the schedule. - replace the lone Chinese oauth-success string with English. --- src/commutecompass/cli.py | 14 ++++++++++++-- src/commutecompass/config.py | 9 +++++++++ src/commutecompass/jobs/morning.py | 10 ++-------- tests/test_cli.py | 12 ++++++++++++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/commutecompass/cli.py b/src/commutecompass/cli.py index ca36674..31276df 100644 --- a/src/commutecompass/cli.py +++ b/src/commutecompass/cli.py @@ -135,7 +135,7 @@ def oauth(ctx: click.Context) -> None: token_path=token_path, ) client.authorize_interactive() - click.echo("OAuth授权完成。Token已保存。") + click.echo("OAuth authorization complete. Token saved.") # ─────────── init-db ────────────────────────────────────────────────────────── @@ -740,7 +740,11 @@ def config_show(ctx: click.Context, as_json: bool) -> None: @click.pass_context def config_set(ctx: click.Context, key: str, value: str) -> None: """Set an allowlisted config field. KEY uses dotted form (e.g. prep.prep_minutes).""" - from commutecompass.config import ConfigSetError, update_config_field + from commutecompass.config import ( + EXTERNALLY_SCHEDULED_KEYS, + ConfigSetError, + update_config_field, + ) config_path: Path = ctx.obj["config_path"] try: @@ -752,6 +756,12 @@ def config_set(ctx: click.Context, key: str, value: str) -> None: click.echo(f"Error writing {config_path}: {exc}", err=True) sys.exit(EXIT_CONFIG) click.echo(f"{key} = {coerced!r}") + if key in EXTERNALLY_SCHEDULED_KEYS: + click.echo( + f"Note: {key} is not read at runtime — the schedule is set by your " + "systemd timer or cron entry. Update that to change when the job runs.", + err=True, + ) @config.command(name="unset") diff --git a/src/commutecompass/config.py b/src/commutecompass/config.py index 4116f9a..3f1d94f 100644 --- a/src/commutecompass/config.py +++ b/src/commutecompass/config.py @@ -361,6 +361,15 @@ def _coerce_bool(value: str) -> bool: } +# Keys that the app itself never reads at runtime — an external scheduler +# (systemd timer, cron) drives when `morning`/`poll` run, so editing these in +# TOML records intent but does NOT change the schedule. The CLI warns when one +# is set so a chat user isn't misled into thinking it took effect. +EXTERNALLY_SCHEDULED_KEYS: frozenset[str] = frozenset( + {"scheduling.morning_run_time", "scheduling.poll_interval_seconds"} +) + + class ConfigSetError(Exception): """Raised by ``update_config_field`` for an invalid key or value.""" diff --git a/src/commutecompass/jobs/morning.py b/src/commutecompass/jobs/morning.py index ab4ee44..2f4230d 100644 --- a/src/commutecompass/jobs/morning.py +++ b/src/commutecompass/jobs/morning.py @@ -201,16 +201,10 @@ def run(config: Config) -> None: # noqa: C901 except Exception as exc: logger.warning("Failed to fetch MTA alerts: %s", exc) - # Filter to those affecting today's planned routes - from commutecompass.llm import OpencodeGoClient + # Filter to those affecting today's planned routes. Reuse the llm_client + # built above for planning rather than constructing a second identical one. from commutecompass.mta import select_actionable_alerts - llm_client = OpencodeGoClient( - endpoint=config.opencode_go.endpoint, - token=config.opencode_go_token, - model=config.opencode_go.model, - ) - affecting_alerts: list[Alert] = [] for plan in plans: if plan.route and plan.leave_at: diff --git a/tests/test_cli.py b/tests/test_cli.py index 65eae68..840e08a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -487,6 +487,18 @@ def test_set_disallowed_key_exits_nonzero( # Error message should name the allowlist assert "prep.prep_minutes" in result.output + def test_set_externally_scheduled_key_warns( + self, runner: CliRunner, tmp_path: Path + ) -> None: + """Setting an inert scheduling key still writes it but warns it does nothing.""" + p = self._toml_with_prep(tmp_path) + result = runner.invoke( + cli, ["--config", str(p), "config", "set", "scheduling.poll_interval_seconds", "30"] + ) + assert result.exit_code == 0, result.output + assert "poll_interval_seconds = 30" in p.read_text() + assert "not read at runtime" in result.output + # ─────────── adjust idempotency ──────────────────────────────────────────────── From 8539900e0776aafcc3e6121650437642ef0ef6e5 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:48:07 -0400 Subject: [PATCH 12/15] docs: fix broken plan.md link, refresh command list, single-source version README linked to a nonexistent plan.md (now AGENTS.md) and omitted status, snooze/mute/unmute/undo, geocode-cache, mta-alerts, and config unset/reset. package.nix now reads version from pyproject.toml so it can't drift. --- README.md | 8 ++++++-- nix/package.nix | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1dd090d..99646df 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,18 @@ A self-hosted [Python](https://www.python.org/) service that pulls events from G - [`tomorrow`](./src/commutecompass/jobs/) — push tomorrow's earliest prep time to the configured HA script (pull-model alarm) - [`plan`](./src/commutecompass/planner.py) — replan a single event (debug) - [`digest-preview`](./src/commutecompass/cli.py) — print today's digest from cache without sending +- [`status`](./src/commutecompass/cli.py) — show next event, pending pings, and job heartbeats (`--json` for machine output) - [`adjust EVENT_ID --add-prep N`](./src/commutecompass/cli.py) — shift a plan's prep time by N minutes -- [`config show`](./src/commutecompass/cli.py) / [`config set KEY VALUE`](./src/commutecompass/cli.py) — view or edit allowlisted config fields +- [`snooze`](./src/commutecompass/cli.py) / [`mute`](./src/commutecompass/cli.py) / [`unmute`](./src/commutecompass/cli.py) / [`undo`](./src/commutecompass/cli.py) — adjust or suppress an event's pings +- [`config show`](./src/commutecompass/cli.py) / [`config set KEY VALUE`](./src/commutecompass/cli.py) / [`config unset KEY`](./src/commutecompass/cli.py) / [`config reset`](./src/commutecompass/cli.py) — view or edit allowlisted config fields +- [`geocode-cache`](./src/commutecompass/cli.py) — inspect or clear the geocode cache +- [`mta-alerts`](./src/commutecompass/cli.py) — print current MTA alerts - [`test-notify`](./src/commutecompass/notify.py) — emit a test message via the configured notifier - [`where`](./src/commutecompass/cli.py) — print the latest stored current location ## Configuration -See [`examples/config.toml`](./examples/) and [`examples/env.example`](./examples/) for the full configuration schema. Architecture and implementation notes live in [`plan.md`](./plan.md). +See [`examples/config.toml`](./examples/) and [`examples/env.example`](./examples/) for the full configuration schema. Architecture and implementation notes live in [`AGENTS.md`](./AGENTS.md). ## OpenClaw integration diff --git a/nix/package.nix b/nix/package.nix index aa93a7e..1ba3860 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -2,7 +2,9 @@ python3Packages.buildPythonApplication rec { pname = "commutecompass"; - version = "0.1.0"; + # Single source of truth: read the version from pyproject.toml so it never + # drifts from the Python package metadata. + version = (lib.importTOML ../pyproject.toml).project.version; format = "pyproject"; src = ./..; From d1918fd11d6e7301a5f401135e7522a4bf2be606 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:50:22 -0400 Subject: [PATCH 13/15] chore(store,ci): stamp schema version; type-check tests in CI Add a SCHEMA_VERSION stamped into PRAGMA user_version so future migrations have a version to branch on instead of probing every table (existing idempotent column-adds are unchanged). Extend CI's mypy step to cover tests/ too. --- .github/workflows/ci.yml | 2 +- src/commutecompass/store.py | 16 ++++++++++++++++ tests/test_store.py | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7e59f1..3a0dbe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: run: nix develop . --command ruff check . - name: Type check - run: nix develop . --command mypy src + run: nix develop . --command mypy src tests build: name: check, build, push diff --git a/src/commutecompass/store.py b/src/commutecompass/store.py index 4729dfe..9e29fa3 100644 --- a/src/commutecompass/store.py +++ b/src/commutecompass/store.py @@ -52,6 +52,13 @@ def _json_serializer(obj: object) -> str: raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable") +# Bump when the on-disk schema changes. Stored in SQLite's built-in +# ``PRAGMA user_version`` so future migrations can branch on the version the DB +# was last initialised at, instead of probing every table with PRAGMA +# table_info. The existing column adds remain idempotent and run unconditionally. +SCHEMA_VERSION = 1 + + class Store: """SQLite store for plans, pings, geocode cache, and alert ledger.""" @@ -183,6 +190,15 @@ def init_schema(self) -> None: conn.execute( "ALTER TABLE pings ADD COLUMN send_attempts INTEGER NOT NULL DEFAULT 0" ) + # Stamp the schema version last, once all tables/columns exist. + # PRAGMA can't be parameterised, but SCHEMA_VERSION is a trusted int. + conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)}") + + def schema_version(self) -> int: + """Return the schema version stamped in the database header.""" + with self._connect() as conn: + row = conn.execute("PRAGMA user_version").fetchone() + return int(row[0]) if row else 0 # ── Plan CRUD ────────────────────────────────────────────────────────────── diff --git a/tests/test_store.py b/tests/test_store.py index d49e7c4..68583fd 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -571,6 +571,21 @@ def test_get_cached_route_respects_max_age(tmp_db_path: Path) -> None: assert store.get_cached_route("k", "d", "transit", max_age_days=0) is None +# ── Schema version tests ──────────────────────────────────────────────────────── + +def test_schema_version_stamped(tmp_db_path: Path) -> None: + """init_schema stamps the current schema version in PRAGMA user_version.""" + from commutecompass.store import SCHEMA_VERSION + + store = Store(tmp_db_path) + assert store.schema_version() == 0 # fresh db, before init + store.init_schema() + assert store.schema_version() == SCHEMA_VERSION + # Idempotent re-init keeps the version stable. + store.init_schema() + assert store.schema_version() == SCHEMA_VERSION + + # ── Job heartbeat tests ───────────────────────────────────────────────────────── def test_job_heartbeat_round_trip(tmp_db_path: Path) -> None: From 412900eeff7fab2ae68c952e9571c03351f0df08 Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 02:52:55 -0400 Subject: [PATCH 14/15] ci: add coverage gate (pytest-cov, 80% floor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add pytest-cov to the dev shell and enforce a branch-coverage floor of 80% in CI (current coverage ~84%). The floor catches large regressions without tripping on normal PRs. A NixOS-module integration test for the systemd timers remains a follow-up — it needs CI/VM iteration to land safely. --- .github/workflows/ci.yml | 2 +- flake.nix | 1 + pyproject.toml | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a0dbe2..f90a74c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: accept-flake-config = true - name: Run tests - run: nix develop . --command pytest -q + run: nix develop . --command pytest -q --cov=commutecompass --cov-report=term-missing --cov-fail-under=80 - name: Lint run: nix develop . --command ruff check . diff --git a/flake.nix b/flake.nix index 7f75f07..f9689ef 100644 --- a/flake.nix +++ b/flake.nix @@ -23,6 +23,7 @@ ps: with ps; [ pip pytest + pytest-cov pydantic click pyyaml diff --git a/pyproject.toml b/pyproject.toml index 3dad375..3c36d02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,3 +38,11 @@ mypy_path = ["src"] [tool.pytest.ini_options] pythonpath = ["src"] + +[tool.coverage.run] +source = ["commutecompass"] +branch = true + +[tool.coverage.report] +# CI enforces a floor via --cov-fail-under; current coverage is ~87%. +show_missing = true From 593aae23a3e66633400f29c69d69f24671fe1b7a Mon Sep 17 00:00:00 2001 From: Multipixelone <5051116+Multipixelone@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:13:03 -0400 Subject: [PATCH 15/15] feat(weather): pad departure buffer when rain/snow is forecast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an optional [weather] block (Open-Meteo, keyless): when precipitation is likely around the commute, extra minutes are folded into the buffer so the alarm fires earlier, and the digest shows a per-event note ('+N min for rain'). Weather failures are swallowed — a forecast blip never breaks a plan. Disabled by default. --- .coverage | Bin 0 -> 217088 bytes examples/config.toml | 8 +++ src/commutecompass/config.py | 25 +++++++ src/commutecompass/format.py | 6 ++ src/commutecompass/models.py | 5 ++ src/commutecompass/planner.py | 13 +++- src/commutecompass/weather.py | 118 ++++++++++++++++++++++++++++++++++ tests/test_format.py | 22 +++++++ tests/test_planner.py | 44 +++++++++++++ tests/test_weather.py | 79 +++++++++++++++++++++++ 10 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 .coverage create mode 100644 src/commutecompass/weather.py create mode 100644 tests/test_weather.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..3dfa2b253548c18fc639c271153aacd8cfe00ec3 GIT binary patch literal 217088 zcmeF42b2`m*0$@MQ&nBPJAr^hW(X1_2_jLFBEd)y#E66eMwl5S2`XskHj6o3bIv(? zRm?f(oO8|!&j0SIa}Is)_uYT}Oa8UKb??`#d&l?buCDGr-F@CZ@7`5CZOX2-%WJCV zH!Q82y}Wu`r{zV{M1Ng5f)Bj3Foqosto=DAb;{Hr0bwHwR zs&9Nj{JYpW@uAV_vD!$VXg^$0`Bxc%$_P|OpfUpgF(Z(k5|321X(KXgmd~EExMum% z*>h`_hJO<~PTgtjo;y|VId+F#cd8CAtL{nF_}8~@_1NmA4Tn@OsaaY*zjkp=b#48; z+PSlr*VZqnUcRu#Y`tPx%{=xjTn5+T4f{3Sp4F_{dAQZ`ng!U!lBKnEvzM-}UR1NX zPtyka-j>&_S{`12oz&DWsOK%Jd+M9_s$N<%zh-Go{oI;m;iKqTJFge@${s8*wNgErStgF&sjRVe(u7WWqqn=+h;hp0grRl^6=R% zZK&(NyrFt=ZGF?ztY22Uytbjfx@J|)+!f1f=KZ%%un!NK`G>(Pe>T|Hf&7FH3~#CL zt88>O-T1$`rQTfMcs?FneS;a+O*iDb#*LdEbN!0CIT-STR@5!w7YDZp?|6B`zq;kW zy6y4?{bc{2Uz`8twcPZ4|Ka5{L(Z>gc&iolwFj@Lu`hm~>P@%o)ATgz8~Rl9lfXM* ztC(Lktc7s!uB=(L?BK=t>NIQiiscPWJPR-WtO58F&ovJsRh>GC;=xUC6ds;gb843_ zvm*KrZyI~?zj*8L_5b>P!=I9We3S5}Xz}9ehWSk&8NPLO_~PK*T>hEqZw6!bQvRS- zH|P!VNdHIQZTw*I7^>$iZkY3r@3p3P%(5DMKrk1W&E~JDpUWSWW&9lGHms~!I(tD) zKYeXDE&NZvk?__T5cR8e>h!PPI`%CvYk-xMZsSL)+O-pzXw%zBzgqe~k$-p#nOE|^ znMTc3bv3h>typR%lKPG;mNcIceGQ&Y--ur|w%xyaI(oZV1GEUz5jRrRs+CBG zp9r3p`2XHNJPE#J(?{T+jNt$M;V+rJd|~x}ee8TO9(WJWuNu+nUp;Vs2(t$8Uq9DO zHa+B$Y8w4IO#1wP^$!o3FWK~vcd1$3cjfHGm`#?^Ti9 zmN&hB8Jpgo>^++vZ@7a^Y{nj2^=jH2AMeQx%a+x`R@t_e4L6ro_gFZ48P7t?dQ{J8 zXjoh`yFT;=++xx)+-L2w>ZZr=UtMYRrhP11&HGrevPaWnGUme5{GRyGna>wQUES>Z zdD^eohQqyL?&l9D-%mJ+;D*&b@S$9WkLW+`)9h4V%QKeQf4?Od6W(jx?0MnGonMr) zJsz^K=D+MNbfa;zm)9(>t*fc#O`0A|(;MrM*~_ZuF0H|p+Dn@r7)EU2KR>E)HxqG< znM1>8)2oO2zrA<(C7v~a|MDt1;IDG&P9jp(ySGTM(4XLNa+JJD1tNg2sKxG6f zBTyNE$_P|OpfUoL5vYtnWdtfCP#J;B2vkPkKW_w-kRrnCf0=sMNqvn!m4B5HsEj~m z1S%s?8G*_OR7RjO0+kV{j6h`sDkD%CfyxN{TaG|nstzgB7BZljQ0?029)KbJ1`O&q zfbgtj>Qg86Y3iqc%O_ZQ(3KIWj6h`sDkD%CfyxL}MxZhRl@X|nKxG6fBTyNE$_UsI zh^r35!~l|1yQoe9xc=WR80n;LP1UD5Bwt7#pB$gqn7B1jpV%t?cKqb{xY*CJ8)CJw zYI>DUrisztqSr*{M%xC@21VSg@~<)il@X|nKxG6fBTyNE|7{~Mv7PIrTlHVqP*>A` z`HK4bn#KLcHOyU6S5v=yS^o*O%W;5tT^&wdFt=ez|7A<(Zo;}HvzIOFKWkRq?ArQS zvv92EgtpjIi@&_5LvUEo?G~+c58)wb^6QuI;dgJGMuiWu3;&TMa4s# z)DpX^+PvK@X;{1%yV|h@b`|@}yIQ=s4m%mY1$NT>FYjb!P5p|RW!TSz=Gf0)e`79L zJR2Vie(lCL+pGsv->|%P{%Rhb)MkxN9S+N0vU=AfcGBT5k51EzkC(n)zm*c$+h)F% zX3eU_o3m*ym=wqE{`y;K;p|y+7uRA6!t>lIhFzup^7CAZ^VaZ~Pay2(ufGWv)HKX( zm{)@xjgMkSfBjvFvv=SuZT!4~&3azTFbnX*nGo5m=Y_Yy;)c14_<8x8^}KMFi21cV zRqp6vKY#tBkJlYLndo9Cf8CY%)B%{fc_!LJVNYHE^4n+b?8PR-!^ZXhcEKnobz3kh)iHTOazdgZ(LH`j;?MX_v0rFX^mjTX)eyZl zc4O?ISm)@x_&d>d!E=e*k}uM0LFs?n7oN)Vt&Bir1S%s?8G*_OR7SvYCb{WW;xB(c zDqH`zo9L!n{PjI;TK~7*yoXe_{%^B+4{6i-zxC!lq_XvY)h=$j&0im-e^~!-xx0(A zQ~l-F?w{BHt#)?PRhzjxz5Z`G&P~Vu`mVzDe~Srjy7^zq|GmwdZ_C#I?qoOJ^{>Bu{_FZ* zZQeN9^}oam*DCs#C-3qP8@|R=&i_~WE>{_W$_P|OpfUoL5vYtnWdtfCP#J;B2vkO( zG6Izm_>Ua{C6trk`oBnRaPYs%zsd+yMxZhRl@X|nKxG6fBTyNE$_P|OpfUoL5vYtn zWd!~MM?k4Wl#l;+n*9epxynWad|10Z%I~)~J8G*_OR7RjO0+kV{j6h`sDkD%CfyxL}MxZhRl@a)l903o{s(I=x zer|(Ozo&joeT`oNd_VPO>gCk4smD_frv9F~J#}O1>eQvF^TW;lBZr~#q$(p&8G*_O zR7RjO0+kV{j6h`sDkD%CfyxL}MxZhR|8pZS@KV0-{uk|ozkM$lj=#On>wv$#&Z)=W z?q}_ezunFljK5t@YmdLxC$Gfcj_YRPZ@Uvl<8Pbe`r~iaF)93QRa%a}EeZ$XZ}Z$_ z{B4%$C-I+F$)@%HcB$`D@1>qe-IKZ|b$05Q)aulN)b!L&sclo8Q>kQQ@|)ya$tRL` zCa(-P#|)u-|0&f%N>!oaUgp+N)8W;3S-~uUSOXox9qh`n9l0N#97Eap9q|V1B zTkD-PZ&;qZ0^Cz`v*AxAZUy(yx4`d4h^^z#;gYTNB}p+{jE;STOS)pfUoL5vYtnWdtfCP#J;B2vkO(G6IzmsEj~m1pdQEfRF$GNqyy{ zeocLo`Y82k>IroG-;laEb$aUP)SA?i)ZEmJ)TGq*sR60(sdlMmDKGguIsraRzLtDC z`9SjaB$-R=hCdVcRCwnKWldY1JXiWT=_$={u;)TSciF*<^ zCN4{ylUSE1Bo0d~Nz^3vN$imrml&4lm*|>kok%2P{FnHb@%Q2{$DfGb7r!NbW&FJO zDecBl|>ljf{$H z6X_CZ8S(vJ{m=b3{ipr={2Tp?{nPxCf2hCMKfs^t@8A#eyZfzu@-}#1d+&PBdk=cI zdY5}=dB=Kdy(QipZ%=Q$H`LqOYwsmJ$Nk>@(0$o`%)QIK#y!tn=Vsj%?gDpjcUO0e zJIL+nws)JkuKHDdrQTOBtH;&f)s5;>b(T6_Wzy`o$bzZX+5rOlnCB(?iE0#RrJ1<*uMZ$Thj6CN>OQMmS7YsQYg>as?w87cvJZI?- zXM^*srQe-DoM$Zk=KStFZRuC%H|Hr!zc{}-Pg?p}xXu%s$oa*2+|mz?t(?a!eee9> zJZkAX=X>W7OW!)*IS*U<#`)HH$kNx&H_n5WzH+{H9oO>+2?|k6gZRtJdedjJq?>g@}cUpSKdDpqa(%a4#&h2IN zj&qx(HyQ^xw_1AHdBeHI(o4?E&druybY609vh;%UqI09A=baav8!SENJnvj@=~?GF z=Q>NzIBz@GmeI4$HI|-i9P3D0 z&C>Oa)16Z-UFTfyoMP!(=Q`(POV>EpIwx7W+PTJAXXz^ECg;R5y4pFx(xr`yoZ~HB z;#}$+XX#?+66aV;7daO@$5^`1xyU)%(gn_i&QX@mcP?;Bmd*Ot+)?);aEwP0+Y@rG;ahV^>%>+Bs&qg`=FK zmsuz|M=iBbbV>(XC^*F>7V=J^!9vc-*IURsxjG9OXWim5$S!JfTvRZRIO!lG*B()8 zU%g8;a&oFY@p3{xoW!j!b?!50b zD|eY{A?SR?xm9Ajm_(ME3l+9)G?Z9US+p1rm= zvPa($R`wijWVd&R8QHblP$Ro^9b#nXE`yD%?y;Sfod+4&;iGMhY~Nv^k?pDnSlNCX zBU^vb-^i-g{fykQs;`l)+V!z=%iczA@x#_eHs7L`kIP$Xj=AY2+pQwlMPa+AWMcslK`Sq&Kd0GR-VBIO|dt z7CEOTElh{?TG-9GDQ;n`b8pPT0OuL9(8YN>YN3_$MPPxPpCbnT+#p=v!VeA&d=&Q$2-+K%3I?d?A3UCd%JmKz3seSUI(wa=efVR zU%BtOFJWzYk9&iAiF>AdoSVk#vew<-o#O7~4s-juo!wS$RBcq>;S2(w;_Ly>;>-cJ z*F`aFO8oOKRSMRygq&)5`%H^!SSB)w()rE&)B!I_hT={9!6Gh zW$c{T@kk1m#^%NLitQ2`9osh6Bi1g~EauT~^cB5FFVSOk58XhQ(3x}`rI8iX(*87s zcA{a_mpW4`ibgj^zl(kpeJ%P_^#15A(JP{Vi>{02qpPEJ(b>`I(Mi#f(E-t|qHUsy zpfUI^_#k*GcqF(ZxGMNta6*s{mIXDzUcsbbM9??r6f_T1_3t4P$}=zr&baX)oN9z z4nzVlP7OwTN?R4j`u|(`zI;(WEN{o^{~UQd+DVqmd06{Tlzb#g<=?;O2$%)?pn<}4 z8>wvvi8ki)fkQ-VtG5+ZMh_S$Oz)BE+efrAm-p!_T3Y>zXkm2k0b&cQ`-tX7_Zlaf z8QtSeVS1BP_Z}i?F5jxVNEqF%mxvqPrJINu-MNb(s~bhs=#HI5V07EhM8xRUZG~@i zRY&1j-CDRtx2O`z=;keiG`d-HA&gEo6OPe|q|<10JmLIlG&;aG8XYBPgV90M`NQZ) z;QVg1A8~#&+Vh=XjaHuXi`L$v#o|k&4_YL?FuL|2@ww3pYsF_qAFxV%YW4o&6QlP( zKzwZV4DpfC`_2#_8okfH;sc}i-bcJ|^vu1*dq(dyQ@m^RjQzzsR_`U=wtBjF%joF` zh&QdCA>J^0$LZpAqenh1UNd_8k>XXOhi@-lF?!f=@v_kacN8xf-RE`jqS3wkh!>3R z)muDoboYVcIist)i)W4QSS_A0x%6ry4zYm^j7iA>w4Cdvp~i8Qs0BSZ8#%uHrt($G$qB6Mn^~-VYC+!YmLTdXN~^sxJn$ZKRa%tILvJEO$%|T(Vw>#tBwAk zlUQZ+TZ6?RM&CPCgfRpbFV1ZzixoN}QAnJ!V!0(GP-0mbmBdm@H#s+pgDu_QoF~Gn})@opX$sZs|m4o!HaT3C@XP znx*5N6U0|zO7mDt%* z9`CG4mU2!}Oe~|km|!XE+$P4CQBLe+DUH31vvj1B7CY)UwOU#yb};(jIx*JhC3Rwq z(G7KIkLSfY-uQJ(#VD%}79*`*BDS}>L5#5a9WmVKMHh)-MlV<-h8jJ8ff!iJ?jqvzF#K}H`?C$_bEe=*Q#d<7a{G`_lQWAvW4i2g=T+f(#2ddf7>*XYSpL?5Gf zpDcPCy~_--wb7IA7QKv~I7#$0dcs7}!|3r7M0cad?IN}^ddw)%&FImiL|3CnjS*d} z9xXas{fekI8kW(?XxKzYqlb+U9gH42Otd$8$WYM^{r{q$Y~cF;x2aE3Z{Ya<2U54C zu1cMkIyqHL9hPc92f$vbT~cFHLsETHT~lpRDf9sRk^C5{gcy@laiy8+o2DjbFwO#NUFqdiEk1gCEiFpmv{u705>JBNSv2AHE~QL zoj4>>mzbN_8@&KKB}OC$Bzhz|Bw8e*@y7U1@h{K~@Jjrt_yh4f;@8D5iJu)`7ca)w zpdX+%J}W*wzDs;;d`P@+ylcEoJcW*cKd{FCIQC}j`PieeyJOeKE{>fMI~MEw<*|ja z{bG}2DREjm6rEZQg9DY`|}3w{m02;L5!3myz^Ltnt( zf)j&murjC(_6zn1b_li$dIs%+WFXKP@JZzL$WxK~A~!`Y#Tx(E$PtmHk($WN$S&v& z7!c_esfrL*_}}^;_%Hj9`FH!*`4{@9qC4O)zaH!RJ^cy(aKEo#?YHoK?>FyD?;Z39 zJmlT(UG1Ico#f@cRo)`+0B?#n4jlr0y)Isr7x#qwv-<^-oLAf@-TU2J-K*U5-BaD8 z-6P!P$Z`&Fr@0f|k?ugZ2YLmXyT1BEeT$^vb@hyTP~D-fRTrr<)N$w*I7BU0v(*f> ziyEW0Q(LP}s-=n|FZe-zD&LaN%SYth@&!DvWVQvcCWIAg(I>=OO`AU%K6v~m>Okwp=|qEknuy=x^0m0LRr-@$hcaz4l?*6#dp!7D#*xC zHg6GRL@1jz4>C?Dlg)zk5ur>ZgY?=^#uGt$O(-cIqz?~el!EkOp$wuy`p{5Df*`#* zlzt>guL`B-2kApXsk}&fC1g}66-lq)3!|zrNG}hs{;VoUFAL>6orCn!P`0LFTmx0o|Xh16irFS;q{6gtT8qml<>4_T9$3W=`8qmf- z>G2xS#X#wuG@yxr(&IFshk?>NYCsDErFYPP4hBk()qn;DN{_MdC8bAeK>GrvM`=L! z0;NZ4K=T5nx7UE)1xk<5fYt>{57&Us1xgRofW`$%57mIa1xgRG@D8O1Ye3fmrMJ_7 zrUgn5(tw@?N^h$HEen($r~w@dlpde~4GWasMg#g4DBWKJ+7&3xGe15g=mB_}(meNv z@GPZy_Gdt^0;PHWXF#h0rP%=((5XOa_5cPnDo~nTfB}69lx81bK$`-k*$EiXr9f%+ z0tPfGP@3I<0X+(oW+Kxy`b5bmQiy8;9H6DZBTz<~AyO0zRCpgV!m z>&F;Z~^&O?zKNzsQLs3KnIuR(%9>Uk45rNX|A`Is{7f_mggaK^`lx8Pk zKoh<$}M znh%JLg*AE)h@BCWj`2fUN}=);9#~D^QCr z3D{MjY8M7`v^qUtKfz@S7X=4uRU5FA;Iah^1GW*U`3nN}5UBm;2W%iv`_>2S9<25Y z*gBwQ-WsrPKfEu0@)d$Mvo8J7NAD$9Iz*_8Xd49K#h1bU^jpoJ|bW%fEqSDU>|@Q zIx1ijU^OgY2Y}k{>42wys6pEWJoiK4J04H`Py>esJnOUCHrOFl{a*-pzQ<+#`Uhh| z)puaPGd(Wr+b`g09%}2?0-occdTkx>1P|4-SHQD7RFA#^PwlLF20X7rb$d79Ngb+d zw}5AKs4iUtp3b50rHPu{HB2Rw5_ zwf-XDX&b7lb-;5r)Rt8NPuNhc+66pYv)VG?sTyjF9|E4Ip_*?I@FWd|Z+|>PL#3Jp zJUv4tQvuJ-P>EInPt2^60nf@%K_ci9DnAH#KE`GEf>#|Xe81zFn7@8ScoxQ|qcPwa z7+d|22zdI1`l409b1&3K?E;>7q28?yc-Dn_tw+F9F4PNs1DI|f`331274`d{Nl&QU1VDN|{S!R^ zq^HwA(E<=r9)=4Y0KSC4_hM9q@t*+SSnl7Nzu*N^q z_Wh5gLDYlVQi7c557CcN$$v6>Z}i6KCFuM=Cb~AdEV?kdKUVpZqNAeQqWixC*7=cO z18Vsn2X6$=1`h>y2G<1_2WR3KfNZcTs0-$xqQ7e}HW(cA4yuDz0Yw_I-v2D}cI1V~ zqp0fN7`ZHRPGnuA5IHQe1Z)0%B6~!}p|0OA(lydLl88wE7ynEDJ*@j5_wVtq_b(HrtTR{O82CvarI4eDZbx;k2|QA^ZZ ztn??T?Nxsq7tl%t@(=m7d=GsAkH|aaHS&CUGL8yZEf>pKcoQ}Kqa4q|5{`YLxPWpz z5i?vME~XsM#UWfsIi8Lgj^)jGMh@W^%JHPkaEv&Xay&1Ga5Uw3YGyc^ui@D_1l)!v zXoi&noADeS!V1dqG|jL=tfU;z)FCXV98cB^%lVoeEZ{agW%D)h#S(_my;*{fw zn}I}(ay)l41XvE`c=~4G6;O_6a0Xrh<#-Zj;1y7g=WzyCc$DL*oI&BEo8#G>K?+4V zp3oUEwNQ@dbb!-{O4j{wuulPpd32|!^2oH>Uj2`F_|wLcp%sK^ShqVkE~V62i@t3w^|E zS_&=OoG=X5mE29;)F)zGk(BbL7PLgthPvT^(dshH~|xL3V|ftAgzEP_DwI%e2I{OGCNzsvvuCC=Xs5WS4}p z;ou-oL3Va1XYL(j4-6%~U}tBAat8K$ zfR=j&+5NSg9%T0m<#gQJzFOig_6g;V*kJEaj(j@E&J5-DBZKT-p&Y(_kev}qd~wcB z59PoegY2H6?DKk%ofgX8eS+-NQ1z9k6E?_$bf?E4xvQim>nBRB#v1&1Z;pWq*=BDNPHvBvKd4nBUyF= z5%n5_EZYFCe!W+aWfOpWdRUNU3xK?8A!W4zaPI|WwE=MJ1!c7XaOVYOwE=MB1!c7X zaNh-GwE=M31!c7XaMuN8wE=L`1!c7XaL)y0wE=L;1!c7XaK{B@`{9vc9^!@z%4!4P zehbQK1K@TG%4!4PZVSq41K?&0%4!4PUJJ@<1K?H*%4!4PPKzMR1|S3)ErKjh|BwqI zdGd#xj}Ht_{VW#-S)TYI=gkkYJnciyofl+z(ubTgH^}mo4>@g4kmU&3;-q<|FLt<^1Z4}vUD=0ym>V|CbO_1e@8?yP9L6)a&$Yk>%%ab-FC4&r4*^m(mGCW~JdXXT*(={aC`5B(9 zqmYpd&(tF7qKd%NG_L*zH2|KZAwNfvplOO0?gyQMOgKZkZw(GI;SBBGJ2jf&85-Yz z&^JLDo}wjsD9|=R8J?sWI3LUKG#x^TGCWZ;un%TZ8PG048J@Hm&@Dk3p0-1{iZVQLGoV+3GCXxNpjCo0Jb8z3Ic0eIW*Wq2xQK#K%rcrs@|hXiGKIuGG| z%J78Ffc^-|@RZKL$ytUcbp~`tP==><1~f-dh9~wA&ZG=a?F?v*pbSs$A)G-Op57VI z7(p4H;2F>tK^dOn8PFC%8J^@B&=o-$p5__Q6hRrD=o!!xK^dOv88|}A@MO<`jtI)| zbRWX8l;H`Vfm5^$Px%aJhoB5k`V8oXpbSs@3>>6oc;aV3F9cSsVJ1Z8;g4n&hS^^o`fNFqb8^B7t zZfTHd@&U+$gG^`ys%}Y;35`J2H3XT^2vprWL57V0H(PX3kYO8uT(Bs}un9mST+6Tp zK-S<=HUO6MgA7mqkn?JS3{U=$2jDUA)X#GNAj1#|_JDq=>eLqK`TsfjY4Y{t zlgYoM7=Ji^JI)bs4ibYS<4fc7;xpnCOR%JE%iv0H*0o}C4i@{rDvW#w)2}@WT%6J)FB4d_dP?E~%Vi~oB z^&tXTGF65ldJhNJI!dg*mD5Dd_AC|CM6u+0zI`JDHB85r_uNTFyx+e+?M)8Z$s}B`F zTfIvBWb~>-#gA667C#uh^eXYa(FZRT-x=L-u=v*Kx<%p}qZikSugyb}YjLvHr!B1k zJ!R=|`J{ZZj56|xGD^$G%jih?SQ)LAkCxFI`G}=em;oNPbckFfAKFA%Ks;z^CANIP z(h9j!uD7&Iu8{XzS}K>x`z$TN@j~Vpd{54ov!yu(-;*^Ua}2(Rql#ura}2&G=Yq^J z_#VzSGF#dfb?CITE$X28(zd9BYNTyZ2hEeVMIAI(+7@-t9BEtBK?h3Pq7FS<+7@-t zOle!x<$fUBqK?^fKWSUk`rtkBwfIq*K6p=jCBBxX58e}B zim#;UgZIQ|c$}sW-V>jS&t$#5zfZ)MvaXCim5VKXDDIStEWIy2lm}UQPrNT{Exjb3 zlnX6ADc+L{%IGCI-_qmaZdqgLZt;?wS4NM^xt8t{kIOlh?i8QM*_Lh>cgX`S-6n3A zvn<^zZj%RCx<%Y7_qTMjxJB+~=_YZr+}F|#;wHI|rR&8F()7W5;#zUNG=1=%xJFzn zO&`36!+oxirVri|7fX_+4;~~glBN&d6BprH(+AIVp)`H)TmrgCnm+iZf{-+Q@J$sV zY5L%qE|k05$BCMd+^vj`k-J(#RY>kqMn}t?Euk(XCza7La-tO*p@B@~F{7)z)S$Qbr%k&X!O>lGSDOiR@$vB_!Fgj2@L8ETM=b+n3P; zvYjQAkz`v-s3XZXmQYBNtu3LFB&*8kLAhlat(UDVp_n9FmeB*Ug(Z}eB>p@lq5ufsY1PDRE4uvmR;c5KhsgxK&{U-a|0z*_z{`jXzE z=jkE3ovxyDX&tIxhtNSdYJW2ANQ0>twWk!x=ugp4qi;l?#xeUhM=!&A{SX%ODc@ zJ@QrLT~zlUj@%KsI&vP)^q0q~eo^EA9I-zxG93~!RR zz1JVz{H>a@nXl2y|DyYddneBLcfNbFTX0vqi``l7RHQRQ-QI3Tbn(0D7xlS%OFgR| zP`Bb}|8tNJWYh|^Q0=RBS7X&6)dNTSCzONk{g06lJSp#$H_A)o8S)scNDjt#=)G{R zzma%@=zmlQ6G>0)pmwA}m`Hlq(+*S!6G;z;1x%nqm`E~=r9zlUdTO*9LxnJr^wh`= zA5$SrBt0}3jG#i8NP5UEMp7Y6BpJ4+LYPQ;I6oma3lm9C4O6435GIlg!>JG^k{%9A z7)FIKk@VCyYC9@~iKK@%hHa=2CXyb`b=aQ@VIt}AuX0c!Oe8(kS?xxJFp*@arb3uV zda7D=rb3uVGPI*Ym`Hl6BSV--da8r!NQE$w^i+G*feK+F>8Wlma2*hoJxk!iVB=dGBo4OIGJQf zsb*B*Y?2|VQdHn{k|Cj5Qi1cy5Rz2jgpwhy5>()fk|CzzRN$17A*y0j;GB{{@>AfX zG6dWyXO#@FDk^YV8G=Iv&MO%lh0QpzYy#Ox1S)eo3*j<9u-<^c#rp1WdXO@Qp4LD@%C?};Z25?8s3m^Q=x^0 zH>t3NhS%g9RA{c@75N$!nrV1hzCwkRh8HCsLDIqtR7hxeLB2?ZxP|Ab5YzCy#AamS zIVwaoJjb^QEZ{Z~4NpmI=397@3Z8~1J1D_A`NbNpVOV70TFM`!;c9seEPs1hhD$38bfZNQ`aFINZ^0PHuC@-SC_8-L97gApP5Bkt9puF}U zd7iw4^4fm@=Tlz$4^Co>y=niEr^s_Dul+}!%#T|856*OZGUc`Z$aV4*%4`1toJ4u; zKRCQ?9p$zE;QTlzQhrbUK#!BhQ(pUzJXRh@dF?;)Xn8EiO3G{hLHf6X^4fn8{w<@t_8*BXkn-Ap zI0~e^_8(3HDX;wpUpD~Se>exEy!IcC0V%Kj2PsvW^4fm@^C_?W2T((K?LUBdl-K?P zm`nLV`t_MD=TKhz53V_o^4fnm{-eC{AEbYjH~xe0kMhQUko{5K_z$8#${YVd@<(~& zKM4LPZ~SMb+?(>oe`d-llsEo^)Q|GUe-Qdn-uMqPKgt{bLF7ky<3C9JC~y2{ikwP$ z<3Cg6WXc==LEJ}q<3E$-9+Wr!gRqbC#((zU+Zg{r)JJ*aKS=s0Z~O;AALWhzAm^jJ z@gKx|lsEo^l#lYpe-QFf-uMqPKFS;aLBvOS<3C9FDBn`QJ_z_IZ~O=O9_5Yy4CUJx z|3SJ(dE-9__b6}t2iYFwjsGCpqrCASBzu%M{?kErq`dJTQUbK z4?;c48~;J3M|tBvi1a9L{0E61<&FO&WrFg?e~{-<-uMr?pDAzr2WcMVjsKv2Njc*` z$nq#>{3j;SZEE}nNgm~l{}A8C_z!YC${GJbj7K@+KS=Q?XZ#1L0p*PUAj6}a_8*S$ zD5w2LAi<-Y_8$)LD5w30^E=9E|Ka$Ka@v15y`!A=9}e#*r~QYsJId9EuQo?_l+*qr zklayD`wsxUp4xx#BYAgHPWz8QY)3imKLV*8<+T3@gm#qE{v(jtQBM00;3djw{}D*+ zD5w2LAh4sH_8-9Gl+*qr5Z6&o`;S0cM>*|30%0BHwEqZXb(GWoBM{Y5PWz8QQb#%M zKLSA=<+T3@8b=G>L;LiF74>W*@^al5!JafJP^Sew78o>up5ZudfRcbv`t zVs)A-sY7ve{sC$-mgj?1chwqa^51}t{daL({)0G=|K;*5d>>g0Kc0hX(s)!IwnqIT zX;$}@|Ngs;fJyW48xG5rtzRMGS*dLO3fHVswtfX#p=|vMy&{JyTfYJ=SGInomV#~l zN*xTg^()l?w)HDj54QCyoJ+D^&!|mD(TKmaSi@{Xn*U1$~UN^()Z+%GR$y`zc$$0_~%0{R(B`ox)J%}AU!m`2rn2=b96Pg@vh^!$w~w;*E6@yO>sO#Xl&xQ>X&_s_!g)ET zDOsQeGs(JSH+YQ&+ z`jwi1c3@k-Qad8wv-K;E1eL8{aVDs2{fa|DW$Ra*3MyN_;#g4G`W5Gb%GR$q7*w`? z#mS(u^(&4Bm91ZKHmGdsOo(DqFwecu?8;73YJ>)~`4qRJMM_38Av}D~<@2 ztzU6QsBHa;LqcWiSDX?mTfgF%P}%ww=Y-1EuQ(`FwtmG)p|bTWY*(#p{R-4x+4>cz zt+MqiP79T-UvXTh#@W{od7-lPD-H~mtzU6ssBHZT)Joa<6=#OZ)~`4;RJMM_siCs< zD~=77tzUtX%GR$qI8?TN#mS+v^(&4Jm91ZKcBpLq3M7=RUvYYvh^#8cv0E<73fW6>sP2jyrFFUN+MxYwtgiMF)CZX z!ZC>NsgxZh#3%AuL+Wf)IJih5XGQ${DNt1X?ZPEl8t(Q)cZOY3mHqbn?(h%+T$ zZs`PdqPon|@u;g@YUwz2vbv;q$Om=Dp5wytGFek#wu1u z&ndEm+*n1+=y?@bLUODkW%Q)-Eg?Hr-X_{ONV%4f9xG)D@v)MYkRK~iM(dSh2??@n z+(bAk=TA$>kmW{82$AImOGuIBAC?d!%ik>_N0z@?LXa$fwS**D{$dGHvi!M>Zk0b- zLYORnETh|S483mXl!%k%_m+?+%kRqQX8ElpB+Bv|L-_QatFAMIp5q(j*Orhl%dadU zVwPW)(Y5jmO9+|e=a!H%%g-z!W|p5?Le4Bdv4o&meq2VE$&V}{YL*|C(WUYOO9-3g z`<9S4%l9lHZkF#_Lf$Ojv4p@`zFkJ=%eO2ca+YseLgp;ru!PWAzHSMrvwY1GVrThk z8J#3wv4r4RzHAA}vwX=CqG$P{C1lU?1xpB@<@1)1KFjC0{@+p#snq}fo$LSCx)->o zxJCC+x6VBfM`w)3Z!+|8JGoo99x{e6)Z6Mg^`N>r3u%?)ZZ_@+O z0ni@D2>AFu^ew(Kye^-S4`E)p9`EV147NazyBevmlgvc#buB#t{PapV_;jFdR<6H&i0C~??_tG~kCa?}U;J{}hbeUNW< z3rZaGLB7%}C~?RK`Rq19i6cJ9#|H-`4)`D+8WEH@-h;e%Y*6BG5Au!)L5ZV1$ZMtr zB@Xr=FWxUGajXY`C@4&4O0+mmd0rqCmy8IjvA_w@|JecAQ4oT#)h!* zYsBlNF&f^N)zQ*u2I0B;Hd1L+(-odO{f}#@G*ZJfcV8-PZ(%BxMrfGoPNULr4ZHsF z0hNYnnCR|GrJ)+eyE{;6h=!fq@l+bDVMli-Ds87>2X{v*4YDwXO517}%R3mTVT?PG zN&_s6rP4MUM!UF8e+{GD(NyZEVW^AOs;`D2?ocZA(Jhr@JBLWApiRB|*Z*QMeS8lk z;b#m;@h}ZPsh_EMsD>ZaPgGp3;Rp3A6<1mKk&1_C_+I@$#g!VqRo_!_g@$h!*hIMN z=Nt7c71>4@zEr8K2#r3k&T7nEw=*|*;<-Fy-7tj7lt?0TU2C!3E>SYvc)jG z!JDzkgz!2Q*=87C=i9K+gn-+y)iAuYVK^1pY#3ftFHw>0hT#SEA{A$8cwW6gMYbHi z=2`VT71?wco>9+Ik!^?JY4r>h*?1V9R8Lcpt%u<;g@?oD6T+iZ4DH8LkE+M07}^iR zBUB9S$5W3eY!=!N!^2bz?Z?CN8Mg`ThXJ<wyt#n65{oNee{Du(vsse9DlsTkUir|#k%g!bd%;Ktjj7}}4gZdZ3u zF|;3s+o%}YkB9F#cTq949|qiJCp`wYs+*`7+K-1HKDw2Pq5XL3W_1e{chH;N#Cr?v z$5S_{o2fWPUvnexpvitPyf>&Dsi^%2r$)YkirRnFHR@U_YX4DJt9z)Z{ReOj6}A81 ztJu|4)c%9M%qyv={YPD{uArj!A9X2@f%YF1crK)(_8**$`C=++|4|pJOR1>+2XGM; zwg0FK)a6vv{sX`cwEtj%b^#T&|L}T^iv9F6MR-C*?LWvM@IKJ~12~zA+JBI7oI*wI zKY-(?sQm{y2|)V~zHqLiqV^x8Bqvf)`wxD4`UEO&rSBA(2^F>fAQU;CirRkw$5OG2 z-VA{W6}A7UW7RQKtk%~cE}^3KAEf-ajrJdeBLymI|3Mm3q@wm8K%R=)e~^V>GwnYr zzX7kd_8;UT*i8EmzCP!usQm}2NS2D)e-MgfsHpu1Nk^KB+J6)x5-Mu{K_aq-irRk^ z0um}}|3M|@2r6p-0USm}?LP|X2o;n1nWEG~MeRRa>7k>*hYH4jP|u-)@gJ0Ps9^jD)f_4q|3NW_3dVm> z%b`NOex@kpP{H^QDmhdz{)0je6^#F&jzb0GKPcl+!T1lVI8-qHgCY(UjQ^m9Lj~hM zDB)0{M&BtaI8-qHg8~i}jQ^m1LxnkdGn8+rVEhNw8?OI-u`>>+k6*APK^{JtI_AbKhEGcHnMGGE1b1I>i^+?<-g-U=Rf4%;a}rl zfV1`=<*)G%_G|pT{oVYr{&s#Zza!4uAMyV1zVSZrUO}h-z1~gUW!~A|30~Ga#9QRe z@}_wcybw&W| zq+Er5|1Z=#_%iW`x=URLmpC1B;Su=6FF;Li57g&|sNOgyKnoSYdjA__{jcDx0QVxh zyA0>+KS5^E_jiyyKu$$(z;M|Q$3*t}cdLm1uYA1vkaB;=ajyAwD1V0?UGwWu{@4kw z`E@9N><+H^btr$-L$3LCD1YQA_Y||A?MJ#N8$DvIYknQdAF;h_ejUmm`nYR;9m*dv z)HS~jzZGO@&^ob&96iG+YENiuS5CU3~rnpIyrnny z-CgtRP=2>wuK9H+zuQ)>`E@A2^ZV`^GY-|A-NTLURPCDIhw?jibItEV`5imC=J%oe zc3-*X_o4i@?OgNwP=1@X?n<+t)@|GsMz?J3E;qVGM|YXkE#0L?CtJ7&8y!!&ON@@i z-3FthF}L36AnMi`9SPjUM*9(Wk)|wS9-Z~jed3;caG7I4|Zo8{m=;aK%?&+>&`OzjtTAoMqe|{-QVbo z_jC6%`s^BaU!za1bN4a2u-4sM$J*7IayEA_OS7B{+!>aTw7JtQA!>8?w1lk9 zon{GPn>*DIr)};OONiUt$(E3}xqDbb;O6db35lD#nqjTGpQAf9}C8Tt2n=&G1JsSoWVdmYz)UdX*){hnP zJ!R|19PlYyKjwr_+4?a@e9G33Ipb5de#{}Cvh`!oqv|z#e~9_it7Y_vdc_igKJ{`L zJ*-}`gs4xwSVoVi7c8wu!uPx-q@WTd6T-jv(=~Kod<7HYzxY#3 z$p7L`G9dtrKf#0qF#b3bBEa}#HMBhbC=)`!_#;e60pkxdAqI>;#Dp9${vZ>A!1x19 zNCM;cGa(9$-&aG|$M0oA7#P2&hOUd>P4)kd{;vP){r}t7*VV`C{;2y7eE{A?C%+YS z57gaOcMWC$Wa^gIolrNg?f~=xm|V95ZUpqH>r~gWPNe=${fNnb@26fxKY)i*cciXI zSHFDfOw0l}IyE=7S87UXEP4X;OLf82Kb34q{)DaoA0}TU6w2+&rY6_ zT+lobU`ldKa%i$Q=KUoTO^KfpU!up~>ckU?yAwAgE>2`H3*eZPdBvTBa^hS#_E^Rvm)g|GTIi z)L_+9wN;V)NB$r`mv5u*|D*B_c`f?=ohKK|`SL(HUG9jU|69qfvXuVy~m5`G=NAHEzug}mU#@X|0BF2&7*L&JT- zDdFgF>#!Hn0v$92KL%@qcY+si_u$UpI?VVxA6dZy^lh9G>=bMt3<$cx9R*zCTO^8)~W#pWT;z#04^fcrI!{YsLE}%Z{qj%ssoY8v)XMgTPN^luYl$?&6euw$9 z@MH5|{?l0#R|@ZD?>3z^aV5bmI&0!e;oaojtg|Mr6yA;A?K*4XN`jj(nxQ$S65cZJ zMx8ZrrNEt)Wjbr(N`XExcj~N(D}{Ge;{u&Eai#Ds^RCia6ITlFQtvXIHF2fzDwr^k zHF2fzijBwVtcfdySMVx2YvM|RqRyJQQeeVBT4zjLDZDgF+!+&B5@dA7#FfH3!#h!D zOk63v)4elv#>ADvTk4&zGbXMS-f7-aoiTBx@J{tk(-{+23U3K!4rfeUDZItr5}h$| zrSMMi7VC_OD}{HmcZ$xKxKenFypwgt#FYXA4;JZ+i7N$W9N?=qai#DUHeRGNCax6T z0&k(tn7C4S^SuQ+W8zBT9qrB68536u?gp7$Rg?D)46*^<$O5q(w zVB$*Q9qJvfGbXMiI80|uTq(Rmyd!nS#FfIE+qg_;Ok63v0|`uADZDw}fjVR2O5q*g z&DEKM?9agg-W;7Vai#EPdk5&uf%cL8yhC(mj)i?1Z`YXvEbQ&=qcgKD?B(sPGy6NB zr|oB9rni^Q>}z3$x1Y|;vaoyOLprmMh26Z}bY^b{^vGTocJ+4GnVAfD+6)U*yeT>} z-2uI3PYb(vyXwpy40zf!3zNMWIy2S6q{ioUW_Jq{y@@)rn*(}eR|^xoNjfuy0Z-e- z!Z>fN&g{%EMrS5l80(GGnVl?*A=uFYz4asuqrC|_Gm!yLn_ywMH(6)KTi6!IN;2ad zG`_4eV;NTK%oq#X(9=d+80rnznH?+)_J-=rC<}wU!8$Y2Lf^(ebY^=Cy}iCVGs40a z1j8A6>&$i*dU;#u%rFZ*y!dRSENt$z*O{#>Z02pQGyN^J@ix<$EiJUfoW@K)3p9-})7L^h zrZHyvSg7;rb*8t41P=FPwy>bRI-TjokkFZ)7L+$gXL?u&n_BBkcMAbQHw$s}y3cfF z2y~{41s`J;Go3BO2s#-+Pbpt#Ix@s`rh^5VMUrW60rv)UW^)JVWuIwhfhIU++FJM< z6C5*}S)d7ynKl+^f@7w&g+B;dG5o1BEiKSE$4mx z7|xQh{)71?8+FF|58y|gvHk=2UT3WTU}Oov`VS_Ke5o_me=u?6Yn>7HEBF$_OfuGg zVxM5*NXGh4?ER*GI%EAOwkGzz&iM8<@5R>WjP;+`yG{7py_kb=vw*Y<1JlI&J+Y_9CVvrmg>A=E;jXZT+Wt%t_k%4~8i|uhZ6lnujT-t^Z)M z$#Xhw{ik_;V%qvo^ZdlL^`GYXiD~OU&GQq})_*WR@hP3Q{u6rwv#iqAe`1fvp3rIQ zKe5MRkL$GcpXOnTY3o1D!xYojf0~CWrmg=p4^vEA|G_ZD*L2$YPxEBOwDlkKdcQ-b zt^dUCY?`Cf)_nf6%J^ zR-Lx~gNcU^>a_JAz;!xp{RchRFVku3Ke5Z37U{J0pBRlYOk4i}T&mO7f6%HOk68a{ z9&(tr{)54Wc$)PeOf$S#r>+0QF2FdIwDq4@1;ZrL)_>5dy`t0Bf6!08tkc$i&`&A(-NJw{)2w%X`QzIL!$|!bhH0>B6cQpv;9DyzBEdc`ye=88arL3 zcP8zLrFaTi4?c{sXev$4gAZdkVVZmg^3)|NO|C<-s?y{+kV{TgX>uHri&dKZ268bz zN^V0EFC(vk#0-}-ISu5ZlU1602J)muDori}dE!YbO&$Yz{D~?}4g-1I@hVOJ0*N6n zX>u3HV~$m6@)pR2$EY+p3*>@@QJQ=Oe~kIDb5xpa1s|TjK&8o4AdkFUrO8qtG36yq zh60HxFKMz9NQ|;blbJvsI$x#9N=P22(qtr%2i>63WFwGs4^n9|5y%7Qsx(;$pq`-Hn{Szewpm?o=$oc6FvlTkoUou<-c6Ofaqsx+AdO+nv4K)*hrNo8z8xzN|Omd4t-6f$pRoT zv@uNv0Ew~O>Bz{z!_fE5%B@wJmVP{8z`H6<3qK@=b*E|Bhs21RG%fm&TlQCJTJjpMKU8U2?D0`d=sus8dPq#@KA#qPl3S?rX_<%Y{<%7z7I{d_a6F%u zct}hFIiD7I$Sye8Ov^jTuIhYR+#x%Dr_QIP9TG!w&ZmVP5<_y%r)3>-^Y&E#|0=#Z zhJEB=;lAPS;rMV^xFznew?bLyui*RObL=Ev44w$?32qE7!|b>-agY5-OpD(Wi9GEk zy9b*EQM>_D0KSZ`ssE*ZUHymkuhlKE1@R6nzRGA0OaRo|t) zMZI75OWjv>@72AC`GI#JJE+v1U3XI55q104?OHdwZg5?Xx;97+{zz>|eVBSB^;GJf z)D5YNQs<`@BQH1rvjfMcwn_C)ZJyG}f0ExPKTW=od^Y(&@@C`&x#VfdV~`TeNbZQK zfmU4D+l7YR@GjMygmFl8eC?696zmo6C7v*Ef1+GEI zz_aB^@(9cV+*OX2gJlodMhc_?8_+NC74ej~N8ErZfaiH-G4XSh21*d6Sq4h?m2$re}pss zD^T-a=3nfm{U!c^Ky9+dft)2 zy-pu7FSy6)!;T28v}TGV$1gDY514ld`vG6YQF(i+Nyr!gU73jf7)x_3r+DicDc za0wGqrf@M6Vy5sECge=v$xH~E!bMC-n!=Nq5H*D-G9haUPhdjW6duonv?)A}32{?+ zEEDpk@E9fpPT@i(Bu?Q1CPYr*d?sX0;n7S8ox-D-kUE7&G9h*f=he_*;So#-p2EYK zkUWKlF(G;i4`o936duBa@F_gFhUSC^F(G~m=Q1IG3J+vL02R(*LIM>Yz=Q}YoV|(g z%>9`VLWTP=A%zO}WkL)U&SF9i74EZ%@Yvo=NTR~MYG}7`CKIx#aK!vdyjSBay zp()`WOvt0cX-o*D!l_J1q{7`d5gyx(37J&5YYj~dr!XOv3U{fYG2zZk$fd%`ObDjJ zoi-6Z!;Vadrou@zG%lRTgm5aHz=U)v9KVSgUk=CB(CTn369TGm3=p^$fRRLRb|JWkOmN z4q-xE6>iOhyeb^LiSTs}VnSjS4rD@P6%JrRW)*J5gwQJN&xF(}+>!~gRoIUSxmDPg z3Bgs^hY875*qaH_Rk%eBCBj}z2(QAPOh~W79!!X@!tPASuflFM6og%wkYI&fm=Ixw zogGnz6?UqjSlE#XDOT8l2{BgKeiOwS!_AoxWQFaRkYt5znGj`#n=v8F3fnLt%nDoA z(4S!|Cd64`OD5!5VGAY%T46mC60NX~36WNq+C;I9m}hBD4G0Rg!bA;yA8IDVT4BV5 zTq{&e2)07Wgk&oeOo+C^kO|pV7%(B+LKk88*^zFAJ`>`t&|^Zr6~>qla0N{@^m@?9 zgorC>U_!8aR?;AbYJUBORGh`WM~Ovt-}ADIw%1wSx78hb4Gp6QX;qrrDf562z}zGZqS z_HghG6H>2WLk&F?tY<>*6|CDtO>=^;neK?)8GKbk%Y!eOkbMPT)X*KlS|+4l!RJhf zzk<)0kbecAG9ds9KB=MWf{&RHfdwCJqNYW`hfD~;f)8rw(%^k2#9+ai8oDfaj|o9o z@GcXQu;3jgL}9_(Ovu85x0n!y1#dDT4GZ32LL3&nUPGDSH6{dN!K+M2#DZ69=-l9C zdoYeNvEU_!)0YOT-9MUyH+aE4fLtti-aT;I(qNU-r!EPeV_gkaI=$r7 z;91s-gJ+yxd}{DC>m|Vor%zrSJmvJFlY=LnK50?#1l9k$QT_kFbN~Ow#76Ak*Cbv^ zJd=1Ru^bZsE>7gIi$6IrKXFiEZ_M)>ml&4lkDdHx39Xy-FM7THSih;C*N>yK;7$4p zOaeGpFVV;7L-oFTs-CDv=z+Q?X8WZu4d8d|>OYI#iB?BXMfXRyMps7{L}^U;J05la z+1T6f6pf0uj(SJ!vAb`=l)o?4dup|MLfx%yP#3F=`jA3TaKg#W8O0{-oj06I^hrtd2hN!IH;#hM1z z={yCS1YgCz(0PhB3BDwtaFgH*R7~;|ZyNYg=PBScuvX_O;v`s$l1rXKP6K!Yia7~B zi+!u}6m$}Ngu+UmqE3PjV;|`}g`ET+#6Hw{iaQD3kA0x?6nGM>K~W)3ktf0HDAVLA z^dz8?OP*p+1Fz~l1)l`3(rYOCY=)+xI#1yz!OOAFbe`f*f)|=b>O2LY1kcA-={!ZC z1gkI@K2IU2f#-FeVo-vWv6rJf1);vDV$U3)^OS_r15cuqlBX<`;0cVW&r=#|;7OgQ zJe1&ZdV~^D19%N(q6Aca$x|w7;4z)2T$JG97=V&df`w0v_-@b-)~zJKoA= zDtDZbvk!=J$CC8bezBD*cZ~V)tjAPtp^-CYsoVl1k+ydCa)%i?agxd%YUKDQRqhZY$KeSF8#!j2${l3n z=rJlc*UBc9JJ86Hqg8H>kwafsxdV*cdZ^0HHgeENmD}IQ{%cfjKO+%_=k_%c33zUn zk$ncK+&)J3`dH=mHnK+_mD|h6uIp89rjebys@x1C+x1Yn=|-m7sob7MCQ>T5hmq(g zlAC5EI*R0`8i|e~x!sLKN0HoaMxvugZdW7GQ6x77GV)Yh=60bEMp*E3JDZ2scUHN{ zMt8*k*Y**Z545&bvyFbv@y^q^Z7t0AF4VbgEX?%o(Yc`(#(Pie+z<=>y?1qP zYYSaHeAW1;KKHV#9))pEX(XcpYZJ}WU8hzxfEi}CBZ>w|G78)M& zchos+3k~=AGj-0|Lc{g`(K^@He4d8W{H)GdTWFYrIRQCq3k@^;`*qIRLc>TO-)n0N z4MQ#4vayXKmr1ujAcRt{Xm&=gD0jQn{{1PR5T0Spq&fX@$y> zAwW(XqjF>ikP{%u3?Rotk`+LXgCrw>9D`pxvH_A4RE|u5Bz|DX0!ZT5kqm(3S1L#A zKjaQrK4|raL?tXoYd_>jyf3Z%kRx7EIa>E2hmTM>TJ<4^4Ocl@^C7n#rgF66Lk`_m zZ7LsXX5 zbI5LMRhCwBNL0eIw3b7n{FSAZ9I|r{m8EqYvSVkJrBxiV!(^4EHJoHem2Fb z?y0h7^_H&;Ravuo%ax;4)~w!g#Uz!TYyOZA?V_`^dZQ(f-@V~;ou&1g06p@fEUnv<2Y8KsE1jifp5X7sZaPaVJ;7h-aG#~cp5RX`Rasi@ z4g96EwBQr`PEVsHpWruo16uS6e)WFWSz7iD{HC+C@DotK{46c~1V5uies-LNjoyzs zYZiau{pkIyvu5!p*r>B+@fT>TkEfZ%UwGem8+6tz{=(bfeWSBx@dwbCV-|nmt*57% z#b2PuKK@S4;!m(%XU*a-yidJGoi&TU@IGpssIzAA7v2Zn`#NhDf8o9FeWbHy@hA8| zXU*a-y!Ys7X7Lx^yWV>`YZiauy#rgwn#EsuZ+UO(tXcep_onxb&YH!a;4Pgsi@)$* zr>B|4UwE(3mtz)x;l1p=qO)f47wF~xy3U%#pWtPkHH*LSR-^xa)-3)4UH(_=tXcep zx5|4?XU*a-yyv`CI%^hx0DT^__zQ0(J*~U_`*{}q|Fg9C)8E{)-b$UN<)7d&?^B&6 z10Z;~aSxp(3m|w1KA9yGAb5}-AsZlg0Am8OWCR8t)LF6u0vZ#LB{Lwn7h?jlWCsNI zcn|9=8G?a(b(SoF;BNXnWC{d#VxB>kY=Pi*j918#F%aBFXQ{~=2yUg5*<=m`w|KWw z{r~&;N3k%Ev*gEy2Zu9p^L#|uKkOXBH*oL#%ivv{B!3h$=B^IP!CAqH!QsIy+&SL? zcg?!v9C?Tv=j;D}>>h9>?gpHZJTW;hIXk&Wa#C_cazL_svTZVfy8%BZzD|6QconI^ zgNfS`S0}28^DsAXA#MlkotTmslNg%lljxYJPsH`_`Ws9Rd=vKr9@Tg1>-EJrioR4I zuMgMzAupJqx5EvAuDZ3B(cjSz(c0);qyAnt$(1z<*8&{C~dE|MDHML7$p2ODuG8B`sp%HB4B=!mF9E zjD=S*VI2#vWWqugUcrQwEWEsi9tS$J^`-5XxSg!L@EkO>P~ zcmWeuw6MyAB`vHlVNDCmOjy*y5)*EsgvA=VB`h%EE=rhZ!ex{&S3@_2SteXZ2{TN% zj}oSta3Li;p9wco!t%!gWS6dGi?nb{FG*Y-5{i^>O z;coP+E&B_1qhIygQn(xas?Q)X%e`l>kHtPt_vj<`cDn0&v6s`GyNa1kx9cHhIGt)I zraO&#*q%;D39*OMDiYJ2mP$-@T1c_G)1eT%IUNtht{SB-%5>sl3Q5?iY7)D+XRPlm zc6R#XUShJ-Yx;?uoPK?4v7^&Zj~0`hesa8+==5W|iwRCIn=M>PyE!@#uB5G+kR6Dz z_Gd^nAv_Rcn2;Wb(T*rS5IZm-KM-7g4l)$L4p{{gd{->VM3H3wq`<>AOhz=)qKnfL$B52OPZ%RQIX!-i=;-vgF`|Rh zV2$}=;U7+Sm>mAjx?}j8)9p5dzdGHvL--5pcHz%Xw{9E$fJMacDJ3MoRaCxdjz^<;`ELarxM zP7!iFnSzRt>&c)*$n|83DnhO&Q&tgjJ(*%gez<4xA0je1Q+2mOh_)mr%)hb5MqQ6G9kqX zA7Da^5#G;)93#B1hCT@IWkQk>-ou0_BfOglSw?sl6T*z}P9~%o;T<*fR=AuAc}93U z69SF!HYOw*;jK)FG{ReI=;iQcCWIQ{O-x8N!W)?oYlO>~kZXiDY$ANV>zR;jgx4`4 z+6b?u`hN?IcmHo)|8GeAm{^Mw{4XRPOWcX-|AmS3v5#Mnn46f9*eS7nVgM@tuqXYe z{uaCVH}p#VkiJb{g{uGAdJ#_X&(_nhhaaZ<=}x*H75`tObI?O*dQm;D?!q4aBHRNxRV`EpshK#-KT-`uSHLz( z%D=FS|5UyypOX*E+vU|b&3}$O8GQi{kbB69sPu0sJIfZ>!~Z7M<2?VX;%QX*Zx)w} zf;dB*fF1n4VmC2X3`K>%y-0>l;ZNb0;d|%`_(XVjcmt~YneboXF-T_iLT!H(dII(c zHw#tpH|}P97Q7X#3LZf=a}7EIo*SGJ939NT&5TLtP}D!@f<89^(wPlN0A7nfgT5EH zpwDX&JxfnSXU_dF)oUDP1opwyr4*(n{p^2***hwYq5X zN#&p3aJ?>?d{X&K{e`+{@=1j+W2r8he3Af7vOe75?UD>7vOemA@Be92ZSKNwBvrntW3EGwC%Zp9D1Ar;8?^ zRQ|5?<(Pa@`IAw{E1G;#`7}qkX!1#gPLn(7qRA%#4Ug%f$tRUR(ce`U5kn%ZRQ@Oo z6)x6U*uLRqT})Y^(ZR)}h2j1%T})WmjzC)&hKanzh+#WjR1DkdqO`CrCJYw^1KuFC zFw`HVi-Co~es5ijTj=c%)02{EMNp zE}Y8HQ5Tk2=!nl#Sj>QTJH-N(X$mJ>pfXKik%hMYP+d65LaT;GT{zJ~OH^hGCs=4f zaJ+?jzlAOwXQ9q-sSC$4)a$}A3@Ke$Xd#8dN?`#5-fg}Gs`C_%wxChxDI8_N_uJ~i zkrrZ&QWxeK@S41Tbm0gKjRc2VXh6N9aF~UEye3^Zl%YWv4q^CP7Y?@YHzpYu4st;6 zHrK+Rn0{P1(86!1vJ~c6*y#PH3kO)BVoYH+!wl?f3!af$(5$tW@YZPM&doirjg_#z3=3ZpOtwT>YQyK6EyIc4S!w@*4<_0p$Kydc^t<#zEhx3)X)C z%XGo|4<SpNZBt_#+Gyvs3Zx?ueW zfOoV0gLXW4n)M&=V$6ssSpV@Z^e)x~>pxx<)v1E@AFt${uM5_H&~&Gy3)X+U^U>v| zVEqSho-SDb@y?^ySpV_PYh0oW)_=Tnys9o(|3TNDlXb!R4;uBHq6^l4(1B;EE?ECT z2c9!@!TL{gJD!5|pXPQv1?xZF$&D3Vu>ONSJLl+v^&hm>Ib0X4|Dc=B5xQXg$D8LJ zsSDPByd%(br(pdDFi#h(|9FSfYeM_ue|Y0fx?ugso8>Lg1?xZFUT7Xuu>RvwW1E8Y zA8!T*`V_4H0H*7_^&f9KUX!=}<4te8Q|GP!czbxWbl&=pH?{F4owxqu?TS`3dFwyk z6!fympJKnZUC{C-Z~e#H86$=A)_=Uo-p)F2{m0u0&13S`f4m*Nopj#%k2lHNQRl7y zcoWgsCU5-*FhS?7|9BJVE3p0p7_alzf4uSDRGqi}1Hfyn|9Io*Y1V(dG2V7MZ~ey` zOZwLj#@sq4s+)jNZ-p&oZwMuV2T^PfT95S@4aL;Y{^&VL5d)13bdYG z4?5%Io&WUq`sn;D`xR{A_11akKh!2C@BD|_yz`&#Xl#>r{?i$4a`Mi9 zI(eOS-uX`_96!rD|LK61H+knj?Y#~<@BC+TdN=1k)FvnI{HG1Q#`#a1#*I4f{HNaA zO6Q&bByi>}@B9ac^mN|&kMa^a@BBx3k+lHnb)Ur_jfE$KM}+%_)4~biwqc*JJ-W^};Pm)s z!JENK^qjvXxID-QOM_$4aegMc$c@1MvNL+6dgu%KW&GXv3w#3L(fZ}}SJ#*8&#FJM z{_y%)^;7D1s2^0{y}osQSoeF~`nnJ5Uaotx?(Vwl(J}D6x>M?os+(OmwQgM9(7G+^ z+SNtq75H81lho^}XHxg4Zc1I6%BKF6T8J)z(^HdD+o4Bb$5blWl-!tHn|wRDD*15o zwxl@$aD4L6QS{^U9HOMEOnwfT+LEb&=2t6nIG^BQi7Yr zr6P;z0Sm=kF9D*6|P%i`S!r-^Hi0XtA972_!qAJ-F)ONM@+Bv+?tBPY2!O*=Ws)oI$wNv=-QUQTY!f7*M! z`{dvnx|`${?CfOE8oE;UsG-YccP4D^WVaf+LUv`s_D*(T!v0QnX2J$fc4EQ~ zPj+O&7Eg9y!X8hyXTl~=ZeBy@$#zWG=E=63sBwwhj0qb(*@g)_J=vNGTRqu|341-+ zk_nqV*`kIP%X%iX#E^BHsIekbOxW<@lsD?hK2l2B@yP@ewtP}EVb3QcCT#kos-byO zGGW^%#U^UJNrp_=_{o3?J3kp`!q!jvOxXKLuZE_}7!!7XqG=N~-YFWHu>TVcOxOU5 zf0(cX6n`^e3n>0#!X8lk$%IXy_=5?%K=C^hwt?a|ChP;nuT0noieH$p6BIw!&;;=l z6ZV2)V-1ZLKQduAD1NA+3F3Pu><7hnOxO^LZ<(+o6yGpmODHxlVNWR5*U)gWu7-w* zubHqd6kpZQcH&DWYz)O0OxPKUwM^I=iqDy_Hx!@M&|vW?6LyE& z6E=wALniDH#Rp8-B8vB!utyYYn6OC{?=fMQDBfklHc`C8gngoTn+Y35@fH(yisH>0 zY9roY!d_9l&VzQyJSX{@1`@rH_CR_*>*D&Em zu(-O0UKLj{;ZCr)auYQT6<09fRr|}iB@Obo*-y0R?k>P-_YuGZ32ft$G z+?rr@@Ho22UyJ(kIl-b}9%lROhS@k<2R+gEPojtXH>fVZ%DewZ(Dm=C`cnOw^(WLH zR=-dEF7>182iAA1Z&e@E{Z_ZG?tM%Qc%tsEy6fsLs5`grb}&Cm=cglotj#ZIxw|oYGP_ws&A@8Dw%9d{+Rqc`4*-FJe0gO zc}232JUw|_@{r_S$;rv>lUpUbBwHl?#4m}jFc;uOoEW$xaZRFsZXv?TmREK#0KjEanJL-A$h`L=}rAq2db%Huf?Sp9mqi{~3 zn`)&3`I}rP-5uw?DxW{siZ&F-{wDN=lefj=%(+0`*n`@jrUVS9N~Cdy*##tQt}uk zRBGkI8mf~Em{6;g^O;brl}9t7S}TuYLb+BR>4@sJavl>3w(C#z|;e@6;tdZ;aHsGI7?)GfY+>RUtIRUf8~@%E}W zQwPu%OzqcmtZkE@PMLA-_Pz!Z-Ms(lUlYI7zZuWPr78e*y~Q!L&=ZRQ9wQh!lxnEvwr zR;`)-^#4+=nEvqpR4ti)_y15Wn11trSM^N4`oF0VRpOfZ}7iSK@F`}ai;bDPs*>M4a#HsA|6vQ zrcWD&$|j~yK#fcv`=82&8u~>3!}O8=vHY9qL%i-UrVsp&)^(+&R3^6eU0Cf{PZ9K!bcKJVe1++9{|fms(`EkU@+GEA{mbNPrc3-w<%^rB;WYUI)5W0Y znJ&U-UsXdF%jcLb^e>PrnJ(}zlF!!Ah4LAuDxUc?Q^l{!6-;HnBA;TyQB3({4VC2+ zOgM}wA7{d8O!*iSj$_J4nQ$IcKEi|pnet&KoXC_9G2uw2e6WTV$_JQmC{y0ggj1RF zJ|-N?l=m{>T&BE-X_0@jyqgIpGv!@OIGQQ%WWw1@c}EQ$E0;6jbf&z$hK`Z9Z6f@M zZe_v&O?e9wPH4)TnQ%l?-o%76n({^_9MY7_m~cu{-oS)on(}%koYR!o)zBgG+8R1o zUQF@NXxW)gRepuhG zuhtcP4!Zasg&qAK==ryu-congEp$BkEm|La7`=)<{`X@~e|c1h&WKLH4gP(j-J-G4 z(En4fzn9bs^?W7a0tYTkEr**6TT2W7T$@De;0=5hfBf*;av3l+bP^0iA}e#HRd(^8GIXj61;&E z8xIAy1y`Y8!`aAejtpi8(=a1>SkN!%gfkmHroOJj+|pMtqw~J_P4Ua(`S|JaL5*_{Ky#tW11!*7sPa}89z!$3a(@eVpevAuK+jO}n!>zj9!@`YC$LMl*3pWsSvv7Uv zMqTd8aDy&)v2bnddR^{p0p|>Kxs!#fW7p_%M+;ZdyLGT|1^Nn>+cR9L%bQ!cEQSuH z?JQi1iBaXY443NiW)?1{cWYzeB8-A6w`RClms?pVVP;jirG-33PL*3&$i?!yTyG&8 z%jt5Rg>6g%TgT~68uPLG`tl@kEZk4}rFRaukvq9p}Yj*MJ<3XVG&dCC%1 zmR2rSWntvWctU98ai^(rVC2F(R5@@~DGU=`~n*N=uBy$feR^BM-%MPcib4!&K>HD-TtrMMfTch$@|AHF7`5V~pJMI8|C`BX@jFm5w%Y z!j7tRl#!T+R65eg(c@KVo{^Y*R64@Q!BbS}a3co{R;9y?>_0%24mGmx`>J$^k-ht? z(!oad=&DKw8QHyuD$O+#lY~kK8rikCD$Oyn{d!e8z{qypRB5)6ZGKgy{f%thMwRw6 zvQ=wU+SkZLJ5`!xq)Mm~IRk!5gi7 z*H)@TUI4kiy(*CtK)&BsmBI$ORzp*iV(n10V}0MX& zWp&9KK+`d?i*(5vK-1pnZ(6bj(6mMDb6v6q(9{NpDNEJ>nl#1?maG9ZuJN|kC2IhU zOTDvn$r?c85tukzvIfvN4NaR$)&Lp@qFYwU8bD(`8jF?2;_LMNsA0X|Lzj#JDBQ&x zs7uBGRD5WB8(lI6py0?ub;%fj!XTd^x?~JM#Rtdnpy|7OJ@C7afFk)WY&K`Lrj;<`mf@MCUwcI|B8}H zT{7#x@)1nxl3D*1COrM6OJ@C7K4M8-GV8y>IZ*s0oAqD$2qksNtp5r#o`2J&=Jg*p z?GQ=ok}&{4LnmD#1E4#22qblh9Do3Mq%M&M7{G4~xc~vuNL?ZyAV3(YOXLIu$Rc%# zynp~vq%M&g82Cn)$PWyx*ClcU0_2doM4n&(??$dbfD}>}$rlI^Lh2$p0|7EfT_kTH zKm@6aLR&>fe&?&e1ZVsqb`zD5FmThMe+&) zM31^iZb5+LQ5VTC47{g{qBGNUmW3??%2sfY4DF$vFs+IqD*L2LU2S zT_pD)K;o#2NFG9fxKS6$MVg^uq%M+=5Fl*SMRF1XWR1E=UP6GV zQ5VTg2#_@DBKZjcf<|2=MV{kllLLV%D_7s*+gp<#wDlD7~b zV$?-)7Xl=Vx=8*)fPhgK$zcrKp^M}(2A1n0xeNi)MO`GHAwamOi{vx}$QE^xyoLbL zqArr%7+9u@J(gUD0I{MjlJ5{8Rn$dt9s-1lx=7wbfJ{*r$$bbA zDe9uJABB18m+PXjALS!Z)J0=I3U{n8)kR}JfCgMRHuj@@q=~v{>_=hSg->eihu~se zH1?x#koy8%H1?x>B#F9c?1$h&T{QNieB_9_XzWM%h!J(s*pKp&B2xW-UHsM9zfr-z zur6KquexLF4y~J2w_Dx#y6x)v*LAIHT_tYEY_Ys$D99ll(s?zfOLTd^Pz@@o|jybT$nrv5j{_ry1ePZDn?RwW*7uIX3*_de18|LsHu_V)kIiGZE_5q^Ka^QQ9h|ET|{ z?|=>WBHZB{8+Bxw=Is%6V4CVpi`p~o?(G?EUPDu(c1##77PV!K~@AUQhLR4Ruq0F?GQ+|77ZnXZ}$`UDWSP9lg%# zH>M7tUu&qN`h{t8uY>xTshzjE`iZHn*G_F@+6>S9v4+~JADCL>&-Xo3E3dWsj;W>B zN`1@J!rM%JQ$sD)2BwskRO^|NUJJFZhEnQlCXHu)#T0p3eaVD-fa(h-oaj_*nS>|R z=S%_UGp4vF)TcERs85)DJo95F&x@;%YRFd~GR5%B4;EYpp_p33gz!|o$At7$ zy<0<#>K!KJr|NAc1gPpQCM2lpO(sOB>J27jsOohlgsAE@CZwq9RVKu!>J=vBsOn`V z1gYvJrmtdOtJO@1Qq_x0$Wqk{ObAoe^ELE^TE&DoRXxXqJXNh^LZGUiWkRB=o?$|y zs-CW)chm|dgsSQ(CZwwBNhZXq>WLb9Q$5avU{yWFgfpD#Q6@yI>X90HRXxmva8*6T zgmhIsSVODT15C(Q)%{EeSk-+sv`XE}gost$!-R}g-CaY^s=JtwvZ_0o5VNW~n2@uo zkHFTT0p@wc%*E1n< zRo5{gb5++eA#_#O)X)v;Y9_?4>MEvdV%MrGnGn3HE0~bHs>_)Wy{gM<=t^}d6T(+@ z2@}#+b#V<{sxD$e{;DoyLIA5SsG*Bhl?f57sxTpgRb?iGu&Ts_6jl|P5W}j1JwHV` ztjhC42x2j*f!Y=#IHDvrYp;}16q|7xS!~*Agt6zdUaZb@8gcBoP9u*!$7uwzXFH8V z_AI9n$)3r2fjYx!gtDhQjZ}82(}-nHa~iqqzgW*xr#g*fc8Sx7W*0k+Z1xn^hpLmE zMmoF5X~eT9u|8P2D|gY{gVYJ`<49=TmAmMG1JrTuJB_&ZFsG5%9_lm#+e4g2 zVtcUDh-?pX8ky}}rxDt^e!CG;Ti0(lLTu~$?MBFLUBBH3!L94J8zH%M{dOZnx31rA zgzVP!+okZ<_1mTN*7e&(e5>}dy&|KynrVAQBEX&DzA_}Z)15|y>$>eyhU>cRQi$ui z?NW;Cy6qyyRlB<%bL6;cH}@MrL9W`B2}!P+!h|SS?ZSjCSMAJ%Fjq}xLYk{~azt^i z+K~x)uA0PzKvzvHdSoEcEc7C`O2ZsP}I!Qo;{^55EmR zL&bk}_*8g*cx!ldctMyBPeUefL^wO#6IK6F;nrdAuw&RF41>SWso@LU)p#X%29^Ki z!F9pKK`uBWI4L+PI1v3BcEOE}ZBYI1f_V)p-VpyO{&oCAOa*ujT?6jIy@AWn74Yo% zDe(pIgX8c}%LXO7ED6@ow z@l`3YgdDZKs#0DFITDi6O33Z;EupNE?1wfpSX75j&_VrIaM`GRi0+hvNxj zjNER7s*bjDxT@}8NU=SXCWi<#wt%+{$fKbvq-6;2nk; z*&lzK+Zx&TD^=ab$UgYl8*1bh_~9I4B<539w>Gk8A5|S}C4OWFS&5eoG_pruRUKd@ z-eD^%@hSQn*{zeRZfPX_Aonv8v#hFpjqKb-Rr?s(3Gdt6$PV~z+rr3p9aOcKk*$AL z)t*Me=c_%8Y}raxyBpb}rK)x_l79ZX8i`?g)h<@zSD~|!8ovsij8s}xI~s{$desg_ zhEi4A8;Qbhb#q8;#N)Esjy@P^d`sJ!hkwRfZD!1v&Yt^5JHnzGOj4H~OS3w`~5x|*=i$M37F z+Cp!CD_xB&^z!@Ys*!8hKwx&=O9t4g;www1^XYi79~!@V?X) zTF433dh2wBmU4p6(DtxGi#frkXf0Tw<(%LX%wMX|f==*(_ld3?X5kGqYpl?sPLI6W zI89e*StocI%>XO3uoKYKs0uCZ1TUg9V1*WUf)_CKsY2^J!Sk3WRiP!G;5qaetk5D) zuo683E40iLJcHR#6ER$x+Mg%*2)hrJcLLd!kDUEUkILJK~@^2Q@{ zg_eAR+r6uFg%*8++q~O#g_eB+8sJo+g`eOCdKxYL1lObcUWFEag6q8Nb%mCHf@{&f zuR;bua19#!RmcJeuEqqY3Yma`YjlNdfZ!@`xvr2A7{FmlvI2rD>64Nf5L||4fEBU> zf=j%+bcGCo;KIhGy0VjnD&|O5cC=7JE4~WZ0zEB{#(ott27(+tLe@Z#_42wx=3pSF zD`XD@8853VWDo@Bc^O?Hiy$}`%>XN85(H;?=jsaC1i=~JCAvaJL2z2*8eJi)AXrR5 zWti5kc|+`^N!LLG7^F#yoI_# zR$^eDu8^4!9EJvhm3|fu^$yb&G8B5`5bsc3>0{v_`lP)r%=He@6|xn2+Fb7-T_Iy3 znB|St6|xqBeb6SbLgqrSx3`b3ki8J>$#S zu7wq{9s=rGSRwNvpss}#Yd>f@xU;TU`$3;Ud58=pvc96}oKw$3v+?m#zN*y6Lj@9}k5JUAF$?p-!R8&VM?4U3A&`52_Tp?ED8s z3SD;o)4}Vg%g%pLqR?gMKd4aXvhyDlD0JER59$-T?EGgludOaS|3P&^m!1EhIHAkV ze^8syW#>OAP3W@opB7$AU7lyZ@hD8_vhyF*C3N|4`$*DD>9X@5R3&uT`49drq|45K zs5fER`49CbEIa=}MM9UI|DYhD%g%pLkI-f3KXEV6W#>PrM(DEh9~2{W+4&E3Ei60# zK`BC)t^ZIZLYJ-oP}>Qr|NjvG^53cd|98K^|Nq>-?+ncAn-|TF_CyE2k(k)mGirx^ z|4r!O_cdnry{ev358}N4)vAiAeM`{s|6sK@`uL5(+`c}lqpHVv=-=fxnB4cKT!pj# zcgpMK#WE|G%H!qXaz8m$PC(cH{<5oVEv5Kd{2#WsHjY!;iCw4-)YmJ$;uX*XplF=3*1QR|F*@!(KCllqHfG9FVn845rYt448lc^jrNmZ!A~$6z z(SNJRO<79x?-RKxONlM}M{deeV#_{}o3fPX+b42UmJ)piMsCVdqR)WHO<79x*(!2V zmJm!uZpsppsmM)PLNpcq;=ay4P0`Oz_gWXZDN9JFA~$6T@l@odEFqtY+>|8*RFRvq zgoG+`QMQ+LxGOFks_x_u28EtTSv&|znX9+P?bQ~E>{eP&boL{s#sd&au1(I-x?85MnOH4>}nBd4d&h(2_B z&*{+zPVcd2^uE*6_K4Q7emi>4>8aDAcb(pSYV?lNyS^R0?evu0qqkV^8olZCE>og6 zoSyu4^t#hK?h?JmdUEut(-Ys0UU7QDj?v4kCq^$hJ#Ip@+Uc?5q8FVW{dV+%(>sig zo_BiG_R%V*M{XZI=k)fYqLr*iM$fW-J9@_H5j#Xrv)(>h;q>sgqo*3K8 zPH#Ijdfe%4wv8TR{ZjO((?iEbkFee@dYJV#(L+uT85%w4bpNL40jK+Z72WT2pB~YD zPH)jOy4UGmTSWIb-Lp@0H|t)}U95XVcRJmpZ*&Lip3!pFUq!b&-K|q}o74DBxYg+{ zU87r^?%XB1+38L_qMMxV&_BA->2@8WWlp#LIl95=R;{D!oo?AGy3XkqEu(9l#&6Lz zPN&*MSF^5*u5wzZqAQ(NI=aGXsiMoB4rO$i)A2C6)c&U7Zs8^NH%&K17rSTtoQf`T z`m4^-g-*XcB)Y)qm$r$xeoWz4#Pwse=ox~`Ii_Vp>~h+3j9nY zU=bIPeMDeUjvqq?7G;?bf<+l7q+n5+2{Bl7J`?3&=eY_sg0SdZeh5iebPf}uu;}dn zgT42Rma5p^y;tX2RkhaY-sGStf+$%)Kt#zogCtQv1Vlx$pn_mJ=4|H#W{+9%D28Ls zia7_&=@>8`Gy6WXE7Zw_l;Dx!T*HMFe znkBel?x~hgHQZAy!4GpcS%M+vZnOkP%str>EHQV3C3s@)dP^|H+>`1k>Yiu`wwQZ@ zA@arCb(Ua^xoa)K8FP=f1Z&J)V+r1vyV?@WF?W?ExMS{0OR&e>6_((SxyvoVAaj>l zf?>M1k21l%o02^ca9~PX6~Vu z;F`I!Ex|T(XIX-8=FYSPicdyAtbM5XmF38BZcJ~@L zWL)Oj-D`5vT)TTsR+?*fugObu?d~<`eAn(?lbhz+-D|SbT(f&!^V3|jdtEcsT(f&! zbJSe3dtI~CT(f&!^VD3kdtEctT(f&!bJbk4dtI~DT(f&kw9YlV*EM6!HM`d}XU#Rc z*EMU+?PI^!@YdYkb+p3mWeM(@yPqZ4Yi>_V@YmcPb+pv&ZV3*X+szUzHn(dX9qo3p z1e49}YzZ!#yRRkKZ0^64~OYqy=c9vkcxw~6}(m4g8PH5!F53;xHLE`*buD5 z9KSiiu8dgC*`9ccMGQ?c;WG zTVn!Xi2Vrf;4QYDJ;=7QYgmC@z)r;sz@ylqYyuk$=U{KvipfMHrUZPFcq8!)rU2fS zxC*|(xrvh#D-uU0W+cWW1}1tW+9#S}4&d+cZ{i0h|^)9vOi-v5D{t`rsyq)-f;UM1P2W zihRJc(FdZp!!5{1&x>xvT@DMQGm#BADB2U#9Gk-{_#?72@?qrF$dkwg+#I<)l8Ky+ zd4kK}6dV#68ySp=f}JDnaP}>RV`X3BSk;T+N5glAZ^0>>A};+q175*0%oUtPJtxbO zmm3QCEn2cHIk}L{TCgnnxR6bou`Ic`kccH^$-{-jZe^AnTuAIzX34*W3{r_KxwkH4 zB1_h7$n_gpmV8@0{6b%zRjw^Qh@+ZW<=Xm<3Ct15D%aL;j61*ata5GrT|dLR%PQB_ z-}M+gC9*S=(xYwPdY7MjAcoo-JfF&a&j#LPnyAEcvyFe@7Bo zGHXMw*T}QVsg1JzAZAgX! zyqGd-Wk@z2$TP~Qm3X5l&nTl-hR9aTD5F+}$X3iKqgIBS97Wo-gPMj5rTHUT1|j9Q61Q}EKtsFk=GOY#%Xxkd5mYqYIurZjD{x&Mr(MSV3dZ(a4k<}qyfyJ$c(V?6weH| z@C45cv+y|24At-uCQW39Xn4?hh-U^HXk5%QgESy1%`*pUxS!x43lH$jKn=J|gJ%xZ za4)^U01bCLck#>t8gN?%&-B-Dmvb-A^s}&yXZmWm)A=XQ>~8@t&_}~RoI81@w}w0D zeR^rw>imOe_OoyY&-B!A1qMZAdT6+upu2`G7)X)nW&w|M)lj9UbHPLYZgwv`}(0dk}=AlS{jqc3NlB zJhQug(q+ztJhPjIOP$MjhDrBEi zI%i?JMn=^ef^&FA)f6}TQqw0-x&T!6jGqrky_dIPA&rrFcw>i~0jc2If z5Fq>QW@;rTEH^o4xEZQBp|ISDd~BvxbwYAOnq{c$ghF!tMwX$XLvjPlP|1N@vy^41 z;6Sd1q;dnf5)y@hmE~nko%Gx(t%)4CHZ3-85Ahyz^3o?bB3b@ZqIvSei-< z$<-`P1%~8GmZtJTas^9MaUr>!rKz-#T*lH=SV$hn(o|L;mn>juDk_jiEn#UYDUgeg zVreQUkc$?xG?f#`g^O64iU}n0;b|%*kPD7vX(}Wncd|5<5y<&?T`D4wbLX=(l@Q3o z=dv^v5Xd=rE|m|++0$8?iU;JZ*(^<^19IjpmZri1Ib$YEQ`vx=K8K~LXpo%2(o`}a zr{NW-U_c`2m!@(7IpuJcp0DLpmZnmHk4~Jx(o`rQCrn{!Die_7C$cmZ3COYd%BVy@ zjv3F=R3J!>Woar8kfX=2G!+NPQKMO!N(1EZQ7lb`0TMyJG?fL&!NXXZiUQ<;gIStN z0wfZ7X(|Yi{qb&84j}vXVreP{ko)&#X(|PfNZF;S5J2|s!_rg+Aba&^X(|FFd$Tl^ z0Lc9Yurw6_l6Wy%{vmtdEokwF?B0W=Y3YaT)}5tk;fL(ng{5iPhwQQ+OVgrHvKvd& zl25WLOVff6*%{BJ`S%RFRzd=0e7Ln39G zrX`+adzPjJ9+>rUe`l zIn^{R-;mhLOw-~GnQ&Q}mTt&+f~9HUhD4e_P0KbUB713Cv|Y$VnpW(PD;rswmTNq` zb5EA0#Tqibo~LU|byzkocDC|#ZLJ0z=)Azwda>U1T?qebTC9A3Iv6A*YOEXubhZw#%*kR$Q+(pn5T0>3=8 z6q6ju(zFmm4j;kNwPiSjEi;y`t->Mfn6b24gt1}9(rOXLei=)vMHt&TRV~8UDr0H22xF&=U8WXcY?QIf)FO<1GIm*Q5e{LSj9sSIVC<5y z%hVc-O)_?wT7$7i#x7H9Ft*6pWoiw^4jH>lt-;tJGyDIs3u33Db37X9@+q+qu>&wu zZcp4Z@8TA~@1mcecifKN@wVudNS2=$J>|dNX%2o2J`dgrUI-oyw&5;-EkQas8!p0% zU?FY;7#|D{`ULv~ZG$B41Ng=N%75Q~#edSj4>tl_<>&qL{Zrr|EWw=sll|d-e>ezx z_)T#uz#rbX-pAe>-m~xz?(nYj%HGA^8QuxrQrrx1h&LMUK~JxP*V2=oBY%{i%Xj1p z@=AyCzByuQb{SArqMMu9oI{GNa!F-O< zCeLED$E_HYQ3zicJ}ta9d`x&Q68>;|?`?D>#yjfWG8y3=^lq7K=WAv19ytUDjr|?!r zPnyhI8a;6mZ(;O=iM+Yd<0tTDMvt4un_4}dH!*tbINo6Nn6W%%^you)(&$lRcwqHt z?i)SwP3~!%R=GC6TO!-$x4#Gm#;=yhxcS8rIX6FBBJ1YghHBo;PnO8M`Oy;1@BE>T zcJc2m(FD)$EYS$hZ!OUb&z+WNi03z!$iw;C5}7z()zQ!VOG|XY?Y}J14YyxdA|vPX zzX(pwXO_sy`P345IiFY}Gv{MVw|fhC&!`MxEbRp;+nB1>mS9ev2( zwM3@QJ9YE{f7=q-I&WDbU*}CrWbC|QiJYC+Es?eJS{=Q^U#+9J`74&l-Fev(**h;; zB7f&aOJwl8V2K=_=Pi-N^PDB}c(&Kki~Lzj6%@P?v zw^||x=oU*`U;*7+M@4>;6B4;R9M;BsSfD#z+K5E{OeTdAVv?X$fF0(}T(505hAG)ND&cVbz73xq9 z(M6WXBD&BLJR*L9eRG&Z=i7(KB|6U%Y$ARxJrN@U$S30G7=Tg4&(_riP7y!L==B@< znO1M$e=~Z`QhtWftC#ZAjb6EwpJw!mrTkQ*moMd~7`<#M-(>W0OZi6gMZh!SCz}VB zuHhT3Ud`8Ay^^0~^$LEX)yw$_Rxji0tUivfHG0Vce!S5~E#Yg7UVIc^ZS^Cd>l zoW+kadd5t?*y!nV_#&%k@P$TCo6e6kdg?U3!00K5^CPUD%I6zBaRQ%b^n@vVuF>Ns z^23cDyOSSg^qBE{j@4uNp+=7$!)F^kYBZl^^zc!9rqPIw&M_x zpJsIboqVd%eS7gKM(^L3Pd2*G{(O?rz5DQqM)&H^Cs^H^k2iY10eqa*z4%z8d+g+6 zjPBlpk2bnncRtGKu3h*@qr2?KM_ApB54XB2A7*stE_|rbojUR%M(^E;4>r1EXFka2 z4tw*1jc&h_A7u2N9r!@2+w%jBZkgf(jBb(Q2N>PF1@CWliuW_RX^QtXx=D)fZ*)V7 z_c1!PlJ~Z{CGTZ*3%(!i|F;eg|F8D{*Q0Y^6dW2%3Wf&<;3mJlf|h|7g#Dk<=>NmN-rtP-{Lb+=`m52sAMQ`}NBIN&{rrx8D_?qz-VffV-ka#&AMkGXuEwo? z=V9-ErMJ+V>5cUcLhrtp*W7dEAK3T*P`)akl=sS;<>lzy&ypv}OMPxZCe&ahRBdz5e~hKBA5A(YgP~KjUxl?YP}(73*%Sz6?>1pg#LXS&IVL5^7;(6jvdS9vMFph>&H5ygHPgQz`qk;;1+=A6Avf; zk+?2VN?e#YHL(Ug{Go{nxaY5TV(&z&gp4=FzmIDXDZlVZok=EtVRM#81(in|&ca30{7 z=$FwQ(HEnSM(>JVAFV_$j-HOw|3^m;i%yCTjqV@aC)y_JM?;Yxaq|ByoCSCg(*duE z6e8zGPK6tAEOz*3YCoV0=IJ(zaGVDC5tCPU;NHL|G5`Pe@U@t6c}e)p@cQtI@S^ac z;Ys+R(f?z4<%0&@7O=eXL4$6+SYG*{LAU)_UiqK_#-QYt4;pmq!Sc!n4dDFdl@A(p z#&eYq8en`tUiqLw#||v7e9)jnCze+}XwZI7mRCM#uxCe>S3YR4dwZ5wK4{Qlca~Q^ zXwb9;%PSu=XlTmv$_EW#s^*ms8pssOlMfmS1#nXHH6t_>z_-aOCp2I#%PS`|z~SdS zIiYwiOw~O3ppYdGbRER$)wao*Yqv386c9o;*>4@dV_G5{wCL!(T%hhTuGGo}5v7q$8$T=gAu- zXde2S=gA!hY=E*H3z%AE2Pkt!@ohZzcV@g0L3UlO{5=;$E<2iCoYp`n@ z&yjCRFa__EBj=Q0QfM^Kk#|Zkk$~J&f(ZoVpAw8GAP1FT907T#1Y-%-Y8XRsyoS-C zNj$g4!bF~1ZD9h>t+Ftl=T=%6$8#$zjODrI7RI=_WdtEVG`f-Jj?)i}*!3CDE!8kM zG=k@j)i4N04Rgm>7{qf&8`$*_&n?k#5K=F>qcqSV#N1*HbOeKemJX`n{NS6o2Q`7By>TKjcesY0q24@qn1&t%b1d}axkEK{ z4fWu;*&4bK%(Bpx=Vof?6zalrGc>ddwc@$y8g?f*L<5~j%uTa^N2Y3MLr;T+EHu(3Bn-rJ)Iq zC+0?4Xv%XVG*FBvH(Wy!M;dd(G`KianH#Dh2BRi7L<9LrxxpIfbYX6ghAq?B990(rT#C(e zR9^_lYsyiDA@~$acaCZd!6)#Da#Up$e9CiFX9(yfnjBRcg7;ww<*3#Wyax{`*G|I@ z=RKaIdP9%A3#Tea6-U7io};2e@HRZ2TpJCz-kRsA^3WsX6XmG(D0q?QRQ2(l*Wvc$ zRQ2(3mCfrsr>c)%yS*l-s*hj0y(XuskB{4H@II>g5WK>3s`~h~+iP;F`VioKRQ2(l z7wI3Tst>_SJg2IUk4tXwG*x|kTxEkF7gc?H+)?u=&#CI;*RG|>sp{i9kKrbooT@&4 z?T(t9sy+md@tmqYzVisZfT}(|F06Tk=XCXP?uUJo)78fz4=AUrk8>Z)o1Csb&b_!b zBB!elpulsw`Z)LC1+u#OINO}tc~(~+=PqX(&+6*q+zAUQtE-Pg-cMFnALkC3Jy~6S zoUP6sJgcjZbGvg7&+6&}*vhlI`Z%}I`{?T9-0IxMv%2~K3OuW;k8>lg%*pEN8uK)#b;bYj(1_{5TnSMp<2coHT5q ztS&#!WloxBb@_3~Xv*sH1GtE1b@{0!k+Qn{)RIV9U48&~nl3+xCE*Lz<)@ZW%Ifli zj1r!v%MSo{lP*8bc?7!rAboTu&+77n^wBvytIJOUMLGE_rCYGhj1Bt*< zmg)}V;p_NF-{qgO!{hKvn-tZ&$3iEATb^)OH~6BH;TyR7%Js1-l7<3ET1t}!|fAT-`-}Ybd zAM@|_Z}Klk?*Fe_00#NJ{Z4)xKky^S{D0}a=e^`T?%m_v>|Nnyy>rp^uksdqhk6se zpW-I*a-CM`{ES8hb;bgiH{Pm;tc=Yi5n6X+}3w0PVg^E%tR9Zz(hBk-%lkXxTo*4 z_?zg{{~5m}emQdZf5X}Rqj5vug!rKNewf$SES`w{8v7C{{O4m2#kR(-j^%J_e?5Bi zc}P+W!}XB@_gfug(ELo|pe z|2O}fzvYjM+=m4JmPq|>{~(<7-wVBc0yo5biK|bZ$88^5k=@T>n%{by@}C!;5*`-b zKinzY%5?Vs{r~?t-vPZ1;Nus2H(@r&{1E=y7iNRZr_{eN8)SY6fA0&kLFOa(9}#AQ z%%|YLFdJk(CI5xlAoJ;OeqlDqd}RL{h1nqU5%J$8%mx|$(s%w4ck8_i`nzA4{W1Dm z|94^b$7+B13$s54YK#lBKUVwOUzq(dqF;sC9|JWegxMdf{rNA<{ut3O!t9TM8ksQr zW3>)InEkO@3n0w?Sgi*TW`C^K1PHS~R_g+U*&ieNN|^nzS|1?H{#dOM5N3a@)(Hr+ zKUQl6gxMdf^#a1|j}d(;%>G!d8xUrHtkw>Q%guji>IcLYOK1p$*&(ZS1j6i)5$zCW zhpg5U2(v@xJ7@}o*&(ZS1;Xr*5xpqP4q2@)5N3z0)))x0LsshyMAm*Sn87YGme3mr zvqe^G4ushvt91v$Y?0O417WtvYW;yQTV%BcL6|MFT8ALa7Fn%D5Et0@LysWL9$BqP z5N408)+LB@?K3fFU7S-#kBPG_p-~WLSwg2E&a{M9LHx}UdIfQYB{U1-bW7+K#A$U@ z5T{x~zaUPrgoZ(EvV@L7Y_x=yL7Z#}J%iX_2~C4oZwXz4ILQ*)2618?Z51b2LgOIT zSwiO^*49x$9B&D|gIHq;&4XBN3EhKOWeM$rSXoEcixrm8K#1j*&_Rf0me4|o<1C?v z5KAqgi4ezHLKh*9v4l249Bm1GgjiBXmy4q;p_34cEuobVi|S~zSZE2&ggDX?x(TtM zj!NPPOXw%W{5mR%d6v*oh`E-~Qi#JXp{EdsSwd4G=G4(e;!sQ2%NDaOVJ}+;aEL)nvV;ypOsu1`!~{#| zF~oSg=%2PpjI$46!&;2BgbiykMsE*OpCLy71*ZtJM~6-WiA>to#^1{8Hwv>yhh9UN zJvuZSVwiqibp-BD^DdZ>)?a*7NBzWymS9hc59+ADc;6BXO7Wg0IFw?CC0LZ=T}$vN z#XEJ>Q@m{nE~R+O5^PHGW*v1EZ&-p+DPFe(r&7FT309?e)e^i)@k$-F5-(eVTPa?$ z1iMnaXbFC$c%hEkiRbHRckx^u?IyO@Q5*4W9kmwESb}RQp0)(rQaoh|zNL855{ygn zL>)C1k6VIuDIT)~?-ILv`oH+>!?3HT^{m;#?6~^VX9=_8iuO^M9ar>^!tA)BffQ!P z6&)o0O4hG4YKSm9uIM3!*>Ob^Da?*5x=5se_49`f#~EU+hmI6x#}$nv_8axbhfEh{ z#}%!lFgvd3C572>MKdY(GtVD1SeP&-+DT!;nCK_*x4eGM0|yEd#`Fgqj8n||;{y&v zK3VI21B3}oD>$^=txw!`s?%}g6Xf* zYZM9nIso`C; z|KBov;D5FM|Nnjd|9`jtzc;*qq#yHs!+n4sBLVQ7_lUR6y9sVU&O6^b1?T@4d$Yaq z-XO1+*U@X`N!$ncz5GPJA)kR4aGSgeC;!itC)d*ZGvpXKQ1+1RWiy=m|6P0|J`k_q zF2H-lO=63Mt7yV(4Faybq{iTVnSeZxC(zD!~Y?Bl|9Ms zWjC|SaRTHlb`m>|&4;fr5-I+!tQ~7$vBWQ!4!9%nV&c)nU5V@AEL@Bn|JuaSiNi1( zaA;!x#6F2O2|p2v{|Im4t@!r%gP06>O}r34KfWoxD!wQ_D?ToMFx-U>$nx{ppRsRa zAK@;5r(*ZTZi!uiseot0Usx79B6bMw0T_Uj8hgZ=#NwC<_*L}1=u2=IwncA0h z&xo#z9)pR1lcU3;eKG&PZ8U(#@KfaT$lHXs|x8j1#EL?&O;pO24m<2c*KQNBNi^^^cLf?gc09GmERa(O!UvZNPZ)>ghQY08p4ZYI1+pm`ivLJ zaU}SF9wE<>;C&2`E|Tl0-~(PH-;va1}3-g-LK#=sI3(YXP6rMgtARF1FTC zr7yIVh6<)*7h77W@?r}OG|#%&T*IZI3wg1bhD!*VYPgu7iH3^^8Z=xO+RBS53zza@ z(!wRY7+63O)wggFVyPO=pjVR`PTf`FMWJC+=wx2x8a9SD@uI8YWXz^6G7Bg0VnV|P z`kc6i^`Q;C7}Ic42v3V@I5D)I7b6x<;>ECr6EFk27_zX87aa|2@B)QR8dei*w6KO3 zPS!vJu?rhCti(XuWLd$sJBn`_$D|z8W3oCfx1P#kDlenF z8!wpp(>c_Y7fk)>jKE02)Sq_rIi~&um}gxu^+z@~@WOEY6>tn)DVX|0BcBVV{$Ml^ zFPQqnaDt^^>JR<>RWS7@PERxShekdZbp1hA;TvAi^#@r6fUZBzw*yPtA<8Hj5>rXAkP|)?q!8MS)pz9C3 z%FlQ~*B{J3!B?Q`kMmw*Ctm2TzB>+O6$-lk)Ml9!bp1g*;ay(P^#^X_N4%ix526O! zc|q5oTGXJR>rX9RP|)?KmM$ph`cumg6myPsk4yzP&{c)besfdEEKZqwh$qTyv)Zz&R zU4IZlz^m!{gAl^wyrAn(Es;>r^~bpziG+f#KgbZ=%?rBzARKTTFX;MH3kMW*{Q=z0 z3%dRw9Dt|k`h#%5HeS&62YG<4yrAn3@&LE;g0H_j*CP*5(Der!{nzt?u0Po5zm6Al z{lP{*K1bJ|+OB^=*Pq(1e?iwDY|UTA3%dR|Whc)Iy8d9FzRU}{{?xYS3%dT)cH;}W z{y2H;;1_iL0Tg&a*B>WGucqq{_USL>d0l^M`}BETf3P*5<9S_wYMb_XU4O6}kI&Kd zr?vr~*YyY6>^T0R>kqcs-*oe}`V$JAGpjtW%Ma#toWk?E{5YrMh(%tPALlgZbe`Ac zr?#1%*X0L*&(Y-vJL_liye>brZT38spHMj1gjpVWsy@h%p1g_WsrEpgbOOs$<$*l$ zB$lVT1Bojf^Hg<6u48$sIgsn{T&g%Et1M6T26FAmEKk*jB%VvP26D|RmZwSsxq1!D zQ=NfawVLIr%8*>i@>F9WSK_%;VMyY2slGt2Sj+NMT}a}&R9hgIuV8tqERai&WqGPA zkjE}%d8#TTk70SLDUiqD2~<%?;&rK>KpwrE<*AyG#B-^ZKrT6&<*AZDE?UC!R7W5e zE^_l!MezMwfGaWcR7CLMc`vg(l@Q3e^H`n=2;}U!EKlVFa>gWW1N7#j@4sewI$ z2Auc*xBt2S4w3_pA~|rge}$jL#K4XIDu1zms6Wvk>i6|K`@8!MejI+mH{OSs7x=Vy zKTZr>;}yLNz0ObZ<84e+{q?Qv#+%U$pbK9z4_R^Y?(PIG&?}VSjy?(dj%zrj~ zcKD?5Qk?jo93C3(6W$vxR+u`hQ~z@|!TZP#} zYq5G6)}L3))#FkV^&?OV)=T3f){^zY_=uog+r%#WO!P|Pue}$9>%|}TF{JCo@0Jj+ z7r$9TzFz!l2?2ZYizOuN#m|-yu^0cggp9rTsg5R!AM0p>_@R!*i|^}bocOMe#)@z2 zXpGpY4ONQTi*M{h$l8mqEg@_#zOsb0z4)?@28no6NJFhY|;c6K-nP8CQT4R%OfAPUnhDV`A8ix`LHE)J@TPCa^-`T(D%p(ETQp{ z_ZyUQ1|xG3IrN^BoWXTemBU!YB?noBu=uw%sg#Jk8ETKV?Sxe}U zWX2Mb5Hejyx68{cp-GaLT0)m3FR_F+NnUITeUiM$5*j6Wp(S)m@&Zd}mE`%B&@0LF z>gXDIt|jCf4gy(KhH@+3>>p5%#o`K0zqo?stB|0LI0 zLIWk&T0#dUkGF)RfLv2Yr^wZo&_u~qb#$s+sdqi8jgsadH~J`P4sxTBlI9>cIw@%m za-)@!<{&qEDQOOJqnVQCAUC=xX%2Fuos#AtH~J}Q4sxTRlI9>cIx1-na-*e^3-ul# zdMarSa-*q|<{&q^DrpXKqpgzj>2Rl;$8e8Y^iIa-*}7<{&p(D`^gLqqmagAUB#T zX%2FuyOQQ0H`*&{4sxTvlI9>c8Z2oJa-+kN<{&p(EQC;LKNEimqQ{cvBsZEYIn{ij zbLUBOk{fN7G$*;yXGwFC8;zDUC%MsSNpq4Lt(G(=xzTG$bCMg)mNX~1(QQd{k{j)o zG$*;yZ%LC9LBl0YN(3F3G$|3ZTynViI>$|r<|H?oE@^TF=(?oI6`<{sCRc#IOPX8( z8ZT*b1?aq_$rYgWk|tMx-bOxdNP^l_pn!Bec@w3UG#2np^=6(Mppmz$sd3as@a>D^0Ed z=V+zL72qJPG`Rwtq?P-c?_tvx(!>gImR6cr0S?nj6Dz=JT4`bhI8G}&n9p^EG_e94 zsFfyGfD^US#0qevR+?A=&eY0w=5r%4xx3y#4%)|+aW3a0~5hwe5B|0RUV{-p*@vq`L;xEJ>j^9z+(?1XA`j^L#z+HbM;{D?L z#@oa_-1PSycJyDxss6iTH^eHK+J918rhKEc$=qmcK|S{1eXg zzY~4|v-`J&Zwzkt}d5qpz6fWsR<` z^h_6$Y+UVJfXhyljgo9U$|-uL3rRLEbSj?dLXwS>oUNYeLXwSRojWn))O_AS&arAAy^bfdA17)j|JH-1lNL&!YEerDx*QR3ki=3kB4n*9vc-N=h-da6dfLb|K-u;eI&JWq{EbSE@jH$NfHTU@QNAFlm?ZSJ&0XL7e3%B!*wzL~) ziKVvT-Mpjfs4a4K=JVQw+j@&EwG6kx93JynbG+Y?mYRlJdJF2Pxp#ym8Qu%Ccg^Ry zyIgOcr37fMrC2zDJATYFW1(NY!z_jI%sG}q;TX;anrEWlKsHbl?qv&kvn=fjHDY#< zdF*$*<_t@}g?4$<>*#k(2sY3BHSBuR{-V%t*iA5x{T!Cw6iYwuYVJ+8^gU>jC8XVO zg3){);%?pqOUSz+&uAV);LRIXN8e!Hk$DV}H*bt3WZp2x*gS^N8}39hgwz}MJPaZB z=8dSM54_=)5Pb87Swiv+=Pb?VA^PSGv4rfKH`o%wZ^&SpXCnRP9c&5lH_T`-vPGK4G~=1ToV_jo-lAq|K9Bl8&Ia9%e{$isPE|Ds(Jye@S#9%oj~ z=OGg3?Q02{IBy?I2*r7wEFl$#yRFRU-4MD7Syw~I#bI)_Aq3+v3(F9aaoFWDglL?% zXB}Pb?O_SwIImqDU4@gi=JODb^LDGFtG%|C5Rk*2TjrTa$a$?TAtHxFu6e8ys$#yj zA%x^S+ckxdlEYq{c?>Z*ubCy}v$1e7Q zI=aa7Eg>%Fd6tlu!xV7yc?isT!V(g59=C+Zoaffj2_CbA(43dBgw&iDw}jZ77qf)i zoENR5le~x}BfkcDLVOYONi0QpDZCqCx5hrAf5cd5|VWCdrOGY$?q&7ODDgz zgfN}lX$d<-@*7Kt)5)(bAx|g2vV=gL{IZTF$$wcwq)vWe37IV}X$cuS`9>WLl&@Pt%1*vkM+4=nmXNcPuUJCRPQGjjNjv!x<^T5x zPjiAFf=`1tgJ**Wg4=_ugM4saun~9vEevL25C5Q`CnoD&7_^VE_Ie zaf`T06vTy?{C9#l4m0~_it*UN?~C039-^7xyb;s?zTi9f%h<#JC%>Ivi|qd;{7k-{ zufPPrL-`~=oFBlu@%ESjD0#^JHzxSM@8Tk8_kPUqzs{|=mtwBK2KWSv+(U8G-%z)& zyDyxAzzwsX*yrpW_5x-L+=)s4Wp)v~g5%i|Hiu1QLs%cyiM3`Pb1=*Q)5M!_3m!<^ zp13-ZPn?(7m{^%ui1`9zG0necVlVgwZv2n<&iIG%SL08{?~UJ#2?J@&^FJwmTzozp zgOTwA;$7qI;tlaw>=(=!*nx@ukH+qbT@TOTlGqusb+Kb`U&CZf8R#3^H`W%OK{)zT z^z-Q3(dVKMMem4S8!ckaz$wwy(Z$i((ecs2m;~54+Ai8GDxwa~0sJfSUgQ@5a$7&#nga1!#BV;$b`=g zZwjvoAC1Jow7PDYl~r~r)x8@ltL#z=V=c-myOhGTzp~0MrEtSVS!I_}9ow_A$}Xik zK&tFgYA;BYT}rj@$I2?Zlxp9Rl~r~r)xHBOtL##${a&oBvP-FUU$e5xE~R#dRN18z zW@MCAb}6-6OIB9drBquyL1mXxZFXa2m0e0I3Kt`mRdgv8;L}ueDdh#MtfEUP>9MkkE~PNDu&km>DK1!9MVC@8XJr*#N@09q zSw)vpamLCjx|G7q!m^4kr7*LwOwlDbl!_$Ewd9hUYGh>WffdX zZ6E5EYr&;(>Jo(E%9LCRhm)aXNboXcmk0vfwpgb05`m9oYMJs&1YXGJWlAs+AZ@_Q zlwl$e2+Ecz#Z-ev0OgnnH~}S@2pEF0Wy&%U(8Y)4z8d1l;FkBdkl^J$8e;Ud^|pW) z=%s;Xp_TX35TWG`!q6mX{keyx4dUFQ+ungs^f_ z1EqV*fd)$VmVFJB?k#&7o<#(=EHylXplw-bc$$D~pmcB9wSY&Mh9~K12@OvW#5Ful z5YzA&K~%#d7^YT^Sa^n)!xr#9Aq!9OvSZ;%UfN{g30~T0;c;F%*}`MIv_ZpNh#{BO zYq%3h>e5LT?&75rHEeC1#7ie=xDCU{O6xS-jNou-t%hqG7xL2a8m=K&qk*P=)e1;BxxqP&l~e^uZrI35s)8gpoXkq9f+TTwUrAMv zKlBys{gnUb?f+P{=EvX8UJbt}fs#TD1a*cB?E2$EaT(g#yR0&D0TEj}Jgd~yY zEvXWcTyQ-rsS=W$e+4V45|W%ZpOsVzNzPrsN~(k;XU=s?wGt9e&TuYZB~?R`xD2nP zYDjY0c2=Sq5(*`!3}&TT5eX%6wO2`1k>mt?R8^5AF7Ya55^l) z(SSVYU{<1%0Xgs>R-%FdIRIZel?%uN2C@di`27$Eo9iK(^U~ zm8d8{VsclBN&;lF)~rMo0kWYPD^WdwOg69*RRc&b$x2iUAo0gRi7Ejkm#jo}0FrT5 zqACE1DaNICN@9v}i7Eg-ioXa-wEjclFM<-S{w`#qL`#3jO*XO;t^0WR-L9-et3Kq* z_~+1?54rsSR-zRj@_Ky7XuXHLVje5eY7bdm$xC{zCv%*a^jc5eI4|k7o~&_R(rZ0A z%t)Ds-Qi)yJ4;CBz;MYYru^x{Re)Cc5! z^P*bn1G2t(Q7!cWlEQeJTIvIA9`@x$wbT>z;YGF72VvOWyr`CXg5JESmiizJ)0-F7 zQXhojdGn%L>Vq&WZ(dYOJwZEOR7-sjhT+YNwWS{4Hu&AVSX=9B(3Tg~VjqOzcJrcI z?1M1OZeCQ2eGrD%&5LTW55lmzc~LF)1UR#-7W*I!qnj6Ni#=W%J~uDcR(k+!ZeFD2 zp8mtZ<>p0N?`shHl^1EnCxFMzi?rqwz~bgbTJ#l!d6AZV02$o8NDDs!{B2&OrJn%y zHZRiRPXKqD7isxd@H;P30U&_4&5Kk5Y7qL37pVpiz}eNv|Dsf^ptMo&&ap6`~Th!o(modw!%lq1?L3oF-35GFfAAn^b7V4+6128 z=zr&b1Q+26oCLVRulN`Fr{d;-MgB~GjDMit&2Q(YFhTGq?=#E{c*gsucZ+v9w$1(q z2jOV!7fgUxy&rtiW?n-6D!-KP%ID!9Y?W7ITEIDSJ#Gv@8c7b5`@=hEC58A??5xcS zcwB75eF0^-2B(NsVxgEJMq~0vS4;{>iZJ|wPx%}CX?`ESnQ!5j@-z5a-1B!hpTbA* z0hm3w7n}m&Heyo1XP7?ty!(iImwSV|*}V)t!FqSOdn9HBjB^KJ0%0e&4Sa$K`-U%6lARdYR49DQT*vm*6+>5yZS4KX@d4Ojz`Tq{|`sK*QxCiir$kNFC$RUx@ zk%2f9&>_-1!ot6Wzrxi27s3yR?+9NL&ZEcQ5MCCZk6QqThx^ub`Ts5dW4;4=V>-2b zwSS$C^3k84-dkqJdc#KVO{-7#-Y|Oo2JdyFPdMIt&FFO}c&{40cAfW%(Z{d%UN&!l zKSRBj%mZuIdM_HiYK`}T(JNPZ&l|nqdha=-=U?G%H+tTD?^&bgF7Tc)dgfg3Y4c|I zd(?Z%JTPOX_oUI&wtG()J!P=>xY3iRc#j!9VX*h8(c>q0j~G36viGpnnOQeoI#y4q+)RLopla@#= z+3uUPL~8MN-=rl{i+-NX?z+ zo4iEo@VUOpOQa6p?q|$v9(K5&HhRt?|1zr&^Di~}&^i7kM$bCbzu4#*+x?4-o<7UJ z(CQif1x6n--9O*xsoVYYjGl6cf3DS2{d0_-JjFlT=!x6?vy7fF*+0|jiT>Y=9=F{; z!|1US{L`%-=bvWusG=Na8*H-E0xZT-WIZr##9%;;8a z{5e*)_763>WjlYi)vf$lMmKNb&osJO6Mu%$O`G`Bjc(H3Kg8Q6B` zXyQ*c+6(+iMoZ71Xta?21f#j|#~bZ(f1J_G^~V|=XZ{$YV{w19(UF)xN~fk$5r3pk zO{E(B5#||RxAcb_y`!r?%;@by{h@lPCZE_hIfR-~?3)}y%_;Ux4xwfh`zD7_^NM|w zL#Ua>{y@ExRdb7dlS2SCO5fxVYJRbAatJlU*f%+Znq%yn96~Y#%h)$L1fqm*atKLu zZo=i^jLehbI?3)}y(t&;Kn;ZhsF5l!3l9;~xr@s$llBU!8CWertJ1cw>LjW~)^GysP>A+I< zO$;H4@eyzMCWert`4N6o`+jhheG@}SI}6GKSS)Ck|i5RwjzW#7aQk~BBMH!*}H z4UX_l3?b>jTlP&1A&JoukKp1n6+=j3cElsTi6JCuc!Y0a2t<$gCWes2EQv>O-MRTZ zILy9@AtW(P;t?E;Hjlw$#x|=Vn9RP3AtW(S;u*A0e?9iJZ(<1a*i*iVArL+3n-~Jo z6S%DHug`njGcg2u>@m;85R$mh;wfCpZk`FZ*)uVOBwcTT1Kj2@_|2Y)AtW)6;!bQ0 zo5$cdV@$juSk9h_AtW7m&S(J5V=$e)A1%Rk#+7pBG1$(Yi6JC$W5ng2i6Ibe!PW8R znQ)#lh|UnKXB;y(1n=21F@&1=?3oxs&3(ppws|J(XV1hCYW}llVhBVRdY{{0zjLeJ zXBN(`;u1Xb8t|aKPb|TN#&|sQ7+h%2gb->rG>*cX$8ccEGa-bU5$(Ni-{7=0-g_2K zsd_sIkfI|m+Iv?o#W172cZ@!HgZDP=|Mv*5{{KJ!?;-zA^zd7755T$pCV!28v_BVF zfYJUzelOew&=#G196uc2;-CAL_dN0dcjGpID{%+l1&D^OL)P&KWcS7)i?%Z!b;|Iliq04U@ zPsQW7A@JMSC$YC;+hY$S8E_-+2+YFCIVH9>wiFWrXW&G^VBA5_8D37)n2U)HKOi6Q zE~W%N5xp;Z8{C`{?h5={^rYx=%ypO@orp<+{o&`dkG6)C8L(n`;(xs0nsMHY<<0$w?dB%{7lX++>}TWt)}B+~oLM z*=A)jH(7H$+pJ9HCfMQFtW4%6*x}erCNo}m`AW8#EM~}M%h_f!m?4)gW1GodhQw@= z&15b^9(^p^Ox7|a=8bG7W0@r0jcjGe#YeKuWGX{0TFf?+rA!hZB|{l<;Ucz~>|~Po zD4EHSM=oTW$x0@91=~zUGUVJPY%|%&kaK3U&151&9y*6@CJPx714}lOfeblw3foNf zG34}VY%`h1kQi7}CF>Y++Duj@;}~-4A*@QaG31m(S(RL4$jR$il{{m}iQ8C}9An7Q zlUbGgV#twCu`0R6kRwL2DtX0_!|+jZiXpMzQYD`l5}Pbla)}`a;|b&uLt-9Dl^kNo zgNLvx`NJd!u`0R4kOz%mRq}>O9?Yub3`1fUrb@mrWWUj@O0F>E{?D;0dBTu=_GeXc zgduzPVO8>jA$#>>RdRzNd-h^g@`53I^kh|Xf=M>AD*3>W?R&5)xxkRk+OsNoz>vsV zRLKE`Og3Rv@_!-yB&(A93+ef+O5QJ|@K}|cUr1aEStZ{W5~+$RxxSFtPN|aT3yJNN zDmlK8*iNaE-wTQDlq$KsE@YxgR&U5{f-jMLUOfCFzC?0)A)o8bs^sxPJ~fb4$>D|E zHj!1y--Wz&Dyx#a3wgzntV-T4VJITnafx$zzXW)zMlmWz|ti9l$?76s~)W6VRKpaASDmQD-2W;R|r)P)Dl0{1C*Tb6007d zMN3m)@CC7|q)xJt%%1(8EEyu8GA0=^xP_?&`BZjkTFC~ZLH()<4@k85F z$)OWiwTG7Y0q?FP{W5e@auEJ=bybpn?7JvQKkJ>9q@VSDwd~KT`zU$f0j%0dNn93G z-CIfQLsmO#Ie=9=DA^y+-Al{oS+%{A{qSOYD%l6W7ken#3%@Pxl-v(r@9s+W*pF3f zD{!a*8YYT9wVc*WIs@C6zeY&%%T7Mh%*_Tz- z`rFX)LsnJmZ^Q2Uu&P>r8`|v7s%rghXz?AZs`a;_c?(um>u*ET=B%pL-v(UGT2*58H%ehbz5+d#j1YW;1XUp=+{Hqfsgt-o%l z0l#{+<=1V%zgMlk4c|4es#<*;KJ3V$5`3F#)DN@Cq%+1Q`0jE3_mNP)?>oi!uS_WGb{Q z6Hrd3LJKniQ@bTC@r1QoRZ-+XQzI(85iy6&uDCTDle7!7H?Q6VN?+6jtI#q|aHDe*uh2?PK$q%OXfY?COZ6(WoD))(Oz@xD{I1>07WF-vYI=r!ZabYF?q0ot{NmnhLG$ z1Z5}7E3~>3(5Sr%t?vYM3toj*cmlfku0m@(0ZrGd&?-+r)AcH}&J$#j%&E{yuOP=O zwAK?`M(;zbJ;BA!`Mg5wJ;6oT&#%ymPjDdtt@#8OI2ZE@t@;Ws;uTu=61P#H1z_~E zimC$e`B_C(0oeSkqN)H~epXRc046`Hs44)DpH);9fW^-$stUm2XBAZiVDPhwssbte zRa;S2AhqCdR#8;|?mnxiDgbkzRa6y#x6dl73c%WD6;%b`?6Zoh0xOcyAL6lV;NZ}9XGSvaR1*Z3vsR}^i62dao z0LYpx`w-mFZkKV&~hTKggQhoqGsvJWIJ{E)rZvNA3EB>S^6E&3#p zj;1A_Ko3qHv{tW3*2WX~O}Op85a4@g?-A-ngW{r`VQzII}pW0&D>`Sr2ou_OPd zbLf%i&(SZCDc>G_03G9%(M&H}ZSk3_x>#;PJsx!R%lh z&JXkm_6(W?G5=@(3)~*K-G9Kp&A$>80MEwhfu(Q^CS(3zAAfJZC5*B^VbXlyz3e@P zvjczkN;v9z3eH(BgikOU=gYd{+*4DJ$v@<`@?+c-u>F5C`EPI88u#Xf#lOYB#18S2 zcpN7OZWdRFtT&9FPuz2q@KZR6u$aL6j~ay-1NJVgs?G*n4_n9S3_aBiO|e8`c?h zn6dXZTO&8&#K4xwsgY%o6VX{p}cc&G7vo{)lGyf)IaLGkimc zKcpGHBE&gs$9IG{XYKfs5Z77z+I8{!IK5hXa{OM#Yu3i^(fH&zcT>POh4@{3`>K=U z+ZnGsIsSXbD^8Bz$$0t6@!v5%d42p2jn~C**LZFGHjUTBZ>{;cu+27`H~ov8X>vLb z6V2pw9xj^6={#&S^E!S2d^D5ec^GLX$MbN~OpfParI{Sh!%H(co`;!cay$<=&E$9< zcA9xv?XAL3GdZ4zp=NSC4@b@9cpjFT$?-fqHIw6cm}(}+^KjKnj^|;knHmXay$>$&E$9< zwwuZEJbX8k<9QfwCdcz|-b{|?VZE6g&%=8&Ii834W^z0a_s!&Z9`>8b@jU!DljC_9 za3;s|aNtai=V8H_9M8jpGdZ4z31@OV4;Rklcpf&K$?-gVICF9>#&!ed3AGs8Z8ABW zhZSdXI1ew*^bHjzW=NR=0J^Sn*$inm}MTtc-jo}NXAp{HjiLDd79a%@f7oL z#$$GyhcO=YxOph!k;BYG7>^if_Gdh7l-W(><#@|af#`t?VK;uKqu8a>q)a=6eu*1#$86Vnc zcGmbXvlHV(4mCS!{Ic1BasNZi_Kf@VH`_7p)yHhh_~3zN8^%2kHd{09(bL>d<0i8e z<8FJ*mW&VRW;QVHdVrZ>+@*(^*0`&gV%+INvjyYUUCboomaWYMNnf2SGyef(|B z-~-0r(hNpmoR8;wPGI~^eGOJ%{0+_E1;$_33}#^bwPx&=_^X=14vg>C41Qqz75%Yf zx5fXcFToLvzub&vE7LUn@gJ~*e>eVe{15Sa<2T2zk8efo{weYK=;1#mJ|Ny3 zd-x1WUz$*ovA2E?s`nqO-(FwByuS<3#c4UZ3r#}Tn?`hFX^;AS5&I?fW$c64Yq4iy z55?|?B(fgW{6ERh(ZQeg@sG=UF!}E$dAU4KZj>wKd^rs@{X^y9 z$o)HE`kyK5B7aARz>gw(A}?Vte}ClmNFLe$g^@EOt0RjdGqIZ=5jhfd{aqsaMVv?& zQvkmae--bb1Hew~=kF9Hajn=YHi>nZ1UOq9FGh<2qOa(R9la|AYWu$oe}L@&neap5 zJHol}<>(r?KD-2#{l_BrKP22O+&XNB>q0+;J`KHxzJX6+H-Ag$hR`LUvqP&x3qn&v zV?zT&eUSGzp!&xb{4V&{;9J2LgO3OA3YLS{AnQLpxFUEGJe!fhBZ3D7I|N%G=l>_p zY?iYN%PNwRc`{~+VA`e3lQm1k zHe8t}bC!r@%#eAqXBDw!o(x(daU68ZlSNA;ij(DeGHHoOB4pDl5|w!}YKhRKraW1- zM8Y_|ohP%F2+IFtp6ps8sP~h3GHi*|VQf>LEL%l_GEb&0kpR68*|v({*~qvhV)*vS zJXyCyQ1>VEWZr^Mp`Xl?eM@8?PL}7%!1V}DiRa0}C4wqHnI{vM2#Wk{X^!-&L#2_j(q3I&?WMt?4`BHf@c8SoT^E_F*ifos8GIxp43G_VKyNcin8N5VLD=711@hXBVWbzXE ztM5aZC!3eZUYtPBlhI4$FTTApPgXCHk9>cTc`|#6e2DXXd9r&I`AFu;@Fnsm1Z#P+ ze2LKHsXUp!MCb^6o@`$tbc8)m#xD_y>GEX#5_y}Rn#^BC-jR8-e~G+BZXAg5^0uy-yN7!>@0~2}O_omE|5v<4?GDlXhB6tj$!9-p|0+=H^n8>U21Y`&k zp=>WlmN1bQ@!oP|3KMw&L0^t+VMSh)IWmTc(6p)?S;Iu0#(DM}nZraL!Q`nN*~3Kc z!;$YC8N@^|RbJ-EA|`SVg0>u)#ERT2bIWREyYB~?TdK)DGPk5gFhXAD7T3tlh^cam zYNU!Y$Ajg;sKPO1?MgqOKFHG+BYGB>+MFuGml zX4MFVUbz!%qG&?jByrql>Vw#(e) z8o`WqnLD9IE=7cvJ6@A*GB>G4&@f%*j;oQabhn9`Tq1K5YUF&JLC+mqBN)IgbK`3S z^VenWm>SvQJ6Gn$)yO%%tui-OlPxkgrbg&Qdv0`%Y@*L#lqQ>HZe)$%M7zk105Ocj z=^I3DxPlu`7r9{yp0+WP8%ofh*norUxgqN24W~tNgVoKa(#=P!n@`1a3{nt7t8xPs zT(e%}1}M0CjmRCPAPTs0M=FRxRJkJ*TmjgqAcjlj4p$HfS&r5iyiH_bIa*-=QP!2C z^#u?`Eje0U0GBKkIa*r)7cCSyT3G-WE)h9eR{&31ByzN>0HQuCM{5e;i8DoxRusUQ zxRuruf+va`ttJHXB1dZp;Pg2nM=J?IJdD;6z-f3Gts;O^r;8k|Aq1z19IYULlc$Ot ztsj6VOcps>JphkCLF8!d0K|#<9IYIH6DNrrts8(CNtL5j0}yklajWS|q8zOf1aT{^5rDmViyW;G1aT{^ z4}b^v5;+8vqYFSmbDBAc$LOT>wNSR*qH$g1D8|1VB`rrq+)8TzVAn2@9IXKO1?^&7C~~v_;N~uU zMULd3Abw5~e}eeoNcsulCnDh|h#!MwpCH~li9SKRVUm1;c$Xyj1iOnY$vwdXMV7># zU^kH^sVCS~WJ%}&JK=J57sZ zR!O+)dz*-oa2IRmJ2I;zoXFcUt0dg@ieIuy!d)zVdt_Egxa%!_Sta2vDp_{Rtdelo zN0lsDCE+er#>=dda2J&*Ps^;5aM!Cu$tnqVQNsSR%qj^dvO{K-guAFw!4oJ6cYTkd z5G1Q4oCtn6CE>2`VXTl@CE>2`A>YF?t0dgT)W(NoR!O+)O>N9733q9iDzi$$U97i{ z%B+%b7wavEl5iq-$*hua7mF{hC<%AHNsn12;Vvpx@-nL=-1Xgwf|IP0a3Z(LEJ?WE z@1jWMHkl<6CqhLkS(0%gx1stZOF~ZMRwAV2iWFs*w4BH-bcMv6$j!c6WR~QdNENGh zmIR$h1sz|qB( zSe3J+_jE5>*0ZGeL}*#hlI9b+)VD2?CDo_LUwVEdOR`UR>-jQEx=(ka8ka07zam>@ zmb9P9MZSw8SyF#`$OU)^$v<5<-*I0s(? zva}8;vPEWTB_M*uK9Z%ifF5!-9zu%&UBD<6nWg1`2%^78)>{y$0DCj#|DOlm@nNR? zsi+vA5gd=R|9#Pcp6CC4RQCpUkq_0~?oE{6RJXcrKI+6r)g4jS6Q}-N>>+;&e9|oY z|KHF5dprJO{IU4&G5zm~_?Gy(_@ekU%>EmIvjQFCEpYVZA2@dLe*GWo|4@HVZSLPD z^le^(Uc(cS{x_nhYe)1Ujn(^N-{XwHyRkpUcHoS_?QjmRh@BTZEw(&%QtX7-=-7Z* zZ=C*biBkfB=ugqlqJM&O@J#e!^bIUVuZ~_AJtMjb=LDw0H#j=lA6*06MBQju{vyAU zAIUdyQs8lUH+lwMFE5dsimL zvUpP5jdKE5i}S?>_yjY>cys{hi(Y{l5e@%}^Zy@&Uk&dJ-ygmOr~fYwpMkRhbHc~r z?Ej%~2=)t`VFM@s_u^E*bLbOz2hRO(3vGfwFh4XoGz#YddWPDCTyzQi2}%DuI1BK2 zaC@*AyfW}k;3ZVr~C)dsyo=gYjXl(+tjswYC|1 z(ORP!ybo)&W-vdjQ#6D7VXe{(_J_4nGx#6Y3e8}ESj(HSd##f-g9TzOYsT)emTCqQ z#9E>mTo7xqX0So5MVi3}u@*LCRck>rR<`DA1}nsxry0BuYp!N6L#&fDgBxPa(F}Ho zHCr?IA=WI-V2D^JHe=UWGc|)HV$IMDo`^MFGngXQG|k|OSW`8_pQSZLGyGXvlQn}e zVx7>8ZL^No4AzJ>Ni%pO)^W|)CDugE;Eq@mG=n{29jh7q5o^3=Fi5OpG=oE8jcdlX zSYtKApPw~GGngdS=w@uQHA*wsBo@c;@JTF=;S(EAw>XARoVL*#Mt{sWc}Z4@#W{Rp z!)ewKz5uht;vhb8>IRF0_{92CEe_%nYt~yF#3xp-u{elNoU+>DAU?6`6pMrS#EMfa z4&oEbS6CdxCr(~vaS#vN#Nr?xzKO*_Jd6{IgLpV67696tuuv=x;^CoK9K^#!u{el_i(+vQ4;#heARa!7 z#X&rb6pMp+I4Kqf@vu@X4&vdZSRBN|OtCnKhnr$?5Dz=W;vgP=ip4=Z3>AxmcsME+ z2l22}EDqw~saPDu!&I?2h=;3UaS#t%#o{0yzKX>`Jd72KgLpVA76=ui&c=#=7 z=0Gn-rcQT@vv@czW|O|Xo4HTpuI8_dJ9RVv$+&$d^B2bL+MEAi+_s(hcgC5v=Fg0i z8S^K`PSX65am+D)V2rAT?-`?N;XB5tM*5a(vEGA4)Yo+V>)$nJSECWq{vDPwZT?zu81 zhwPp$V{*vu`7$Pl?4B`Wa>(vEGbV@Zo;71~$nJSFCWq{vIb(9j?zuB2hwPp`V{*vu z`7`D#`e%nhV{*#wIW#7x?4Ctqa?0*`G$yC)o=IcAsGkKcjrl?|cB}ckX7Fju=QM*+ zV?L`HoEq~P&0y7-Pd8&lbEjr7Ys?**!L2c$(hPQu`3KG5*O*Ug2E)dDg7W{i{_+34 z{r_*oD637Fv9u7gf5u@ZO<&APX^9yg26pj(i@l8+{wMxJ|NnWh$=Jgm73&r26idfq z(WdCP(Y?_<(HEkRMYp5#|JA79--zUY4tn^Fh&D!hM%$z7zrg= z;vpmWeeh%K?so;B4Biv02CoZVjC200g7botgJW>Yzjv^6FcXZ`HPwAvx3_MO*ROGV zU8(Nsx(n(y)-A7_Q#Yw@L|r3%oA&6|C<4C(z6^YT{{PPe9tzwMCT;4>G=V2uEVi{!2vkyMi?lC_GD~e>Q0zThQ6vh-3 zD3U=6_+cAaRQ71n_dMEo7nMDlMAw)dvZ(CQqz_}ZWl`CqNjT%r$fB}Glb$nPRQ6~R z&iM1PsO(W9J7v+cNBv39880e-G>HS756hzRN0Z)x&7$&0lfK7sV6&+F(Iif9J}!%% zKZ+-K)c2SydIl-TBk;0|${|g{Kz~XWl|xG85m{6YDG2>Mo#k`lRJ z7L`d#lhp6s?Os{jPm_COv6Uuw%VJAS?urx}h!}W+CRxnXF8r=(hAgIQpQ6smalX)>&BRA7y5;anVw_kKMxmgyS8X*I|XlqiDMXN?AF)x}mQt;g$ zDaMK5tz~!1VtwtxHHf&2u^OS{pT%g6T;;o37G;fGiDRb4h$dIbqNowd$cy0`xeRAC zi=i4J|GXHi5tuTvSXU#Igck!fvekEqEc$EYVub8PLzAtt=&O-);h-1JAcCLZoZYf; zdhG%w+=Y!bvI(hp;WSM)M+zHi4>=1DIkk3y9Pq+=5W}B51HIe}>lmLY3Tp`)$DhhKIL~E16 zNeV8QD++TIoWDR6W-B;vz9`I6aPDGJII#xjiNZ_;(a)qXL&4c6iNbURr%e%sX$m6O zE=*N$%4|_k+MmQ}&H`yaKFP_ruP6RS^7zT3pwvH!h`XTFKZ(%;YZsLIC&wNu3QGNxh`0+%{gb1|ih@%AQtF&;g>L)IW)Hn+2u*$)g8}f>QtF(E~+6seclOH494plLLo{f>QtFK-{X-KRIBu zC@A$$4j3*9O8t`qa0jLSNi;_(DD_XGHAF$Ff3oo~Q6Tll2X%O(D0uR3Bo8}M6qNoa z(GQ}a^gnqB?o0ZQ`=Z@Lfz%(cU;jvfv>(6BJ<#-{K+=z!yY&zS5`Msr-9&+8AFy3V zQ6SL=Y|~B@Nb&(&w-E&rd_c^|DUjR)w%Sh=NbCWz$`wfJ0nv@VKtd0Q5jX{sdB9Xg z6iDO&(b=Iu5)YVcAqph$fQh6ikh}vriEx3$Jpvdmkg^*QtepjtbzI)vPZnyT-ZTSQ zVWB4KO_LC67HXo7mhcbBf)e$FvCDW?7L=$bjF*gEvYQN_@ig5{ zsd~cLX*?|pO4Sp_lg7icpj16!JYhU33rf`!#^c5lvY=EwVLWC$E(=Q46UL*)W3r%B zJz+dzJSq!H)f2|U#v`(zRGkPu15efQ-X1b`%7T)0B6v|H>j~pQx|@>qgzItK4RAfP^dcw#VWm!IvieeXC_bsd~b=&bVIYJypl+Tw`1(^Gene z##P2OGOuJkVO(KcCG$$w6UK$cO_98k^@MTG9+_9No-j5W=g2(CI=$&LjM*|zx=sXx z|74zooe1Xs$vjCrk@dz#nI~~4f_Z&1Px4M=t+7t#N#KdBre`CGCvu9hR_00M63u~Wu63|$U`Q|JbG^={d8rnF<<6M{1ure^C#8F9AmD`(*i(OW*eJjezqoa zWS$lQx-yHNjg|o-GmTj?PYVH&8T1%h3KW?s^RyTcnM!56v>XsQ)|e{uv>*_{q(7Ob zC4mTr`N=#j3PeU4!(^V81tKGikupyU1Cimz2$`p)fygjptjyEmpvZ8Ur{#gjK)ON; z1d##8K$)i{g2++E0GX#ng2<7^Q8G`<1d&GLNSUXFg2>_Y4rr-Rq*3N+u^@7&ak$LW zazUhzaj4AGf*I6&rU*&x!z=qmHWYGi+-i_Fu~L038( z`^!8n9z;4Bon?M-jdV16$viC}bfu%wN#+ODNC$cyT14ndTl%aA)JPknt<2LxLRVTF z9b}%C5=Gj`JS`?f_A^?`JS``Rph}t+6e11Sb70^0Szwp%zbV1L26g|ZVcPMDn6x_> zbN{+v!e|T~{=SZVh^qgmk=@@KyCJp}Q~y>Xxt|mp9y=^{AZq?CoZ9~$^ZxckpN~F* zGyA#dWzo&iHPHpA^&cHQGI|hp{4hY}&+=3GuG}S`z-j%GybAmMQ{@uu_K%T+WFOgC zrg2jL*T`3q4v zW$5O&1~dJppql?kagb;)5;&dzGy3?w8{QRu!aJLPefZMw=I}by@}C%<7#<#O#7w`A z;dD3__JzI=eH?oC|4o#W47P*b(hp_s#}Xr3^P&HKdkO>?Cd+B7eh4A z6!t$0)a+yDPPK7pDwk8RDF_PUyZjJ=G#mhpzu>@|!}-C(a~ zy#7@C6vpe$uvclk-d@Rg?K*n}Za+SS|@ru>`9C#9A_WL_}B^dM8?PLwkI$icZ_{3m~qQi_Cbsr zTG||FVpC*upov|P-JS2#B4r=QIN8GHKoc7yn*&YkjO?zpTqx<-U23^dvdQK+6PqKu zbIrd`((cIaq#3qHc1O*yKe9X2n72W)+iQj$lHE=-Y?17?nqiM*x6urnB)hd{*d^Kf zX@+f*-KrUT#%`$@HcEEGzu3Mpc1AO7mF%=;*els7&9GUrTWE&elAY8H+a)`p8TL!I zs~I*-wxbz#Ot!5VwoJCwj6GzVnqkvq$D6T-?Rw3yZNh+6I*&`=l(28IqncskWJ}Gk zbFw4!i-&3<+B(@nU&7wW4r_+ZlO56wyC*xS8MaS$oo3iS*#XV4fwKLYVFzU!nqdoN z`!vHI%4*UKn<#6aX4pkpziNhUl=V;jrq9`9{h}{nBW3+VGwh_SziWoAl=ZV_*h^VI zX@<>|^`mCkO<6x^hV7K~y=K@?S>I`f4VCq6Gq&3LMl)=wtgki0p33@4Gi<7?FEzui z%KAbxY^$u#HN(Ej(itjktSp_Of-SUkhDuv2i!;=OfxVT*8LGFrvN%H}HqYV=HDO?T zWpRe;?XN7(P>Id4I79VzSQclf-WJQ^4At9XS)8E~n`v=|>g}>D&QQH=mc<#Wx6iUT zL-jUV7H6p5PRrs9)!S-WoS}MqEsHZ$Z?k1_hDvO>#Tlx%-Lg1C_4Zp9XQlC>g~WR&QKEuwqVv1 z`u7ZbFzfMVtc~@UX4r*Uk2YhL^@wKJhglD6hSRgwLz?0Eto5K~I6rGWpcxL(TK6|& zDeFGXaD>*nS2LWUweHajhiI+4n=#wEOEVm!wYF=9bF|j)HN!z#>&|8@YW+?#9Hq7H z&YkAr?qZr#)8((n&CjLRc*%VtcqqhQfrkp!@wXW3+=WDHNG{XT~>uSw#!q&PxIV*4sXVv)w-@`rFYrbhdXFhEHS9||m89z6^KE60UJw7fz5PbtWp>IGv z237ooQ44=U_rGWAAI99jB02|LSbs+Ss`~ku{5P)t==%Qk-7qJ>#q7UdVqe8RioF?o z0o4Qd#%_t-h&ci0pnJg5*sR!bu@SK&Vh5vsAQP*P8JHCCN%XzwE76_N2cvgH^U*7# z=c9sP1$qZe#;kyW(Z12H(fy)!G${Wrzd#Ma>+(7ID5eFJ<+bu+d6rzQX8|56yUTVm zDI@R|zCi`STj(0_WaM5r3pYl#Mb3$=k1UPMg14Z~0Cb9E&;if~cj0g77_h6^DS*>3 zD`1wGAckO4zyYF_h+|H`x8c8__usSOhtVk@8{UQ)fUCpv!;{0K!be~}K)bLT4q-aL zXQ&l;8M6WIMW28haSq@dbp87;%>VnJyn&YkPvOk}ZGmjyvcQ(Wsexrk{U-%R296B$ z3Uo$4|9HTUE`Xo<-}Arhe-fwu%l@nV=Ogc5>YwQ!??3wAV?X@cyZm2$0yPCl`VdCj ztVT&{U~ID*C2=_HcQ&h0(uX|SW;IIU5ZJ9Ys}ZrAZC0Zsoe8s9jfhojR-+_Ng;i`; zqa;p(-EUt~`wB|2w7pfoG33(r#hM|QwlC5Q$+Uf;W{9Tk3!1U3?DI84IBlP&8PaL{ zT+I+q+gmh4K5d_)83Jm1vt~%B?M<2?qPEX&##$dn?$FU;uO!{RgTr1)x?OvR zy^?g>E)IJo>4wi8_Da%;b`E=}@(&321 zUP(F>cGxRP2SW~fCFwxWnNr){rUQ=GgAIG8bd$qQN%|YxVW%Yhd4t1FNqWx#4m&03 zT}L_Wl%xxDoC&qhD&5q}_o{O&vwdF~wliL{-A1ExOzk61`EK6fjH_Kr(QmIaR$nUZ zaK`9MsI7NKYnJ!j;Ed8N=gT`IHN%`CXM|?x%Iyr-40DE@VVd3GD>_4)u^XKsnxQ?9 zGgvd!>pMqlhOXVtAkD7!UF!_gZ~Dp|&H#N0zx2*gnxR17Ia0H2zN?)hnz75BM$Inu zRh`3|v2D&_n&A(^@vIf>a8tgEb~uOVOBebsa{6m_f$tKhUo&>0(^tRCb9Xp>^d*$% zJH7R#&1X2h^d-zVat_uEHTTXzn&Bj)(^E72bvQlfNlWcc>HaTrro(ni>hv?5 zZhT>*?*fPYmej`6ovyV%uGDE89dB+LJ=F%>i7l7ZhSQwRe5Z9goKB3_u5&svUcKDu z!1$EaPJ70yPI1~XUb)I?%Xq~~rw!xfYn|2_uWHau{z>Db>>o8g!u~Ui%BiojTi}Yuw5HOyhU#PZ@XYWPifALr42##_jjoe`DOP zgZ)>H+uM5?x9n>Fg>j;#{Sjk3VSmWD-nKtr9Idzi%veV4KQWF-`+dgYi2WYpP}qK# zaWG`R!#EJM->$79set`f%_L1V*?agNd)wJ>GJdC%{RZQg`r5BEe!idm8si-k>{n|T z<)!v+#+~=KUtx?-^N)->erCVSxI-sj7Y)yFOr}kYnz4z)*?d_VO0M%w|!mB{F*_!Z5P;Is*yc$%StqHFP z)n;qLt3tKen()d{ZMG)7I#ipj39k^mP{T7QG2Q0XIk2MHfeBM8~5eV1M-W-!E!K1M)}t3F-v?D4&w|OAKR?m&$Wc zDR44o08WN{lf=huipYIU=w=$?+xt`M5qy`{@aI=Arbr~_+{{e;A_EWf)54n2qIZDw|LXr5^#gDCpYuQBztdm9lz{W#4=lqk zQ}h31#phLFh9q~SuWmWE#_B!H~Rk>Fh9r1pYWmWD)`^NX3EUR)aBHzlgD)&mGrQSEPtjfL8Xpe^vM3sA`eV?JtTUnKRrG20J zK9glt?nUHdSytsrM{i^wOktjfL8=&y&zsB*8g*I%!!%DvKF zf4%aF>fgufuUA&(UTNP4=#^JiH%_rB3h zmQ}eI2s-bTRk>Fh&GztYs@yB>HQOtza<4RI`1X=zRqmBWC!lv^S(SScc}td6xmVho ziB(qRUTNR!^cYp{l}6*dH)UCsdl7kEmQ}e|8m)w0lVw%zmG^%W|sa<8;E`>L$Uz0!1)B2xCsy$q0oEURj-G&%|4t*L6SwAV?ftg5}zUMHcl zs`g6bxWoOjtg5}zIPP$-EURj-G>$vq2~@RL+M94yR@Gi}Uf=)Q)18Rh(K$W}J z2ptM2cc~HVCuMp68o_o_mOIx7c9W5EClJG*-sn3^lsghO(x+_{_`lp&tHE?6$gEfqu!ez`%xdAK#B;N1D5 zoUXxnqMTB2&fG}31wns$hHsZBC)LeUCW>-GLDblnT?NrmuIwm?j&fyNL6qBQJP=&D>!U~C_~oiUjQSO%Cu|%qBOrmiw5AJA)-V}2H=2Q zqC^V@;8BA_iIxk314M}y3&8%pM2VIPz}Q2<1*yAmx4fM{@6TB;zr z+LdTIz^!OySE9uL5Q7#=v=jh#!K=|i0NAmuDA6(ih!bojS_A-bf~`bL03f=^m1qF~ zY}-YYNd5sEJ{Khtf51dLQ6lLFw7(H05`I84Axb3sfS9gWBGCto#zcuE9}qK6OCS*i*9KDc?2k|*p2nR&9LblvmvBBe2S zDnBlia6!p>3LVHwvZQ1^g{EUS$dZ!vlrM*lY9%G>DH<0pOG?&L-fX>+lJ%4iCY~%Q zSx=!+Sy7ghtP{CWmXxfgys3L7PuAg+Q3KMF()E-#b+4p!J%vGg*G5VMYVY$(yiXihBMoONn z(P>uakz4#lu|E&F?=OY#*NhcC8DHMJcWgzq*OeG!&oJy;wjADD=8IE zVfJ20sdx&r_ex5|Q)oz9QYxOp?7fmwad>f}RIgrbI$l_*cnY)kN=n63Q|F12QgQfj zqNG$jh0|IkrQ)f{xPwx0m~f(`R6I3lvM4DPPff)0Diu#nm?TO{#Z%}_T2d;W8r&#K zO2t!01Nzk48Pq6>O2tzH0hNlU1^_A*PhoOyQK@+92tcLcsm8&gs8l@Fc(f=g6;Cw| z5=EuraNR_4t$K;Z0ivi>Jk@xVC@K|CVc>32sd%apr)rgor~2V+u~P9=zn4T&sd%d2 z^P;F!Jk=L>P%574jTcrbp6c3L6ql-3>%3PKm5Qh6@Uc?yR41G`Rw|zA^o}Sh6;E}< ztxCmHbPidmc&a^~L8*AE9gZX`6;DCXi%P{)ExU@MQt?y*r+}4;r|g6%Diu%F+oE`) zdOH|TT~sQbl2K7qDxN}H(4tcD6jZ#ZR6GS0FDeyJ1w&H4<$E^p7WM!C*nHRgqq)O; zz`WhenOB(SnWve{&6CU%%+clmvya&Y696o;F8*`;^Z1|Ruf?B@KY~tvrT8`Ri{fWu z2EYRJ@;fFzIDSa{zfgi^fXD0auCHR2-zDe+u(p12{ml9a^+W3q zsXw5;75V`9V&7tx-y5-KF$LiE*iGoa_o%!R?Y%F@E`2SM_o;G>JW3ucJ0N!tqg(%H zk@q8iM6dpPB9+KB*qfi`P2D>-GC0x?UHTg$F|kj4Ej|>lVdCBc;#P43`tzTOd3z^e zKR#R>CJsbzeoOen--rJi-h)|tkA#1R&it2QAHF8MAUq{JI(#H*`P+vRUf%vGD*1PX zo(SEA9r#tD^N_YL3C#!{gOmAvFkvqplEHss|NZCS?%Rm8;Mc%6fxUsZ z@HzI2cZoM(mcqXJuj@aof35!M`UmT8tG^MS;o0@4)X%Fwp?*~TQT4s+_eU=Ut3DX} zd+ZC$Qg}V~Tv^iy~| zdN*b%Tpzt8x+%IAzRAq!gy^v7;pnK)A)1OtWt03){>|SNza+o?_wV%y)co5_o3<_& zGiNewTrOtLq%tlSGiO?)TrOtLBwM&#%$#wPZjYK%oN?XmHK#b!j~CcvmQntcfV$=x7(^2>*cm=#twEHnz4i2Of%Ng zP1hXOw9%ux3(5=_3m9d{2)2yY@3Io+tb#of$z&g3I8EffAG{aR_Xx73=yJ5|e zMhiEj8P1cr!DcM!)@kM%2{)jbW4NwgGuv=nqZzYZ-@n+tubd{$;1fFgG=ov-{Hht8 zLg$~F!76lq(F|Up^AF8n7CL{|3~r(Gvt~53>L+I87dk&`2E)+#K{Ggp&i9(ZGIYMv z3{`&4x0=B;biQfEesI3l47Q>3m1gh_oi85{EPYiIxlMmU(wm68H`2eCC%V0 zIxlL5E-lUrnxRdL^Soy0VD3Dp8G5rg&uWIY<<2vjp>MhKv}RBG{^0D?3=X5SgJlU8 zqw|!$1dq}AgJuu-9(1164E@TTCp3f2=sd0&d`9On&0sV-k7|ZFTFxVy!D@6kHB9^9 zH98OJYcLxfP7Tv&Req<#sbQLWl{=gorl}y!;nXmVp5(VVoEoOltNRv*Q^Pbh>vlLb zOk>}>&AAJ?*3N&g75le$`=34m{YwMq*x~3mjZW)Z9gcp}@GLHNIQmUfOLd2%-!yuv z-{WxfOYD4yqu(?vjq@Cie$(iyzQy6_H%-0N9gcp}-Rz>Nmb3LB*|;~i^?H# zk)3kwn-^?kr^FalL`BAHH#h~xYt}k>#;eyjImRnjJ6Xo47P^V?@)gdFj2A3-ZeTor zfpb0MdCQ&a7|)&WT&wXs=NiUy<~moCIMADhsq9?E7p6>fu4IfdoGTcgFxk1B@$n}( zmoc7nyt9q*ag&@&8BaXUxkTe#&Q`|9j&v?&JpNecBF4vzcP?Z+?il9+#$(4h=QAEN z);W*y=rPW@j7N=jwlE$!(K$!sQO;(@BSt!#7!MoaoXvRXFy}1BLxwtMG9EOTF4X5U`UdcM6+FJ~R&-o2c)jC;*+)@a<{S*>wD z=M;_mI;%A9=l?b4|GkY){!gQh|M&If`s=W7 z-CVyOy#VH5=Q;*m0Q=V;Sl_O`MST?e{~uzX#{Ptk0MEr9i`^Z&8MXYE#m+@P{}r)$ zv8l0RuzNi$)-%=-)%~7x0t#Tznwj5HE-)#J%tsvf>K# z_CH;$Lf3%l;#e_EG>U^oXVDT)Looagbol>E_$~Afcq;q=s{0G!tHT$C&w}5u7~KPo z3y%yR748%6idle|VI2Aux&PnbI=m8k8r}YXA1a5g!#u#vq4l9<$o@|Vjln=J?HJscesw(qq zSh8JIDf2UohDCss_yI0lBC3@45nLpy;}o1fPgE)F!>#icit3meoG+@Q6+8*gKsg`o zFbDUgl#k#^qDmPb;E8iYl@dNcWGGe2_W+ThR4Ls9oQ7whY!46-NtKd4z$r6Cm2y3T zQ$>|hJwQYxRm$`Lk-JwZ(F2@#f~ZoU2Z&sxN^u_G_;I32VIJTyfE48cjyqXYDaa!@ zUQ{W@BY2FcQiun58173E9$^0uM3oXez`lT#-vRdNE2@;<0b-I{m9jfPgx6Jy?g;i4 zRSNC^F}|%zxg8*8wpA&$1MGn(>#1P(gG7}QJKT!Iy4qdA&TT~XKm|Jis14p4=44d|{~^};%%q0M2Usv>KfW~5LK1cHMD6j zsw%5%K##hr%IX@>pS7y8x`tMGn9AxJGM|X5%IX@@8BtYPT|=sssH&{4A(a+YmDM#Q zQlhG|x&|j9sw%5%upLoVSzUu=i>k`%8qlh>soAs*<{f?Gr?`UfsHFMWh-7^cxx9 z)jMUCqB_4ngFWpf7(rAclsBeSqla9Ahfrci7pT6bN|~J^TV<6}JC8K2 zj8rMNqla9Khfr`w7cN2-PL-lNMJ|?A3h#(eNL;1(jtFvLS)l-r2-W9QD8eIhE{020 zD8wUzlvq|M#v?){ITZ@>h)_vRg`zwnlo?kj%p-C(hFn%C&QoNQtWcmwgyvRNDAH5p ztVqQR_52ws)~TpeFM~+9;-z~2%m&}pqM~BG3?ku*iuE$9cZ!ON^)jby5S5ehgocrU zL{(I*mw`l8ma1DffT~w4*XHYOyQBs{j!B9m>b!OsRQBhKzLG4gQNp)sCo>xhAX53g&QBs{jX;4K; zb!IH?Fh;$HG2=x=Np%JVLlq^}859gvlvHO%j}{dr)tONfMMX(<24zGQCDobXqeMkX zb!O;rQBhKz8G@HkQk@w*R8*8yX9f=u6(!Y~BMui8CDoZmKqb|g!;cUZCDj?U->fL9 z&K%w(DoUy|hdeJTN~$yHxmi(Co$0q*RFqU_diE0)CDoZ8cwQyd8C3pMlvHQXh_j-k zI)fUYijwL~rvpSqNp+@eCs9#SjjTpglvE?C5fvrXNNPkyNi~8RQBhKjoJLfXR3oMd zSGwc#jTjj>T=A4TlEKfQgc=!*s3@UEL?bFnsFBc!iV|uBG_pcMjXyKWXJmz>8pZ&{ zGqOTrO$6zTtdLw2K{z8TB-lid&BzK#HW5TKvO=P*2(FN96G1Q|Diyr7ugIP9K}-V3%PZyi za-&=!=c3+!j2tNY%C6Y++cJo1f-le=@b$=Zkw+ujBjw1o=nr^SWHoC2r$@#|hG5U% zJ<<-7{34hE@C_>c-x4q36#u>A7ICB4Ce9J-G0kt5I8KZZM_|9->HqrI@!#ISZ=b+# zpTKXQz;B zQ?{^v+{gK689&Z_jPWt!Ts~(D_s8XPwy=NPhxtC^j&UDieAqbmLB{<*a35gY_XGES z#(ny__c89>$Gw+vum0{m8uxbZW_)lj_b$c<9qevr+~WiH_l&zAh>n@l8^qgJ>`?kRi$ zHj>L(D|{rEvsM^M?uyz!5S%1;xyGwq&RXFmxtz6TR;_Y5Yt1ZO>Mr5?FJ0^|*7$OF z5#uFG-Gz)7FL4)Wywjb}c+p~a9^-{O-MNexEOJlMc%eIo@%#nuY>nr;vlv50Ph>oI zl{-`8dF~9xvv<1F8J{@AoyHh4I+ZbGbc)71-N}rn&vsAHc!qmC@@Cun?}do1JeJKgb&$BlK5VLW!6J5J-B?pVfS#=B!Q9_x-~JbH{fO5@S) zNXDZkx+63m?G9%=e3Uzk@zCM!P{u=cx str: + if v and not (v.startswith("http://") or v.startswith("https://")): + raise ValueError(f"weather.forecast_url must start with http(s)://, got {v!r}") + return v.rstrip("/") + + class MonitoringConfig(BaseModel): """Dead-man's-switch / heartbeat configuration. @@ -169,6 +193,7 @@ class Config(BaseModel): home_assistant: HomeAssistantConfig = HomeAssistantConfig() notify: NotifyConfig = NotifyConfig() monitoring: MonitoringConfig = MonitoringConfig() + weather: WeatherConfig = WeatherConfig() # Loaded from env, not TOML: google_maps_api_key: str = "" google_oauth_client_secret_json: str = "" diff --git a/src/commutecompass/format.py b/src/commutecompass/format.py index 7ee7e63..b780761 100644 --- a/src/commutecompass/format.py +++ b/src/commutecompass/format.py @@ -282,6 +282,12 @@ def _format_plan_summary(plan: Plan) -> str: if plan.route: lines.append(f" {escape_md(_route_summary(plan.route))}") + if plan.weather_buffer_minutes > 0 and plan.weather_reason: + emoji = "❄️" if plan.weather_reason == "snow" else "🌧️" + lines.append( + f" {emoji} {escape_md(f'+{plan.weather_buffer_minutes} min for {plan.weather_reason}')}" + ) + lines.append("") return "\n".join(lines) diff --git a/src/commutecompass/models.py b/src/commutecompass/models.py index f1f4bd7..6d831e0 100644 --- a/src/commutecompass/models.py +++ b/src/commutecompass/models.py @@ -225,6 +225,11 @@ class Plan(BaseModel): leave_at: Optional[datetime] = None prep_at: Optional[datetime] = None error: Optional[str] = None + # Extra minutes folded into the buffer for expected precipitation, plus a + # short reason ("rain"/"snow") for display. Zero when weather is disabled + # or the forecast is clear. + weather_buffer_minutes: int = 0 + weather_reason: Optional[str] = None class Alert(BaseModel): diff --git a/src/commutecompass/planner.py b/src/commutecompass/planner.py index d58f7e7..8f64cae 100644 --- a/src/commutecompass/planner.py +++ b/src/commutecompass/planner.py @@ -183,9 +183,14 @@ def geocoder(addr: str) -> Optional[GeocodeResult]: if route is None: return Plan(event=event, error="no_route") - # Step 3: compute timings + # Step 3: compute timings. Add a weather buffer when precipitation is + # expected around the event so the alarm fires earlier on a rainy/snowy day. + from commutecompass.weather import weather_buffer as _weather_buffer + + wx = _weather_buffer(route_origin.lat, route_origin.lon, event.start, config.weather) + travel = timedelta(seconds=route.total_duration_seconds) - buffer = timedelta(minutes=config.prep.safety_buffer_minutes) + buffer = timedelta(minutes=config.prep.safety_buffer_minutes + wx.minutes) prep = timedelta(minutes=config.prep.prep_minutes) leave_at = event.start - travel - buffer @@ -202,6 +207,8 @@ def geocoder(addr: str) -> Optional[GeocodeResult]: leave_at=leave_at, prep_at=prep_at, error="too_imminent", + weather_buffer_minutes=wx.minutes, + weather_reason=wx.reason, ) return Plan( @@ -209,4 +216,6 @@ def geocoder(addr: str) -> Optional[GeocodeResult]: route=route, leave_at=leave_at, prep_at=prep_at, + weather_buffer_minutes=wx.minutes, + weather_reason=wx.reason, ) diff --git a/src/commutecompass/weather.py b/src/commutecompass/weather.py new file mode 100644 index 0000000..709cb93 --- /dev/null +++ b/src/commutecompass/weather.py @@ -0,0 +1,118 @@ +"""Weather-aware departure buffer via the Open-Meteo forecast API. + +A clear-sky commute and a snowy one need different head starts. When rain or +snow is likely around the time the user would leave, we pad the buffer so the +alarm fires earlier. Open-Meteo is free and keyless, so this needs no secret. +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Any, NamedTuple, Optional + +import httpx + +from commutecompass.config import WeatherConfig +from commutecompass.retry import retry +from commutecompass.timeutil import to_nyc + +logger = logging.getLogger(__name__) + + +class WeatherBuffer(NamedTuple): + minutes: int + reason: Optional[str] # human-readable note, e.g. "rain", or None when clear + + +_CLEAR = WeatherBuffer(0, None) + + +def weather_buffer( + lat: float, + lon: float, + at_time: datetime, + config: WeatherConfig, + *, + fetcher: Optional[Any] = None, +) -> WeatherBuffer: + """Return extra buffer minutes (and a reason) for precipitation at ``at_time``. + + Returns ``WeatherBuffer(0, None)`` when disabled, on any fetch/parse error, + or when the forecast is clear — weather is an enhancement, never a reason to + fail a plan. ``fetcher`` is injectable for tests. + """ + if not config.enabled: + return _CLEAR + + fetch = fetcher or _fetch_forecast + try: + hourly = fetch(lat, lon, config.forecast_url) + except Exception as exc: + logger.debug("weather fetch failed (lat=%s lon=%s): %s", lat, lon, exc) + return _CLEAR + + return _buffer_from_hourly(hourly, at_time, config) + + +def _fetch_forecast(lat: float, lon: float, forecast_url: str) -> dict[str, Any]: + """Fetch hourly precipitation/snowfall from Open-Meteo for the next 2 days.""" + params = { + "latitude": f"{lat:.4f}", + "longitude": f"{lon:.4f}", + "hourly": "precipitation,precipitation_probability,snowfall", + "timezone": "America/New_York", + "forecast_days": "2", + } + + def _do() -> dict[str, Any]: + with httpx.Client(timeout=8.0) as client: + resp = client.get(forecast_url, params=params) + resp.raise_for_status() + data = resp.json() + hourly = data.get("hourly") if isinstance(data, dict) else None + return hourly if isinstance(hourly, dict) else {} + + return retry(_do, attempts=2, label="open-meteo") + + +def _buffer_from_hourly( + hourly: dict[str, Any], at_time: datetime, config: WeatherConfig +) -> WeatherBuffer: + """Pick the forecast hour matching ``at_time`` and derive a buffer.""" + times = hourly.get("time") + if not isinstance(times, list) or not times: + return _CLEAR + + # Open-Meteo hour stamps are local ISO strings like "2026-06-23T08:00". + target = to_nyc(at_time).strftime("%Y-%m-%dT%H:00") + try: + idx = times.index(target) + except ValueError: + return _CLEAR + + snowfall = _at(hourly.get("snowfall"), idx) + precip = _at(hourly.get("precipitation"), idx) + prob = _at(hourly.get("precipitation_probability"), idx) + + # Snow dominates — it slows every mode the most. + if snowfall is not None and snowfall > 0: + return WeatherBuffer(config.snow_buffer_minutes, "snow") + + likely = (prob is not None and prob >= config.precip_probability_threshold) or ( + precip is not None and precip > 0 + ) + if likely: + return WeatherBuffer(config.rain_buffer_minutes, "rain") + + return _CLEAR + + +def _at(series: Any, idx: int) -> Optional[float]: + """Safely read index ``idx`` from a forecast series, as a float.""" + if not isinstance(series, list) or idx >= len(series): + return None + value = series[idx] + if isinstance(value, (int, float)): + return float(value) + return None diff --git a/tests/test_format.py b/tests/test_format.py index fab0ffa..f2d3e8d 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -208,6 +208,28 @@ def test_format_digest_single_event(self) -> None: # Should start with today header assert "Today" in result + def test_format_digest_shows_weather_note(self) -> None: + """A weather buffer is surfaced as a per-event note in the digest.""" + event = make_event( + id="evt-wx", + title="Rehearsal", + calendar_name="Theatre", + start=datetime(2026, 5, 12, 9, 30, tzinfo=timezone.utc), + location="200 Example St", + location_value="200 Example St, New York, NY 10001", + ) + leg = TransitLeg( + mode="TRANSIT", system="MTA Subway", line="C", headsign="Fulton St", + depart_at=datetime(2026, 5, 12, 8, 15, tzinfo=timezone.utc), + arrive_at=datetime(2026, 5, 12, 9, 0, tzinfo=timezone.utc), + duration_seconds=2700, summary="C train", + ) + plan = make_plan(event, make_route([leg])).model_copy( + update={"weather_buffer_minutes": 15, "weather_reason": "rain"} + ) + result = format_digest([plan], []) + assert "15 min for rain" in result + def test_format_digest_multiple_events(self) -> None: """format_digest renders multiple plans with different calendars.""" event1 = make_event( diff --git a/tests/test_planner.py b/tests/test_planner.py index 9150e56..e8b8e18 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -328,6 +328,50 @@ def test_plan_event_falls_back_to_distance_estimate( assert result.route.total_duration_seconds > 0 +def test_plan_event_applies_weather_buffer( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """When weather is enabled and rain is forecast, leave_at moves earlier.""" + config.weather.enabled = True + config.weather.rain_buffer_minutes = 15 + store = MagicMock() + with patch("commutecompass.resolver.resolve", return_value=resolved_location), \ + patch("commutecompass.routing.plan_route", return_value=mock_route), \ + patch("commutecompass.weather._fetch_forecast") as mock_fetch, \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + # Forecast hour matching event.start (14:00) shows likely rain. + hour = event.start.strftime("%Y-%m-%dT%H:00") + mock_fetch.return_value = { + "time": [hour], + "precipitation": [1.0], + "precipitation_probability": [90], + "snowfall": [0.0], + } + with_weather = plan_event( + event, config, MagicMock(spec=VenueRegistry), store, MagicMock(spec=OpencodeGoClient) + ) + + config.weather.enabled = False + with patch("commutecompass.resolver.resolve", return_value=resolved_location), \ + patch("commutecompass.routing.plan_route", return_value=mock_route), \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + without_weather = plan_event( + event, config, MagicMock(spec=VenueRegistry), store, MagicMock(spec=OpencodeGoClient) + ) + + assert with_weather.weather_buffer_minutes == 15 + assert with_weather.weather_reason == "rain" + assert without_weather.weather_buffer_minutes == 0 + # 15 extra buffer minutes ⇒ leave 15 minutes earlier. + assert with_weather.leave_at is not None and without_weather.leave_at is not None + delta = (without_weather.leave_at - with_weather.leave_at).total_seconds() / 60 + assert 14.5 < delta < 15.5 + + def test_plan_event_caches_live_route( event: Event, config: Config, diff --git a/tests/test_weather.py b/tests/test_weather.py new file mode 100644 index 0000000..2ee111a --- /dev/null +++ b/tests/test_weather.py @@ -0,0 +1,79 @@ +"""Tests for the weather-aware buffer.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from commutecompass.config import WeatherConfig +from commutecompass.timeutil import NYC_TZ +from commutecompass.weather import weather_buffer + + +AT = datetime(2026, 5, 8, 14, 30, tzinfo=NYC_TZ) # → forecast hour "...T14:00" + + +def _fetcher(hourly: dict[str, Any]) -> Any: + return lambda lat, lon, url: hourly + + +def _hourly(*, precip: float, prob: int, snow: float) -> dict[str, Any]: + return { + "time": ["2026-05-08T13:00", "2026-05-08T14:00"], + "precipitation": [0.0, precip], + "precipitation_probability": [5, prob], + "snowfall": [0.0, snow], + } + + +def _raises(*a: Any, **k: Any) -> Any: + raise AssertionError("fetcher should not be called") + + +def _boom(*a: Any, **k: Any) -> Any: + raise RuntimeError("network down") + + +def test_disabled_returns_zero() -> None: + cfg = WeatherConfig(enabled=False) + # Fetcher would raise if called — disabled must short-circuit. + assert weather_buffer(40.7, -74.0, AT, cfg, fetcher=_raises) == (0, None) + + +def test_clear_forecast_returns_zero() -> None: + cfg = WeatherConfig(enabled=True) + hourly = _hourly(precip=0.0, prob=5, snow=0.0) + assert weather_buffer(40.7, -74.0, AT, cfg, fetcher=_fetcher(hourly)) == (0, None) + + +def test_rain_by_probability() -> None: + cfg = WeatherConfig(enabled=True, rain_buffer_minutes=12, precip_probability_threshold=50) + hourly = _hourly(precip=0.0, prob=80, snow=0.0) + buf = weather_buffer(40.7, -74.0, AT, cfg, fetcher=_fetcher(hourly)) + assert buf.minutes == 12 + assert buf.reason == "rain" + + +def test_rain_below_threshold_is_clear() -> None: + cfg = WeatherConfig(enabled=True, precip_probability_threshold=50) + hourly = _hourly(precip=0.0, prob=30, snow=0.0) + assert weather_buffer(40.7, -74.0, AT, cfg, fetcher=_fetcher(hourly)).minutes == 0 + + +def test_snow_takes_priority_and_uses_snow_buffer() -> None: + cfg = WeatherConfig(enabled=True, rain_buffer_minutes=10, snow_buffer_minutes=25) + hourly = _hourly(precip=2.0, prob=90, snow=1.5) + buf = weather_buffer(40.7, -74.0, AT, cfg, fetcher=_fetcher(hourly)) + assert buf.minutes == 25 + assert buf.reason == "snow" + + +def test_fetch_error_swallowed() -> None: + cfg = WeatherConfig(enabled=True) + assert weather_buffer(40.7, -74.0, AT, cfg, fetcher=_boom) == (0, None) + + +def test_missing_hour_returns_zero() -> None: + cfg = WeatherConfig(enabled=True) + hourly = {"time": ["2026-05-08T09:00"], "precipitation": [9.0], "snowfall": [0.0]} + assert weather_buffer(40.7, -74.0, AT, cfg, fetcher=_fetcher(hourly)).minutes == 0