from dataclasses import dataclass @dataclass class OccupancyConfig: lone_confirm_s: float = 30.0 # 단독 작업이 이 시간 지속돼야 발보 (동료의 잠깐 이탈 흡수) over_confirm_s: float = 10.0 # 정원 초과 지속 요구 (경계 통과 순간의 겹침 흡수) dwell_absence_grace_s: float = 5.0 # 이 시간 안의 미검출은 체류 연속으로 간주 (검출 드롭아웃 흡수) class OccupancyWatchdog: """존 규칙(2인 1조 · 정원 · 체류 상한) 감시. 존 멤버십은 ZoneIntrusionDetector가 이미 계산한 것을 엔진이 존별 인원으로 뒤집어 넘겨준다 — 여기서는 기하 계산이 없다. 규칙은 Zone.rules(대시보드 존 편집기에서 설정, /api/config로 동기)에서 읽으므로 존 목록 교체(apply_zones)에 자동으로 따라간다. 발보는 조건이 해소될 때까지 존(또는 트랙·존)당 1회 — 재발보는 엔진 쿨다운이 아니라 조건 해소 후 재충족으로만 일어난다. """ def __init__(self, cfg: OccupancyConfig): self.cfg = cfg self._lone_since: dict[str, float] = {} self._lone_fired: set[str] = set() self._over_since: dict[str, float] = {} self._over_fired: set[str] = set() self._dwell: dict[tuple[int, str], list] = {} # (tid, zone_id) -> [since, last_seen] self._dwell_fired: set[tuple[int, str]] = set() def forget(self, track_id: int) -> None: self._dwell = {k: v for k, v in self._dwell.items() if k[0] != track_id} self._dwell_fired = {k for k in self._dwell_fired if k[0] != track_id} def update(self, zones: list, zone_tracks: dict, now: float) -> list[dict]: out: list[dict] = [] known: set[str] = set() for zone in zones: zid = zone.zone_id known.add(zid) rules = zone.rules or {} tids = zone_tracks.get(zid, set()) n = len(tids) # 2인 1조: 0 < 인원 < 최소 인원이 지속되면 발보. 빈 존은 위반이 아니다. min_workers = rules.get("min_workers") if min_workers and 0 < n < min_workers: start = self._lone_since.setdefault(zid, now) if now - start >= self.cfg.lone_confirm_s and zid not in self._lone_fired: self._lone_fired.add(zid) out.append({"kind": "lone_worker", "zone_id": zid, "track_id": next(iter(tids)) if n == 1 else None, "metrics": {"workers": n, "required": min_workers, "alone_s": round(now - start, 1)}}) else: self._lone_since.pop(zid, None) self._lone_fired.discard(zid) # 정원 초과. `is not None` 이어야 한다 — 0 은 falsy 라 `if max_workers` 로 쓰면 # "아무도 있으면 안 되는 구역"(크레인 짐 아래·굴착기 선회 반경 = 깔림 사고의 전형)이 # 화면에는 규칙으로 저장돼 있는데 감지기는 통째로 꺼진 상태가 된다. # 키가 아예 없는 것(제한 없음)과 0(아무도 안 됨)은 다른 뜻이다. max_workers = rules.get("max_workers") if max_workers is not None and n > max_workers: start = self._over_since.setdefault(zid, now) if now - start >= self.cfg.over_confirm_s and zid not in self._over_fired: self._over_fired.add(zid) out.append({"kind": "overcapacity", "zone_id": zid, "track_id": None, "metrics": {"workers": n, "limit": max_workers}}) else: self._over_since.pop(zid, None) self._over_fired.discard(zid) # 체류 상한 (트랙별 연속 체류). 미검출 1~2틱(run_once의 stale 필터)으로 타이머가 # 리셋되면 10분 규칙이 사실상 무력화되므로, grace 안의 부재는 연속으로 간주한다. max_dwell = rules.get("max_dwell_s") if max_dwell: for tid in tids: rec = self._dwell.setdefault((tid, zid), [now, now]) rec[1] = now if now - rec[0] >= max_dwell and (tid, zid) not in self._dwell_fired: self._dwell_fired.add((tid, zid)) out.append({"kind": "overstay", "zone_id": zid, "track_id": tid, "metrics": {"dwell_s": round(now - rec[0], 1), "limit_s": max_dwell}}) # grace를 넘겨 존을 떠난(또는 트랙이 죽은) 체류 기록 정리 — 재진입은 새 체류다. for key, rec in list(self._dwell.items()): if now - rec[1] > self.cfg.dwell_absence_grace_s: self._dwell.pop(key) self._dwell_fired.discard(key) # 삭제된 존의 잔여 상태 정리 (24/7 장치 메모리 누수 방지). for d, fired in ((self._lone_since, self._lone_fired), (self._over_since, self._over_fired)): for zid in list(d): if zid not in known: d.pop(zid, None) fired.intersection_update(known) return out