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:
- Which state is the animal in, relative to the fence?
- Should the tag play a cue right now?
- 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
- Entering breach needs the fix to be outside by more than
max(breach_margin_m, h_acc), i.e. at least 3 m, and more when the receiver reports worse accuracy. A cow grazing along the line with normal GPS jitter doesn't flip in and out of breach. - Leaving breach needs the animal to be back inside by
return_margin_m(5 m). That emits areturnedevent. - A degraded fix in the middle of a breach doesn't clear it. If the next good fix is still outside, the tag stays in breach and doesn't re-emit
breach.
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.
approach > min_approach_mps(0.05 m/s) counts as approaching.- With fewer than 3 points the approach is unknown (
None), and the tag doesn't cue. It's conservative on purpose.
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:
- Cue on movement, not position. An animal resting near the line, or walking away from it, is never cued.
- Never cue on uncertain data. A degraded fix can't trigger a cue, and an unknown trend doesn't either.
- Silence once outside. Cueing an animal that's already out would punish it for being out, and could teach it that coming back near the line is unpleasant. The tag reports the breach and goes quiet.
- Escalate by pattern, never by volume. Volume is fixed in firmware. If an animal ignores 10 bursts, the tag stops and reports
cue_ignoredfor a human to look at. - Hourly ceiling. No animal can get more than 40 bursts (80 s of sound) in an hour, whatever the fence geometry or GPS noise.
- Physical fences stay. The virtual fence isn't a safety barrier. See Operations.
Thresholds for the cattle trials (sound level, caps, stop rules) should be agreed with the vet and behaviour specialist before the trial starts.