HerdTag

Source: docs/controller.md

Fence controller

The controller is the logic that runs on the tag. The reference implementation is src/herdtag/controller.py, and the C port is firmware/core/ht_core.c. On every GPS fix it answers three questions:

  1. Which state is the animal in, relative to the fence?
  2. Should the tag play a cue right now?
  3. When should the next GPS fix be taken?
from herdtag.controller import FenceController, Fix, Config
from herdtag.geo import Fence

ctl = FenceController(Fence([(0, 0), (100, 0), (100, 100), (0, 100)]))  # metres, local frame
d = ctl.update(Fix(t=0, x=50, y=85, h_acc=2.0))
d.state, d.cue, d.next_fix_in_s, d.distance_m, d.approach_mps, d.events

Positions are in metres, in a local east/north frame centred on the fence (geo.LocalFrame). Distance to the fence is signed: positive inside, negative outside.

States

State Meaning Cues? Next fix
inside More than near_zone_m (50 m) inside the boundary no time-to-zone, capped at far_interval_s (300 s)
near Between warn_buffer_m (20 m) and 50 m inside no time-to-zone, capped at near_interval_s (30 s)
warning Within 20 m of the boundary, still inside if approaching and caps allow warn_interval_s (5 s), or warn_idle_interval_s (15 s) when not closing
breach Confidently outside never breach_interval_s (60 s)
degraded The fix's accuracy estimate is worse than max_h_acc_m (8 m) never degraded_interval_s (30 s)
stateDiagram-v2
  [*] --> inside
  inside --> near: d < 50 m
  near --> inside: d ≥ 50 m
  near --> warning: d < 20 m
  warning --> near: d ≥ 20 m
  inside --> warning: d < 20 m (fast walker)
  warning --> breach: d < -max(3 m, h_acc)
  near --> breach: d < -max(3 m, h_acc)
  breach --> warning: d ≥ +5 m (returned)
  breach --> near: d ≥ +5 m (returned)
  inside --> degraded: h_acc > 8 m
  near --> degraded: h_acc > 8 m
  warning --> degraded: h_acc > 8 m
  breach --> degraded: h_acc > 8 m
  degraded --> inside: good fix
  degraded --> warning: good fix
  degraded --> breach: good fix, still outside

Breach hysteresis

Approach detection

Position alone isn't enough: a cow standing at the line, or walking away from it, shouldn't hear anything. The controller keeps the signed distance of each good fix from the last trend_window_s (30 s). It needs at least min_trend_points (3) of them, and fits a least-squares line through distance vs. time. The negative of that slope is the approach rate in m/s.

Fitting over several fixes, instead of differencing two, matters because GPS error (about 2.5 m typical) is bigger than the distance a grazing cow covers in 5 s.

Sampling strategy

GPS is the biggest energy cost, so the fix interval adapts.

Time-to-zone (inside / near). The next fix is scheduled early enough that a cow walking straight at the fence at max_speed_mps (1.5 m/s) will still have lead_fixes (3) fixes, and so a usable trend, by the time it enters the warning zone:

interval = max(warn_interval_s, min(ceiling, (d - warn_buffer_m) / max_speed_mps / lead_fixes))

The ceiling is 300 s in inside and 30 s in near. This replaced a fixed 30 s near-zone interval: in simulation, walking cows crossed the whole 20 m zone between fixes. The schedule depends only on distance to the boundary, never on motion-sensor input.

Warning zone. Fixes come every 5 s while the animal is closing or the trend is unknown. Once the trend shows it isn't closing, the interval relaxes to 15 s. warn_idle_interval_s is the main power/safety knob. Cows that loiter at the fence all day otherwise keep the tag at 5 s fixes and drain it in days (see Simulation).

Cueing and caps

In warning, while approaching, each fix triggers one cue burst (cue_burst_s, 2 s). The firmware plays a fixed-volume 3 kHz pattern (250 ms on, 150 ms off). Volume never escalates.

An episode starts when the animal enters the warning zone. It ends when the animal leaves the zone inward, goes into breach, or the fence changes. A degraded fix doesn't end an episode.

Cap Default When hit
max_bursts_per_episode 10 Emits cue_ignored once, then stays silent for the rest of the episode
max_bursts_per_hour 40 (rolling 3,600 s) Emits hourly_cap on each fix that would have cued, and doesn't cue

Events

Decision.events lists what happened on this fix:

Event When Sent to backend?
breach Entering breach Yes: state = breach, and the tag uplinks immediately
returned Leaving breach with the 5 m margin Inferred by the backend from the breach → other state change on the next uplink. The firmware waits for the next heartbeat; the virtual tags uplink immediately
turned Was cueing, and now not approaching. This is the learning signal No. Not in the uplink (see Protocol)
cue_ignored Episode cap reached Yes: cue_ignored flag, and the tag uplinks immediately
hourly_cap Hourly cap blocked a cue No
gps_degraded Entering degraded Yes: gps_degraded flag

Config reference

All fields of controller.Config, with defaults:

Field Default Meaning
warn_buffer_m 20.0 Depth of the warning zone inside the boundary (m)
near_zone_m 50.0 Inside this distance the state is near and the 30 s ceiling applies (m)
breach_margin_m 3.0 Minimum distance outside before declaring breach. h_acc is used if larger (m)
return_margin_m 5.0 Distance back inside needed to clear a breach (m)
max_h_acc_m 8.0 Fixes with a worse horizontal accuracy estimate are treated as degraded (m)
far_interval_s 300.0 Longest fix interval, used deep inside the pasture (s)
near_interval_s 30.0 Fix-interval ceiling in near (s)
max_speed_mps 1.5 Worst-case walking speed assumed by time-to-zone sampling (m/s)
lead_fixes 3 Fixes wanted in hand on reaching the warning zone
warn_interval_s 5.0 Fix interval in the warning zone while closing or unknown. Also the floor for every interval (s)
warn_idle_interval_s 15.0 Fix interval in the warning zone when not closing (s)
breach_interval_s 60.0 Fix interval while in breach (s)
degraded_interval_s 30.0 Fix interval after a degraded fix (s)
trend_window_s 30.0 Time window for the approach-rate fit (s)
min_trend_points 3 Fixes needed in the window before an approach rate is computed
min_approach_mps 0.05 Closing speed that counts as approaching (m/s)
cue_burst_s 2.0 Length of one cue burst (s)
max_bursts_per_episode 10 Bursts allowed per warning-zone episode
max_bursts_per_hour 40 Bursts allowed per rolling hour
use_motion_gate False If true, a fix whose accelerometer says "not moving" doesn't count as approaching

The C port has the same fields and defaults in ht_config_default(). Changing defaults in Python means regenerating the golden vectors (Firmware).

Welfare rationale

These rules come from the feasibility research and are deliberate. They aren't tuning accidents:

Thresholds for the cattle trials (sound level, caps, stop rules) should be agreed with the vet and behaviour specialist before the trial starts.