402 lines
15 KiB
Python
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)
|