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).
114 lines
4.7 KiB
Python
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()
|