Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions custom_components/grow_conductor/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pure scheduling core — no homeassistant imports allowed (see docs/ENGINE_SPEC.md)."""
103 changes: 103 additions & 0 deletions custom_components/grow_conductor/core/budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Plant-day budget ledger (ENGINE_SPEC §2, §5)."""

from __future__ import annotations

from datetime import datetime, timedelta

from .tunables import Tunables


class PlantDayLedger:
"""Tracks delivered light per plant day, accruing from reported truth (2.2).

All methods take aware ``datetime``s supplied by the caller; the
ledger never reads clocks. Callers must feed time monotonically —
the adapter guarantees this by stamping every event on arrival.
"""

def __init__(self, tunables: Tunables) -> None:
self._tunables = tunables
self._day_start: datetime | None = None
self._spent_s = 0.0
self._lit_since: datetime | None = None

# -- plant-day geometry -------------------------------------------------

def anchor_before(self, now: datetime) -> datetime:
"""The most recent anchor at or before ``now`` (rule 2.1)."""
minutes = self._tunables.anchor_minutes
candidate = now.replace(hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0)
if candidate > now:
candidate -= timedelta(days=1)
return candidate

def next_anchor(self, now: datetime) -> datetime:
return self.anchor_before(now) + timedelta(days=1)

@property
def day_start(self) -> datetime | None:
return self._day_start

@property
def lit(self) -> bool:
return self._lit_since is not None

# -- accrual --------------------------------------------------------------

def roll(self, now: datetime) -> None:
"""Advance to the plant day containing ``now``.

Crossing an anchor charges the pre-anchor part of an open block to
the old day and resets spent for the new one; the block itself
keeps burning (rule 2.4).
"""
current = self.anchor_before(now)
if self._day_start is None:
self._day_start = current
return
if current > self._day_start:
if self._lit_since is not None:
self._lit_since = max(self._lit_since, current)
self._spent_s = 0.0
self._day_start = current

def light_reported(self, on: bool | None, now: datetime) -> None:
"""Fold a physical light-state report into the ledger (rule 2.2).

``None`` (unknown) closes any open block: nothing accrues while
the light state is unknown.
"""
self.roll(now)
if on and self._lit_since is None:
self._lit_since = now
elif not on and self._lit_since is not None:
self._spent_s += (now - self._lit_since).total_seconds()
self._lit_since = None

def spent_s(self, now: datetime) -> float:
"""Seconds delivered so far this plant day, including any open block."""
self.roll(now)
open_s = (now - self._lit_since).total_seconds() if self._lit_since is not None else 0.0
return self._spent_s + open_s

def headroom_s(self, now: datetime, target_s: float) -> float:
return max(0.0, target_s - self.spent_s(now))

def cap_instant(self, now: datetime, target_s: float) -> datetime:
"""The instant the budget runs out if the light burns from ``now`` on."""
return now + timedelta(seconds=self.headroom_s(now, target_s))

# -- persistence (rule 5.1) ------------------------------------------------

def seed(self, day_start: datetime, spent_s: float, now: datetime) -> bool:
"""Adopt a persisted (day_start, spent) pair if it is still current.

Returns True when adopted. A seed from a different plant day is
discarded and the ledger starts fresh (rule 5.1).
"""
if day_start != self.anchor_before(now):
self.roll(now)
return False
self._day_start = day_start
self._spent_s = max(0.0, spent_s)
return True
216 changes: 216 additions & 0 deletions custom_components/grow_conductor/core/engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""The event-driven scheduling engine (ENGINE_SPEC §1-§5).

The adapter feeds events stamped with aware datetimes; the engine never
reads clocks (deterministic and directly testable). Every mutation
returns the fresh :class:`Decision`; between events the decision is
stable until ``next_review`` (rule 3.2).
"""

from __future__ import annotations

from datetime import datetime, timedelta

from .budget import PlantDayLedger
from .model import Decision, InputSnapshot, LightState, Reason
from .tunables import DEFAULT_TARGET_HOURS, Tunables, clamp_target_hours
from .visibility import RefugeView, Verdict, ViewerMemory, assess


class Engine:
def __init__(
self,
tunables: Tunables | None = None,
target_hours: float = DEFAULT_TARGET_HOURS,
) -> None:
self._tunables = tunables or Tunables()
self._target_s = clamp_target_hours(target_hours) * 3600.0
self._ledger = PlantDayLedger(self._tunables)
self._snapshot = InputSnapshot()
# Viewer memory (rules 1.3/1.3b/1.3c).
self._viewer_active_since: datetime | None = None
self._viewer_last_end: datetime | None = None
self._viewer_sustained_end: datetime | None = None
# Refuge memory (rule 1.7).
self._refuge_since: datetime | None = None
self._refuge_last_active: datetime | None = None
self._refuge_confirmed = False
self._enabled = True
self._light_on: bool | None = None
self._decision: Decision | None = None

# -- read surface ---------------------------------------------------------

@property
def tunables(self) -> Tunables:
return self._tunables

@property
def enabled(self) -> bool:
return self._enabled

@property
def target_hours(self) -> float:
return self._target_s / 3600.0

@property
def snapshot(self) -> InputSnapshot:
return self._snapshot

@property
def light_on(self) -> bool | None:
return self._light_on

@property
def decision(self) -> Decision | None:
"""The most recent decision (None before the first event)."""
return self._decision

def spent_s(self, now: datetime) -> float:
return self._ledger.spent_s(now)

def day_start(self, now: datetime) -> datetime:
self._ledger.roll(now)
assert self._ledger.day_start is not None
return self._ledger.day_start

# -- events -----------------------------------------------------------------

def handle_snapshot(self, snapshot: InputSnapshot, now: datetime) -> Decision:
"""Fold a normalized input snapshot into the engine."""
previous = self._snapshot
# Rule 1.3: track viewer episodes edge-to-edge, so holds count
# from the end of activity and 1.3c can measure episode length.
if snapshot.viewer_active and not previous.viewer_active:
self._viewer_active_since = now
elif not snapshot.viewer_active and previous.viewer_active:
self._viewer_last_end = now
started = self._viewer_active_since
if (
started is not None
and (now - started).total_seconds() >= self._tunables.exposure_grace_s
):
self._viewer_sustained_end = now
self._viewer_active_since = None
# Rule 1.7: an unconfirmed engagement restarts its confirmation
# clock on every rising edge (continuity requirement); a stale
# confirmed engagement (gap > refuge_hold) starts over entirely.
if snapshot.refuge_active and not previous.refuge_active:
lapsed = (
self._refuge_last_active is None
or (now - self._refuge_last_active).total_seconds() >= self._tunables.refuge_hold_s
)
if lapsed or not self._refuge_confirmed:
self._refuge_since = now
self._refuge_confirmed = False
elif not snapshot.refuge_active and previous.refuge_active:
# Falling edge: the evidence was present up to this instant —
# the rule-1.7 hold counts from here.
self._refuge_last_active = now
self._snapshot = snapshot
return self._decide(now)

def activity_pulse(self, now: datetime) -> Decision:
"""Instantaneous viewer activity from a trigger entity (rule 1.3b).

Stamps rule 1.3's activity clock — cutting the light and starting
the clear-hold — without sustaining activity; the trigger's level
is never part of the snapshot. During confirmed refuge, rule 1.3c
ignores pulses entirely (they are by definition transient).
"""
self._viewer_last_end = now
return self._decide(now)

def light_reported(self, on: bool | None, now: datetime) -> Decision:
"""Fold the physical light state into the ledger (rule 2.2)."""
self._light_on = on
self._ledger.light_reported(on, now)
return self._decide(now)

def set_target(self, hours: float, now: datetime) -> Decision:
self._target_s = clamp_target_hours(hours) * 3600.0
return self._decide(now)

def set_enabled(self, enabled: bool, now: datetime) -> Decision:
self._enabled = enabled
return self._decide(now)

def tick(self, now: datetime) -> Decision:
"""Re-evaluate with no input change (the ``next_review`` timer, 3.2)."""
return self._decide(now)

def seed_budget(self, day_start: datetime, spent_s: float, now: datetime) -> bool:
"""Adopt persisted budget state if still current (rule 5.1)."""
return self._ledger.seed(day_start, spent_s, now)

# -- decision (rule 3.1) ------------------------------------------------------

def _refuge_view(self, now: datetime) -> RefugeView:
"""Advance the rule-1.7 confirm latch / hold expiry, then report."""
if self._snapshot.refuge_active:
self._refuge_last_active = now
if (
self._refuge_since is not None
and (now - self._refuge_since).total_seconds() >= self._tunables.refuge_confirm_s
):
self._refuge_confirmed = True
elif (
self._refuge_last_active is None
or (now - self._refuge_last_active).total_seconds() >= self._tunables.refuge_hold_s
):
self._refuge_since = None
self._refuge_confirmed = False

ready_at = None
if self._snapshot.refuge_active and not self._refuge_confirmed:
assert self._refuge_since is not None
ready_at = self._refuge_since + timedelta(seconds=self._tunables.refuge_confirm_s)
held_until = None
if self._refuge_confirmed and not self._snapshot.refuge_active:
assert self._refuge_last_active is not None
held_until = self._refuge_last_active + timedelta(seconds=self._tunables.refuge_hold_s)
return RefugeView(engaged=self._refuge_confirmed, ready_at=ready_at, held_until=held_until)

def _decide(self, now: datetime) -> Decision:
self._ledger.roll(now)
verdict = assess(
self._snapshot,
now,
ViewerMemory(
active_since=self._viewer_active_since,
last_end=self._viewer_last_end,
sustained_end=self._viewer_sustained_end,
),
self._refuge_view(now),
self._tunables,
)
reviews = [self._ledger.next_anchor(now)]

if not self._enabled:
decision = Decision(False, LightState.STANDBY, None, min(reviews))
elif not verdict.may_light:
decision = self._decide_dark(verdict, reviews)
else:
decision = self._decide_light(verdict, now, reviews)

self._decision = decision
return decision

def _decide_dark(self, verdict: Verdict, reviews: list[datetime]) -> Decision:
if verdict.review_at is not None:
reviews.append(verdict.review_at)
if verdict.cooldown_until is not None:
reviews.append(verdict.cooldown_until)
return Decision(False, LightState.COOLDOWN, None, min(reviews))
return Decision(False, LightState.OBSERVED, verdict.reason, min(reviews))

def _decide_light(self, verdict: Verdict, now: datetime, reviews: list[datetime]) -> Decision:
spent = self._ledger.spent_s(now)
if spent >= self._target_s: # rule 2.3
return Decision(False, LightState.SATED, Reason.TARGET_REACHED, min(reviews))
headroom = self._target_s - spent
if self._light_on is not True and headroom < self._tunables.min_block_s: # rule 2.5
return Decision(False, LightState.SATED, Reason.LOW_HEADROOM, min(reviews))
if verdict.review_at is not None: # a tolerated exposure may mature (1.3c)
reviews.append(verdict.review_at)
reviews.append(self._ledger.cap_instant(now, self._target_s))
return Decision(True, LightState.LIT, verdict.reason, min(reviews))
59 changes: 59 additions & 0 deletions custom_components/grow_conductor/core/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Value types shared across the core (ENGINE_SPEC §0, §3)."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum


class LightState(StrEnum):
"""Display state (rule 3.3)."""

LIT = "lit"
OBSERVED = "observed"
COOLDOWN = "cooldown"
SATED = "sated"
STANDBY = "standby"


class Reason(StrEnum):
"""Why the engine is in its display state (rule 3.3)."""

# LIT
ASLEEP = "asleep"
AWAY = "away"
REFUGE = "refuge"
# OBSERVED
VETO = "veto"
VIEWERS = "viewers"
AWAKE_HOME = "awake_home"
# SATED
TARGET_REACHED = "target_reached"
LOW_HEADROOM = "low_headroom"


@dataclass(frozen=True)
class InputSnapshot:
"""Normalized input signals (ENGINE_SPEC §1).

``None`` means unknown; rule 1.8 defines the fail-safe resolution.
Viewer/refuge/veto aggregation over entity lists happens in the
adapter — the core only sees the OR.
"""

asleep: bool | None = None
anyone_home: bool | None = None
viewer_active: bool = False
refuge_active: bool = False
veto_active: bool = False


@dataclass(frozen=True)
class Decision:
"""The engine's verdict, stable until an event or ``next_review`` (3.1, 3.2)."""

want_on: bool
state: LightState
reason: Reason | None
next_review: datetime | None
Loading
Loading