Files
feedmill-recounter/src/batch.py
T

402 lines
15 KiB
Python

"""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)