move_detect/tests/test_occupancy.py
유창욱 a203c42bf3 feat(zones): occupancy rule engine - lone-worker / capacity / dwell limits
Per-zone declarative safety rules, set in the zone editor and enforced on the
edge from the tracking the pipeline already does (no new geometry, no new model):

- min_workers (2인 1조): 0 < count < minimum sustained 30s -> lone_worker/HIGH.
  A solo worker in a buddy-rule zone is a compliance violation and a rescue-
  delay risk, so it outranks the other two.
- max_workers: count > limit sustained 10s -> overcapacity/WARNING.
- max_dwell_s: one track's continuous stay past the limit -> overstay/WARNING.
  A short detection dropout (<=5s grace) does NOT reset the dwell timer -
  without that, run_once's stale-track filter would quietly neuter every
  10-minute rule at real-world detection rates.

Edge: detectors/occupancy.py consumes Zone.rules; the engine flips the zone
detector's per-track membership into per-zone headcounts (stale tracks already
excluded), so counts always match what was actually detected this tick. Fired
states clear on condition recovery - re-alerts need a fresh violation, not a
cooldown expiry. Malformed rules_json keeps the zone's intrusion detection
alive and logs the rules as inactive (never fail dark).

Server: move_zones.rules_json (nullable JSON, migration included) validated
strictly in ZoneConfigController (bounds, min<=max, unknown keys rejected -
a silently dropped rule is one the operator believes is active). Delivered to
the edge unchanged via GET /api/config. EventValidator accepts the 3 new types.

UI: zone editor gains the three rule fields (dwell entered in minutes, wired
in seconds) and shows each zone's rules in its list row; the events console
and detail view now render every event type in Korean (wire codes fall back).

Verified: pytest 216 pass / PHPUnit 123 pass + real-render harness in Chrome
(editor save payload, list labels, console type labels).
2026-08-06 19:33:12 +09:00

114 lines
4.7 KiB
Python

from edge.detectors.zone import Zone
from edge.detectors.occupancy import OccupancyWatchdog, OccupancyConfig
_POLY = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
def _zone(zone_id="z1", **rules):
return Zone(zone_id, "restricted", _POLY, rules or None)
def _watch(**cfg):
return OccupancyWatchdog(OccupancyConfig(**cfg))
def test_no_rules_emits_nothing():
w = _watch()
zones = [Zone("z1", "restricted", _POLY)]
assert w.update(zones, {"z1": {1}}, now=0.0) == []
assert w.update(zones, {"z1": {1}}, now=999.0) == []
def test_lone_worker_fires_after_sustained_solo():
w = _watch(lone_confirm_s=30.0)
zones = [_zone(min_workers=2)]
assert w.update(zones, {"z1": {7}}, now=0.0) == [] # 방금 혼자가 됨
assert w.update(zones, {"z1": {7}}, now=29.0) == [] # 아직 유예
out = w.update(zones, {"z1": {7}}, now=31.0)
assert out == [{"kind": "lone_worker", "zone_id": "z1", "track_id": 7,
"metrics": {"workers": 1, "required": 2, "alone_s": 31.0}}]
# 조건이 계속돼도 재발보 없음 (해소 후 재충족으로만)
assert w.update(zones, {"z1": {7}}, now=60.0) == []
def test_colleague_return_resets_lone_timer():
w = _watch(lone_confirm_s=30.0)
zones = [_zone(min_workers=2)]
w.update(zones, {"z1": {7}}, now=0.0)
w.update(zones, {"z1": {7, 8}}, now=20.0) # 동료 복귀 → 리셋
assert w.update(zones, {"z1": {7}}, now=40.0) == [] # 새로 30초 필요
assert len(w.update(zones, {"z1": {7}}, now=71.0)) == 1
def test_empty_zone_is_not_a_lone_violation():
w = _watch(lone_confirm_s=0.0)
zones = [_zone(min_workers=2)]
assert w.update(zones, {}, now=0.0) == []
assert w.update(zones, {}, now=100.0) == []
def test_overcapacity_fires_after_sustained_excess():
w = _watch(over_confirm_s=10.0)
zones = [_zone(max_workers=2)]
assert w.update(zones, {"z1": {1, 2, 3}}, now=0.0) == []
out = w.update(zones, {"z1": {1, 2, 3}}, now=11.0)
assert out == [{"kind": "overcapacity", "zone_id": "z1", "track_id": None,
"metrics": {"workers": 3, "limit": 2}}]
# 해소 후 재충족이면 다시 발보
w.update(zones, {"z1": {1, 2}}, now=20.0)
w.update(zones, {"z1": {1, 2, 3}}, now=30.0)
assert len(w.update(zones, {"z1": {1, 2, 3}}, now=41.0)) == 1
def test_overstay_fires_per_track():
w = _watch()
zones = [_zone(max_dwell_s=60)]
w.update(zones, {"z1": {1}}, now=0.0)
w.update(zones, {"z1": {1, 2}}, now=30.0) # 2번은 30초에 진입
out = w.update(zones, {"z1": {1, 2}}, now=61.0) # 1번만 60초 초과
assert [o["track_id"] for o in out] == [1]
assert out[0]["kind"] == "overstay"
out = w.update(zones, {"z1": {1, 2}}, now=91.0) # 이제 2번도 초과
assert [o["track_id"] for o in out] == [2]
def test_dwell_survives_short_detection_dropout():
# run_once의 stale 필터로 1~2틱 미검출이 흔하다. grace 안의 부재는 체류 연속으로
# 간주해야 10분 규칙이 실사용에서 무력화되지 않는다.
w = _watch(dwell_absence_grace_s=5.0)
zones = [_zone(max_dwell_s=60)]
w.update(zones, {"z1": {1}}, now=0.0)
w.update(zones, {}, now=30.0) # 드롭아웃 (grace보다 김? 아님: 아래 재검출로 판단)
out = w.update(zones, {"z1": {1}}, now=61.0)
# 30.0의 부재는 last_seen(0.0)에서 5초 grace를 초과 → 새 체류로 리셋되었다.
assert out == []
# 연속 체류가 실제로 60초를 넘기면 발보
w.update(zones, {"z1": {1}}, now=90.0)
w.update(zones, {"z1": {1}}, now=120.0)
assert len(w.update(zones, {"z1": {1}}, now=122.0)) == 1
def test_dwell_short_gap_within_grace_keeps_timer():
w = _watch(dwell_absence_grace_s=5.0)
zones = [_zone(max_dwell_s=60)]
w.update(zones, {"z1": {1}}, now=0.0)
w.update(zones, {}, now=2.0) # 2초 드롭아웃 — grace 이내
out = w.update(zones, {"z1": {1}}, now=61.0)
assert len(out) == 1 and out[0]["metrics"]["dwell_s"] == 61.0
def test_forget_clears_dwell_state():
w = _watch()
zones = [_zone(max_dwell_s=60)]
w.update(zones, {"z1": {1}}, now=0.0)
w.forget(1)
assert w.update(zones, {"z1": {1}}, now=61.0) == [] # 새 체류로 시작
def test_deleted_zone_state_is_pruned():
w = _watch(lone_confirm_s=0.0, over_confirm_s=0.0)
zones = [_zone(min_workers=2, max_workers=1)]
w.update(zones, {"z1": {1}}, now=0.0)
w.update([], {}, now=1.0) # 존 삭제 (config 동기)
assert w._lone_since == {} and w._over_since == {}
assert w._lone_fired == set() and w._over_fired == set()