"""Batch lifecycle manager — 4-state machine for truck+sack sessions. State machine: IDLE ──truck detected──▶ TRUCK_STABILIZING ──stable 5s──▶ COUNTING_SACKS ▲ │ truck gone │ ▲ │ └──────▶ IDLE │ │ │ │ │ │ 10s no sack activity │ │ sacks resume │ ▼ │ │ WAITING_FOR_ACTIVITY │ (batch OPEN) │ │ └──────────────── truck leaves ─────────────────────────────┘ (batch finalized) """ from __future__ import annotations import time import math from dataclasses import dataclass, field from enum import Enum, auto class BatchState(Enum): IDLE = auto() TRUCK_STABILIZING = auto() COUNTING_SACKS = auto() WAITING_FOR_ACTIVITY = auto() # Paused: no sacks, but truck still here @dataclass class BatchRecord: """Completed batch summary.""" batch_id: int start_time: float end_time: float loading_count: int unloading_count: int box_loading_count: int = 0 box_unloading_count: int = 0 @property def net_count(self) -> int: return self.loading_count - self.unloading_count @property def box_net_count(self) -> int: return self.box_loading_count - self.box_unloading_count @property def duration_seconds(self) -> float: return self.end_time - self.start_time class BatchLifecycleManager: """Manages batch transitions based on truck stability and sack activity. State flow: - IDLE: waiting for truck to appear in ROI polygon - TRUCK_STABILIZING: truck seen, tracking centroid stability - COUNTING_SACKS: actively counting sacks crossing line - WAITING_FOR_ACTIVITY: sacks idle, but truck still present — batch stays open """ def __init__( self, stabilize_seconds: float = 5.0, stabilize_threshold_px: float = 15.0, sack_idle_timeout: float = 10.0, min_batch_duration: float = 30.0, truck_gone_tolerance: float = 3.0, timeout_seconds: float = 30.0, # kept for backward compat (unused) ) -> None: # Tunable parameters self._stabilize_seconds = stabilize_seconds self._stabilize_threshold_px = stabilize_threshold_px self._sack_idle_timeout = sack_idle_timeout self._min_batch_duration = min_batch_duration self._truck_gone_tolerance = truck_gone_tolerance # Internal state self._state = BatchState.IDLE self._batch_counter = 0 self._current_batch_id: int | None = None self._batch_start_time = 0.0 self._history: list[BatchRecord] = [] # Truck stabilization tracking self._truck_first_seen_time = 0.0 self._truck_last_centroid: tuple[float, float] | None = None self._truck_stable_since = 0.0 self._truck_is_stable = False self._truck_last_seen = 0.0 # timestamp when truck was last detected # Sack activity tracking (for pause condition) self._last_sack_crossing_time = 0.0 self._last_sack_seen_in_area_time = 0.0 # Waiting state tracking self._waiting_since = 0.0 # Callbacks self._on_batch_start: list = [] self._on_batch_end: list = [] # -- Public API: Register callbacks -- def on_batch_start(self, callback) -> None: """Register callback: fn(batch_id, timestamp).""" self._on_batch_start.append(callback) def on_batch_end(self, callback) -> None: """Register callback: fn(BatchRecord).""" self._on_batch_end.append(callback) # -- Public API: State update methods -- def update_truck( self, truck_detected: bool, truck_centroid: tuple[float, float] | None, timestamp: float, ) -> None: """Called during IDLE, TRUCK_STABILIZING, and WAITING_FOR_ACTIVITY states. Args: truck_detected: whether a truck is detected in the ROI polygon truck_centroid: (cx, cy) of the truck bounding box, or None timestamp: current time.time() """ if self._state == BatchState.IDLE: if truck_detected and truck_centroid is not None: # Transition to STABILIZING self._state = BatchState.TRUCK_STABILIZING self._truck_first_seen_time = timestamp self._truck_last_centroid = truck_centroid self._truck_stable_since = timestamp self._truck_is_stable = False self._truck_last_seen = timestamp print(f"[BATCH] Truk terdeteksi di area. Memantau stabilitas...") if self._stabilize_seconds <= 0.0: self._truck_is_stable = True print(f"[BATCH] Instant start batch (stabilize_seconds <= 0). Memulai counting...") self._start_batch(timestamp) elif self._state == BatchState.TRUCK_STABILIZING: if truck_detected: self._truck_last_seen = timestamp # Check if truck has been gone for too long (tolerance) time_since_last_seen = timestamp - self._truck_last_seen if not truck_detected and time_since_last_seen >= self._truck_gone_tolerance: print(f"[BATCH] Truk hilang selama {time_since_last_seen:.1f}s. Kembali ke IDLE.") self._state = BatchState.IDLE self._truck_last_centroid = None self._truck_is_stable = False return if truck_centroid is not None and self._truck_last_centroid is not None: # Calculate centroid displacement dx = truck_centroid[0] - self._truck_last_centroid[0] dy = truck_centroid[1] - self._truck_last_centroid[1] displacement = math.sqrt(dx * dx + dy * dy) if displacement > self._stabilize_threshold_px: # Truck moved too much -> reset stability timer self._truck_stable_since = timestamp self._truck_is_stable = False self._truck_last_centroid = truck_centroid # Check if stable long enough stable_duration = timestamp - self._truck_stable_since if stable_duration >= self._stabilize_seconds: if not self._truck_is_stable: self._truck_is_stable = True print(f"[BATCH] Truk stabil selama {stable_duration:.1f}s. Memulai counting...") self._start_batch(timestamp) elif self._state == BatchState.WAITING_FOR_ACTIVITY: if truck_detected: self._truck_last_seen = timestamp # Check if truck has been gone for tolerance period time_since_last_seen = timestamp - self._truck_last_seen if not truck_detected and time_since_last_seen >= self._truck_gone_tolerance: # Truck has truly left! NOW we finalize the batch. wait_duration = timestamp - self._waiting_since print( f"[BATCH] Truk pergi setelah menunggu {wait_duration:.0f}s. " f"Batch selesai." ) self._end_batch(timestamp, self._pending_loading, self._pending_unloading) def update_sacks( self, has_crossing_event: bool, sacks_in_area_count: int, timestamp: float, loading_count: int = 0, unloading_count: int = 0, ) -> None: """Called during COUNTING_SACKS and WAITING_FOR_ACTIVITY states. Args: has_crossing_event: True if a sack crossed the counting line this frame sacks_in_area_count: number of sacks currently detected in truck area timestamp: current time.time() loading_count: current cumulative loading count unloading_count: current cumulative unloading count """ # WAITING_FOR_ACTIVITY: if sacks appear again, resume counting in the SAME batch if self._state == BatchState.WAITING_FOR_ACTIVITY: if has_crossing_event or sacks_in_area_count > 0: wait_duration = timestamp - self._waiting_since print( f"[BATCH] Aktivitas karung terdeteksi setelah {wait_duration:.0f}s menunggu. " f"Melanjutkan counting batch #{self._current_batch_id}..." ) self._state = BatchState.COUNTING_SACKS self._last_sack_crossing_time = timestamp self._last_sack_seen_in_area_time = timestamp # Fall through to counting logic below else: return if self._state != BatchState.COUNTING_SACKS: return # Update activity timers if has_crossing_event: self._last_sack_crossing_time = timestamp if sacks_in_area_count > 0: self._last_sack_seen_in_area_time = timestamp # Store latest counts for when batch eventually ends self._pending_loading = loading_count self._pending_unloading = unloading_count # Check pause condition: no sack activity for timeout period batch_duration = timestamp - self._batch_start_time time_since_last_crossing = timestamp - self._last_sack_crossing_time time_since_last_sack_seen = timestamp - self._last_sack_seen_in_area_time if ( batch_duration >= self._min_batch_duration and time_since_last_crossing >= self._sack_idle_timeout and time_since_last_sack_seen >= self._sack_idle_timeout ): print( f"[BATCH] Tidak ada aktivitas karung selama {self._sack_idle_timeout}s. " f"Menunggu truk pergi atau palet selanjutnya..." ) self._state = BatchState.WAITING_FOR_ACTIVITY self._waiting_since = timestamp self._truck_last_seen = timestamp # Reset agar tolerance timer mulai dari 0, bukan dari awal batch # -- Public API: Backward-compatible update (legacy) -- def update( self, truck_detected: bool, timestamp: float, loading_count: int = 0, unloading_count: int = 0, ) -> None: """Legacy update method — kept for backward compatibility.""" if self._state in (BatchState.IDLE, BatchState.TRUCK_STABILIZING): self.update_truck(truck_detected, None, timestamp) elif self._state in (BatchState.COUNTING_SACKS, BatchState.WAITING_FOR_ACTIVITY): self.update_sacks( has_crossing_event=False, sacks_in_area_count=1 if truck_detected else 0, timestamp=timestamp, loading_count=loading_count, unloading_count=unloading_count, ) # -- Properties -- @property def state(self) -> str: """Return current state as human-readable string.""" return self._state.name @property def current_batch_id(self) -> int | None: return self._current_batch_id @property def is_active(self) -> bool: """True during COUNTING or WAITING (batch is still open).""" return self._state in (BatchState.COUNTING_SACKS, BatchState.WAITING_FOR_ACTIVITY) @property def is_counting(self) -> bool: """True only during active sack counting.""" return self._state == BatchState.COUNTING_SACKS @property def is_waiting(self) -> bool: """True when paused waiting for next pallet or truck departure.""" return self._state == BatchState.WAITING_FOR_ACTIVITY @property def is_stabilizing(self) -> bool: return self._state == BatchState.TRUCK_STABILIZING @property def history(self) -> list[BatchRecord]: return list(self._history) @property def batch_duration(self) -> float: """Duration of current batch in seconds (0 if not active).""" if not self.is_active: return 0.0 return time.time() - self._batch_start_time @property def time_since_last_sack_activity(self) -> float: """Seconds since last sack crossed line or seen in area.""" if not self.is_active: return 0.0 now = time.time() last_activity = max(self._last_sack_crossing_time, self._last_sack_seen_in_area_time) return now - last_activity if last_activity > 0 else 0.0 @property def waiting_duration(self) -> float: """How long we've been in WAITING_FOR_ACTIVITY state.""" if self._state != BatchState.WAITING_FOR_ACTIVITY: return 0.0 return time.time() - self._waiting_since @property def stabilize_progress(self) -> float: """Progress of truck stabilization (0.0 to 1.0).""" if self._state != BatchState.TRUCK_STABILIZING: return 0.0 if self._stabilize_seconds <= 0.0: return 1.0 elapsed = time.time() - self._truck_stable_since return min(1.0, elapsed / self._stabilize_seconds) def resume_batch( self, batch_id: int, start_time: float, loading_count: int, unloading_count: int, ) -> None: """Resume a previously finalized batch.""" self._current_batch_id = batch_id self._batch_counter = max(self._batch_counter, batch_id) self._batch_start_time = start_time self._pending_loading = loading_count self._pending_unloading = unloading_count self._state = BatchState.COUNTING_SACKS # Pop from history if it was just completed if self._history and self._history[-1].batch_id == batch_id: self._history.pop() # -- Private methods -- def _start_batch(self, timestamp: float) -> None: self._batch_counter += 1 self._current_batch_id = self._batch_counter self._batch_start_time = timestamp self._last_sack_crossing_time = timestamp # Grace period self._last_sack_seen_in_area_time = timestamp # Grace period self._pending_loading = 0 self._pending_unloading = 0 self._state = BatchState.COUNTING_SACKS for cb in self._on_batch_start: cb(self._current_batch_id, timestamp) def _end_batch( self, timestamp: float, loading_count: int, unloading_count: int, ) -> None: record = BatchRecord( batch_id=self._current_batch_id or 0, start_time=self._batch_start_time, end_time=timestamp, loading_count=loading_count, unloading_count=unloading_count, ) self._history.append(record) self._state = BatchState.IDLE self._current_batch_id = None self._truck_last_centroid = None self._truck_is_stable = False for cb in self._on_batch_end: cb(record)