Logo
Developer Guide

Entity Cookbook

Examples of common entity shapes, as YAML files you can download and push

The hydris data model is extremly flexible, covering all domains and maps to many down and upstream standards. The flexibility can be overwhelming, so here's a few very concrete examples of how the system is intended to be used. Note that while these are showing screenshots, the end goal of an integration is never to just show things in the hydris UI. Maximize the use of builtin transformers instead of drawing things directly, so hydris can translate your original intent the way a downstream system understands it.

Every example is a valid input to hydris ec apply, which takes a file of one or more entities and pushes them:

# from a file
hydris ec apply camera.yaml

# straight off this site
curl -sL https://projectqai.github.io/examples/camera.yaml | hydris ec apply -

Static picture

A point on the map

A single labelled marker on the map, drawn from a geo and a symbol

The smallest useful entity. geo gives the position and symbol draws it. routing lets it leave the node. An entity without routing stays local, which is how device and configuration entities avoid your whole network.

marker.yamlhydris ec apply marker.yaml
# The smallest useful entity: a point on the map.
#
#   geo     where it is
#   symbol  how it is drawn (MIL-STD-2525C)
#   routing without this component the entity never leaves the local node
#
# push it with:  hydris ec apply marker.yaml

id: example.site.cp1
label: Checkpoint Bravo

geo:
  latitude: 54.1782
  longitude: 12.0895
  altitude: 12

symbol:
  milStd2525C: SFGPU-----

routing:
  channels:
    - {}

Zones and volumes

A dashed circular zone and a rectangular outline drawn on the map as shape entities

An outline is a shape entity. It holds no geo of its own, because the geometry holds the coordinates. Add an extrusion, and the flat outline becomes a volume. Use this for a no-fly cylinder or a building.

zones.yamlhydris ec apply zones.yaml
---
# A fenced perimeter as a closed polygon.
# All rings must be closed: the last point repeats the first.
id: example.zone.perimeter
label: Site Perimeter

shape:
  geometry:
    planar:
      polygon:
        outer:
          points:
            - { longitude: 12.0862, latitude: 54.1770 }
            - { longitude: 12.0928, latitude: 54.1770 }
            - { longitude: 12.0928, latitude: 54.1806 }
            - { longitude: 12.0862, latitude: 54.1806 }
            - { longitude: 12.0862, latitude: 54.1770 }

---
# A no-fly cylinder: a circle extruded to 120 m.
id: example.zone.nofly
label: No-Fly Cylinder

shape:
  geometry:
    planar:
      circle:
        center: { longitude: 12.0895, latitude: 54.1788 }
        radius_m: 1500
      line_style: LineStyleDashed
  extrusion:
    height_m: 120
    fill:
      color: "#ef4444"
      opacity: 0.10

Map overlays

A tile source or a georeferenced image. The operator can switch each layer on and off.

map-layer.yamlhydris ec apply map-layer.yaml
---
# An XYZ tile source, same template a slippy map uses.
id: example.layer.seachart
label: Sea Chart

map_layer:
  z_index: 10
  opacity: 0.8
  tiles:
    url: https://tiles.example.org/seachart/{z}/{x}/{y}.png

---
# A single bitmap, centered on the entity's geo and sized in meters.
# Move the entity and the image moves with it.
id: example.layer.siteplan
label: Site Plan

geo:
  latitude: 54.1788
  longitude: 12.0895

map_layer:
  z_index: 20
  opacity: 0.6
  image:
    url: https://artifacts.example.org/siteplan.png
    width_m: 600
    height_m: 400

routing:
  channels:
    - {}

Sensors

What makes a sensor an asset

Each sensor below has a device component. A geo and a symbol draw a picture of a thing. A device says that the thing is equipment you own, and that it can be active, degraded or dead:

device:
  class: radar                              # what kind of thing it is
  category: Sea                             # how a UI groups it
  state: DeviceStateActive                  # Pending, Active, Degraded, Failed
  error: sensor buried, calibration overdue # for a human, when the state is not Active
  unique_hardware_id: example.radar.sn-4417 # follows the hardware between ports and nodes
  parent: example.ship.gannet               # its place in the device tree

An entity with both a device.state and a symbol is an asset. That puts it in the readiness panel, and controls whether an operator can task it. Readiness uses three components: geo for position, device.state for the device, and link.status for comms. See Task Assets.

A sensor without a device is only a marker. It can never report that it is broken.

For real hardware, the engine finds the device and writes these entities for you, with the usb, ip, serial, ble etc details filled in. You write one by hand when you are the driver. To make a device configurable, add a ConfigurableComponent with a JSON schema. The user's answer comes back as a ConfigurationComponent on the same entity. See the Plugin Reference.

Detections

A detection is one raw observation from one sensor. It is not a track. It is not fused. Some sensors have built in trackers, but most don't.

A detection usually does not know where it is. A sensor measures an offset from itself, and different sensors measure different amounts. So the detection reports what the sensor measured, and the engine resolves it against the pose of the sensor:

The sensor measuredWhat you pushWhat you get back
a directionpose.polar with azimuth and no rangea bearing, no position, and a line of bearing with dotted error lines
a direction and a distancethe same, with rangea geo, and a line of bearing that runs to range + 2σ
an offset in meterspose.cartesian with east_m and north_ma geo and an uncertainty ellipse. A cartesian offset holds no direction
a positiongeo with a covariance and no posethe ellipse. An isotropic covariance becomes a circle
that something is nearpose.cartesian with a covariance and no offsetthe detection on its sensor, inside a circle of uncertainty

Give the uncertainty once, in the form your sensor produces. Use the azimuth_error_deg fields or the covariance matrix. The engine calculates the other form.

A detection typically has no symbol. Put what the sensor observed in classification.taxonomy, which is a tree that a machine can read. The client that draws the picture then decides how to draw it. A 2525C code is a decision about drawing, and the detector must not make it.

You lose nothing, because the engine calculates a symbol from the taxonomy:

TaxonomyAlso on the entitySymbol you get
equipment.sensor.radara device componentSFGPESR---. F for friend, because your own equipment carries a device
equipment.sensor.radarnothingSUGPESR---. U for unknown
person with confidence.pendingnothingSPGPU-----. P for pending
noneno symbol, which is correct for a bare radar return

Cameras

A mast camera with its coverage cone pointing at a separate aim point entity, and the live stream below showing the detection box behind it

A camera measures a direction, and no distance. The example file holds a mast, the camera, the point it looks at,

The aim point is implemented by the camera driver. A vendor pan/tilt/zoom triple means nothing to other systems, so the aim point is an entity with a position. The driver reports the true direction in pose. A client writes the wanted direction into target_pose. The driver then moves the gimbal until the two agree. To aim a camera and to move a turret become the same operation.

The example also contains a detection as a bounding box. The builtin transformers automatically calculate a LOB from that.

camera.yamlhydris ec apply camera.yaml
# A PTZ camera, and everything it produces.
#
# This file builds a working camera on any Hydris node, because it uses the
# simulated camera that ships with the engine. Push it, open the map, and the
# camera renders a live view of the world around it.
#
# Six entities:
#   example.mast.north          the structure. it holds the only position
#   example.cam.north           the camera, 16 m up the mast
#   example.cam.north~fp        the point the camera looks at
#   example.det.person1         what the camera saw
#   example.artifact.snapshot1  the image that proves it
#   example.task.lookat         the action an operator can run on the camera
#
# Only the mast has a latitude and a longitude. The children give an offset
# from their parent. The engine walks the tree and calculates the absolute
# position of each child. Move the mast, and all of it moves.
#
# push it with:  hydris ec apply camera.yaml

---
# The structure. A mast is not a device. It cannot fail, so it has no device
# component, and it stays a fixture on the map.
id: example.mast.north
label: North Mast

geo:
  latitude: 54.1801
  longitude: 12.0871
  altitude: 6

bounds:
  width_m: 1.2
  height_m: 18
  depth_m: 1.2

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      infrastructure:
        tower: {}

symbol:
  milStd2525C: SFGPU-----

routing:
  channels:
    - {}

---
# The camera. 16 m straight up the mast.
#
# This is the whole camera. There is no camera component here, no stream URL,
# no field of view and no symbol, because a driver owns those.
#
# `device.parent` names the driver, and `device.class` says what kind of thing
# to make. `config` holds the settings that the driver asks for. The driver
# then claims the entity and writes the rest: the camera component, the stream
# URL, the focal point, the coverage cone, the symbol and the taxonomy. Read
# it back with `hydris ec get example.cam.north` to see what it added.
#
# `simcam.service` is the simulated camera that ships with the engine, so this
# file works with no hardware. Point `device.parent` at `reolink.service` or
# `axis.service` instead, give it an address, and the same three components
# drive a real camera.
#
# Write only what you know. The driver knows the rest.
id: example.cam.north
label: North Mast PTZ

# east_m, north_m and up_m are meters in the parent's frame
pose:
  parent: example.mast.north
  cartesian:
    east_m: 0
    north_m: 0
    up_m: 16

device:
  parent: simcam.service
  class: camera

config:
  value:
    # widest and narrowest field of view, in degrees. the engine calculates
    # the effective field of view between them from the zoom, so the coverage
    # cone on the map becomes narrow as the camera zooms in
    fov_wide: 60
    fov_tele: 8
    # how far the camera can see, in meters
    range_max: 2000
    # a simulator setting: move to a new aim point at once, rather than at the
    # speed of a real gimbal
    instant_slew: true
    # set this to true and the simulated camera produces its own detections
    enable_detections: false

  # raise this number every time you change the values above. the driver
  # echoes it back in `configurable.applied_version` once the change is live
  version: 1

---
# Where the camera points.
#
# A vendor pan/tilt/zoom triple means nothing to other systems, so the aim
# point is an entity with a position. The driver creates it, and names it in
# `camera.focal_point`.
#
# The two poses have two different owners:
#   `pose`        where the gimbal is now. the driver writes it
#   `target_pose` where it should be. a client writes it
#
# The driver moves the gimbal until the two agree. So this file writes only a
# target, and the camera turns to it. Push a new `target_pose` and it turns
# again. To aim a camera and to move a turret become the same operation.
#
# The lens is 22 m above the ground, so 0.9 degrees down over 1400 m puts the
# aim point on the ground. `range` is also the zoom: it drives the effective
# field of view between fov_wide and fov_tele.
id: example.cam.north~fp
label: North Mast PTZ Aimpoint

target_pose:
  polar:
    azimuth: 120
    elevation: -0.9
    range: 1400

---
# What the camera saw.
#
# A camera measures a direction. It cannot measure a distance, so this entity
# has no position.
#
# It has no angle either. A detector reports the pixel box it found, because
# pixels are what it has. The engine takes the field of view and the frame
# size from the camera, adds the current pan and tilt, and calculates the
# pose. Then it draws a line of bearing with two dotted error lines.
#
# So the bearing follows the camera. Slew the camera with a new `target_pose`,
# push the same box again, and the line of bearing points somewhere else.
#
# A detection has no symbol. `classification.taxonomy` says what it is, and
# the client decides how to draw it.
id: example.det.person1
label: Person

# who made this detection. the engine sets `node`, so do not set it yourself
controller:
  id: example.detector.yolo

# detections are urgent. this sends them before routine traffic
priority: PriorityImmediate

detection:
  detectorEntityId: example.cam.north

  # entity ids that support this detection
  evidence:
    - example.artifact.snapshot1

  # confidence that something is there
  confidence: 0.87

  # the pixel box in the frame, and the frame it came from.
  # the engine needs all six numbers to calculate the angle
  image_bbox:
    x: 1620
    y: 880
    width: 210
    height: 460
    frame_width: 3840
    frame_height: 2160

classification:
  taxonomy:
    - confidence:
        confidence: 0.87
      person: {}

routing:
  channels:
    - {}

---
# The image the detector kept.
#
# Files are too large for an entity. Put the file in an artifact entity, and
# refer to its id. Artifacts expire on a lifetime, and the storage backend
# then deletes the file.
id: example.artifact.snapshot1
label: North Mast snapshot

artifact:
  content_type: image/jpeg
  size_bytes: 184320
  sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

  # where the bytes are. list more than one location if you have copies
  location:
    - url: https://artifacts.example.org/snapshots/9f86d081.jpg

routing:
  channels:
    - {}

---
# What an operator can do with the camera.
#
# The driver pushes this entity to advertise the action. A client then calls
# WorldService.RunTask, and the engine creates a TaskExecutionComponent. The
# driver watches for that component and moves the camera.
#
# The task is a different entity from the camera. `assignee` names the camera.
# One camera can offer many tasks.
id: example.task.lookat
label: Look At (North Mast PTZ)

taskable:
  label: Look at target
  icon: eye

  # what the task does, for a human or an LLM to read
  effect: turns the north mast camera onto the selected target and holds it there

  # a new run replaces the current run
  mode: TaskableModeReconcile

  assignee:
    - entityId: example.cam.north

  # the caller must give one entity to look at.
  # use `position` for a point, or `waypoints` for a route
  target:
    entity:
      max: 1

  # what the task means. a client groups tasks from different vendors by this
  taxonomy:
    observe:
      look_at: {}

  # sort order in the menu. high numbers come first
  priority: 100

routing:
  channels:
    - {}

Radars

Radar coverage circle with two plots, one drawn as a line of bearing with error lines and one as an uncertainty ellipse, and the track fused from them

Radars differ in what they give you, and the difference decides what your driver pushes.

A simple radar reports plots: raw returns with a bearing and a range, and no memory from one sweep to the next. Those are detections. Drivers report a plot in three different ways, and the file shows all three.

Many radars associate their own plots and report tracks. Some report both. A radar that reports tracks needs no detections at all: push the track, and name the radar in track.tracker. A consumer cannot tell whether radar firmware or a separate fuser produced a track, and it does not need to.

A track keeps evidence pointers to the plots it used, when it has them, so an operator can ask why the contact exists. Its history and its predicted path are separate shape entities, so a client that needs only the current position does not receive them.

The radar also declares what it can see. A camera gives a fov and a range_max, and the engine builds the cone. No two radars have the same footprint, so sensor.coverage names shape entities instead. It is a list, which lets a radar with a mast in the way declare one shape for each part it can see.

radar.yamlhydris ec apply radar.yaml
# A radar, its plots, and the track that fuses them.
#
# Eight entities:
#   example.radar.north               the radar
#   example.radar.north.coverage      the area it can see
#   example.det.radar.8812            a plot with a bearing and a range
#   example.det.radar.8830            a plot as an offset in meters
#   example.det.radar.4407            a plot as a position
#   example.track.vessel1             the track built from two of the plots
#   example.track.vessel1.history     where the track has been
#   example.track.vessel1.prediction  where the track goes next
#
# The three plots show the three ways a driver can report a contact. All three
# use the same detection component. Only the offset changes.
#
# No plot has a symbol. A detection reports what the sensor measured, not how
# to draw it. `classification.taxonomy` says what it is.
#
# push it with:  hydris ec apply radar.yaml

---
# The radar.
# The device component makes it an asset with a state. The symbol makes it
# visible. Together they put it in the readiness panel.
id: example.radar.north
label: Surface Search Radar

geo:
  latitude: 54.1804
  longitude: 12.0884
  altitude: 9

device:
  class: radar
  category: Sea
  state: DeviceStateActive
  unique_hardware_id: example.radar.sn-4417

link:
  status: LinkStatusConnected
  avg_latency_ms: 8

# What the radar can see.
#
# A camera declares a `fov` and a `range_max`, and the engine builds the cone.
# A radar has no such fields, because no two radars have the same footprint.
# So the sensor names shape entities that give it.
#
# `coverage` is a list. A radar with a mast in the way, or one that scans a
# sector, names one shape for each part it can see.
sensor:
  coverage:
    - example.radar.north.coverage

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          emplaced: {}
          radar: {}

symbol:
  milStd2525C: SFGPESR---

routing:
  channels:
    - {}

---
# The coverage.
#
# A ring, not a disc. `inner_radius_m` cuts out the blind zone below the
# minimum range of the radar, so the shape states both limits at once.
#
# The geometry is a `local_shape`, in meters from the radar, so the coverage
# follows the radar. Move the radar, and the ring moves with it. The engine
# converts the ring to WGS84 for clients that want it in world coordinates.
id: example.radar.north.coverage
label: Radar Coverage

local_shape:
  relative_to: example.radar.north
  geometry:
    circle:
      center: { east_m: 0, north_m: 0 }
      radius_m: 3200
      inner_radius_m: 150
  extrusion:
    fill:
      color: "#38bdf8"
      opacity: 0.06

routing:
  channels:
    - {}

---
# PLOT 1: a bearing and a range.
#
# This is the usual radar plot. The engine calculates an absolute position
# from it. It draws a line of bearing, not an ellipse. The line runs to
# range + 2 sigma, so its length shows the quality of the range measurement.
id: example.det.radar.8812
label: Radar Plot 8812

controller:
  id: example.tracker.kalman

priority: PriorityImmediate

detection:
  detectorEntityId: example.radar.north
  confidence: 0.71

pose:
  parent: example.radar.north
  polar:
    azimuth: 21.5
    range: 2600
    # 1-sigma errors. the engine calculates the covariance from them
    azimuth_error_deg: 0.8
    range_error_m: 40

classification:
  taxonomy:
    - confidence:
        confidence: 0.6
      vehicle:
        sea: {}

routing:
  channels:
    - {}

---
# PLOT 2: an offset in meters.
#
# Some drivers report east and north instead of angles. A cartesian offset
# holds no direction, so the engine draws an uncertainty ellipse.
# The covariance is in meters squared: 300 m east error, 200 m north error.
#
# This plot has no classification. That is normal for a bare radar return.
# Leave it out. To classify the contact is the job of the fuser.
id: example.det.radar.8830
label: Radar Plot 8830

controller:
  id: example.tracker.kalman

detection:
  detectorEntityId: example.radar.north
  confidence: 0.64

pose:
  parent: example.radar.north
  cartesian:
    east_m: 1450
    north_m: 2100
    covariance:
      mxx: 90000
      myy: 40000

routing:
  channels:
    - {}

---
# PLOT 3: a position.
#
# Some hardware calculates the position itself, as do ADS-B and AIS feeds.
# Then the entity has no pose. Push the position, and put the accuracy in its
# covariance. The engine still draws the ellipse. An isotropic covariance
# becomes a circle.
id: example.det.radar.4407
label: Radar Plot 4407

controller:
  id: example.radar.north

detection:
  detectorEntityId: example.radar.north
  confidence: 0.55

geo:
  latitude: 54.1997
  longitude: 12.0721
  # meters squared in the local ENU frame: xx=east, yy=north, xy=correlation
  covariance:
    mxx: 62500
    myy: 22500
    mxy: 9000

classification:
  taxonomy:
    - confidence:
        confidence: 0.35
      vehicle:
        sea: {}

routing:
  channels:
    - {}

---
# The track.
#
# A plot is one measurement. A track is the conclusion of a fuser. It keeps
# `evidence` pointers to the plots it used, so an operator can ask why the
# contact exists.
#
# A track is a picture object, so it does have a symbol.
id: example.track.vessel1
label: TRK-0413

track:
  tracker: example.tracker.kalman
  confidence: 0.93

  evidence:
    - example.det.radar.8812
    - example.det.radar.8830

  # the two shape entities below
  history: example.track.vessel1.history
  prediction: example.track.vessel1.prediction

geo:
  latitude: 54.2015
  longitude: 12.1103
  # position error in meters squared, local ENU frame. drawn as an ellipse
  covariance:
    mxx: 900
    myy: 1600
    mxy: 120

bearing:
  azimuth: 247

kinematics:
  velocityEnu:
    east: -4.2
    north: -1.8
    up: 0

classification:
  taxonomy:
    - confidence:
        confidence: 0.8
      vehicle:
        sea: {}

symbol:
  milStd2525C: SNSP------

routing:
  channels:
    - {}

---
# Where the track has been.
# History is a separate entity, not a field. A client that needs only the
# current position never receives it.
id: example.track.vessel1.history
label: TRK-0413 history

shape:
  geometry:
    planar:
      line:
        points:
          - { longitude: 12.1310, latitude: 54.2120 }
          - { longitude: 12.1245, latitude: 54.2081 }
          - { longitude: 12.1174, latitude: 54.2046 }
          - { longitude: 12.1103, latitude: 54.2015 }
      line_style: LineStyleSolid

---
# Where the track goes next.
id: example.track.vessel1.prediction
label: TRK-0413 prediction

shape:
  geometry:
    planar:
      line:
        points:
          - { longitude: 12.1103, latitude: 54.2015 }
          - { longitude: 12.1032, latitude: 54.1984 }
          - { longitude: 12.0961, latitude: 54.1953 }
      line_style: LineStyleDashed

Perimeter Sensors

Something like a simple acoustic ground sensor cannot measure a direction or a distance. It knows only that something is near. Give the detection a zero offset and a large covariance. Use the same shape for a tripwire, a door contact or a chemical alarm.

The file also shows the two components for the data a sensor produces. metric holds live readings, each with a kind and a unit. capture holds raw bytes that nothing has decoded yet. Bytes that are too large for an entity go in an artifact entity, and everything else names it by id.

audio-sensor.yamlhydris ec apply audio-sensor.yaml
# An acoustic ground sensor, and what it sends when it hears something.
#
# Four entities:
#   example.ugs.dune          the sensor
#   example.det.seismic1      the contact it reports
#   example.capture.ugs1      the raw packet from the radio
#   example.artifact.audio1   the sound file
#
# The sensor cannot measure a direction or a distance. It knows only that
# something is near. It also shows two things the other sensors do not: live
# readings in a metric component, and raw bytes in a capture component.
#
# push it with:  hydris ec apply audio-sensor.yaml

---
# The sensor. It is buried, it runs on a battery, and its radio link is bad.
# The device state and the link status report that.
id: example.ugs.dune
label: Dune UGS

geo:
  latitude: 54.1832
  longitude: 12.0812
  altitude: 4

device:
  class: ugs
  category: Ground
  state: DeviceStateDegraded
  error: sensor buried, calibration overdue
  unique_hardware_id: a84041c6545d1429

link:
  status: LinkStatusDegraded
  rssi_dbm: -114
  link_quality_percent: 22

power:
  battery_charge_remaining: 0.18
  voltage: 3.4

# Live readings. Each metric has a kind, a unit, and one value.
# `id` is unique inside this entity. The engine merges metrics by id, so two
# writers can update different metrics on the same entity.
metric:
  metrics:
    - id: 1
      kind: MetricKindSoundLevel
      unit: MetricUnitDecibelA
      label: Ambient Noise
      double: 63.5
      # this raises the alert state of the entity in the UI
      alerting: AlertLevelWarning
      # what the sensor can measure. a UI draws a gauge from it
      range:
        min_double: 20
        max_double: 120

    - id: 2
      kind: MetricKindTemperature
      unit: MetricUnitCelsius
      label: Ground Temperature
      double: 9.2

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          emplaced: {}
          acoustic: {}

symbol:
  milStd2525C: SFGPESE---

routing:
  channels:
    - {}

---
# The contact.
#
# There is no bearing and no range. Do not invent a position. Give the
# detection a zero offset and a large covariance. The result is correct: the
# contact sits on the sensor, inside a circle 2 km wide.
#
# Use this shape for any sensor that triggers but cannot locate: a tripwire,
# a door contact, a chemical alarm.
#
# The detection has no symbol. `pending` tells the client that the sensor has
# not decided what it heard.
id: example.det.seismic1
label: Acoustic Trip

controller:
  id: example.ugs.dune

priority: PriorityImmediate

detection:
  detectorEntityId: example.ugs.dune
  confidence: 0.4
  evidence:
    - example.artifact.audio1

classification:
  taxonomy:
    - confidence:
        confidence: 0.4
        pending: true
      person: {}

pose:
  parent: example.ugs.dune
  cartesian:
    covariance:
      mxx: 4000000
      myy: 4000000

routing:
  channels:
    - {}

---
# The raw packet.
#
# Use a capture component when the bytes have no meaning yet. Keep it next to
# the device that received it. A later plugin can decode it.
id: example.capture.ugs1
label: UGS uplink

capture:
  captured_by: example.ugs.dune
  # application port, for example a LoRaWAN FPort
  port: 2
  content_type: application/octet-stream

  # protobuf `bytes` fields are base64 in JSON and in YAML
  payload: AQIDBAU=

  # large payloads go in artifact entities instead
  content:
    - example.artifact.audio1

---
# The sound file.
# Files are too large for an entity. Put the file in an artifact entity, and
# refer to its id from anywhere that needs it.
id: example.artifact.audio1
label: UGS audio clip

artifact:
  content_type: audio/wav
  size_bytes: 96044
  sha256: 3fdba35f04dc8c462986c992bcf87554db3dbd2c4b0b5a3b5e6b34e4c0e17dc0

  location:
    - url: https://artifacts.example.org/ugs/3fdba35f.wav

routing:
  channels:
    - {}

Fusion

One sensor rarely knows where a thing is. Several sensors that observe the same thing do. A fusion engine reads their detections, solves the geometry, and pushes the answer as a new entity.

Fusion needs no new component. The pattern is always the same:

  1. Each sensor driver pushes what its own sensor measured, and nothing more. It does not know that the other sensors exist.
  2. The fusion engine reads those detections and solves for the position.
  3. The fusion engine pushes one entity, and names every detection it used in evidence.

The evidence list is what makes the result auditable. An operator can ask which sensors produced a position, and a later stage can use the same detections again. Because the drivers stay separate, another sensor of the same kind changes only the fusion engine.

The output of a fusion engine is a track. A detection is what one sensor measured, and a track is a located thing. If you can say where it is, it is a track.

One engine behaviour follows from that. The engine draws the uncertainty shape for a detection. It draws none for a track, which carries the same information in geo.covariance.

Stereo vision

Two cameras with overlapping coverage cones, their lines of bearing crossing at the fused track

One camera gives a direction. Two cameras that see the same object give two directions that cross, and the crossing point is the position.

The detectors here push no angle at all. Each one reports the pixel box it found, because pixels are what a detector has. The engine takes the field of view and the frame size from the camera, and calculates the bearing with a pinhole model. The fusion engine then crosses the two bearings.

The covariance on the result is the geometry of the measurement. Both cameras stand 808 m from the object and know their angle to 0.5°. That gives 5 m of error across the line of sight and 40 m along it. A short baseline always produces an ellipse that is long in the viewing direction. To reduce the depth error, move the cameras further apart.

stereo-vision.yamlhydris ec apply stereo-vision.yaml
# Stereo vision: two cameras, and a fusion engine that triangulates them.
#
# Five entities:
#   example.stereo.cam.west   the left camera
#   example.stereo.cam.east   the right camera, 202 m east of it
#   example.stereo.det.west   what the left camera saw
#   example.stereo.det.east   what the right camera saw
#   example.stereo.track.1    the position both bearings agree on
#
# One camera gives a direction and no distance. Two cameras that see the same
# object give two directions that cross. The point where they cross is the
# position. This is the smallest useful fusion in the system, and it needs no
# new component.
#
# Three jobs stay separate, and each part writes only what it knows:
#   the detector reports a pixel box
#   the engine converts the pixel box into a bearing
#   the fusion engine crosses two bearings and pushes the position
#
# Neither camera knows that the other exists. A third camera adds a third
# detection, and only the fusion engine changes.
#
# push it with:  hydris ec apply stereo-vision.yaml

---
# The left camera.
id: example.stereo.cam.west
label: Stereo West

geo:
  latitude: 54.1800
  longitude: 12.0860
  altitude: 12

camera:
  fov: 60
  range_max: 2000
  streams:
    - label: Main
      url: rtsp://stereo-west.example.org/main
      protocol: MediaStreamProtocolRtsp
      role: MediaStreamRoleMain
      codec: H264

device:
  class: camera
  category: Ground
  state: DeviceStateActive
  unique_hardware_id: stereo.west.7731

link:
  status: LinkStatusConnected
  avg_latency_ms: 12

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          emplaced: {}
          electro_optical: {}

symbol:
  milStd2525C: SFGPE-----

routing:
  channels:
    - {}

---
# The right camera. 202 m east of the left one.
# The distance between the two cameras is the baseline. A long baseline gives
# a good position. A short baseline gives a long, thin error ellipse.
id: example.stereo.cam.east
label: Stereo East

geo:
  latitude: 54.1800
  longitude: 12.0891
  altitude: 12

camera:
  fov: 60
  range_max: 2000
  streams:
    - label: Main
      url: rtsp://stereo-east.example.org/main
      protocol: MediaStreamProtocolRtsp
      role: MediaStreamRoleMain
      codec: H264

device:
  class: camera
  category: Ground
  state: DeviceStateActive
  unique_hardware_id: stereo.east.7732

link:
  status: LinkStatusConnected
  avg_latency_ms: 11

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          emplaced: {}
          electro_optical: {}

symbol:
  milStd2525C: SFGPE-----

routing:
  channels:
    - {}

---
# What the left camera saw.
#
# The detector reports pixels, because pixels are what it has. It pushes no
# pose and no angle at all.
#
# The engine reads `image_bbox`, takes the field of view and the frame size
# from the camera, and calculates the pose with a pinhole model. It adds the
# current pan and tilt of the camera. Here the box centre sits 419 px right
# of the centre of a 3840 px frame at 60 degrees, which is 7.181 degrees.
#
# The engine also converts the width and the height of the box into the
# angular size of the object.
id: example.stereo.det.west
label: Contact (west)

controller:
  id: example.detector.yolo

priority: PriorityImmediate

detection:
  detectorEntityId: example.stereo.cam.west
  confidence: 0.82
  image_bbox:
    x: 2294
    y: 975
    width: 90
    height: 210
    frame_width: 3840
    frame_height: 2160

classification:
  taxonomy:
    - confidence:
        confidence: 0.8
      vehicle:
        land:
          multi_wheeled: {}

routing:
  channels:
    - {}

---
# What the right camera saw: the same object, from 202 m away.
#
# The box sits left of centre here, so the engine calculates 352.819 degrees.
# The two bearings differ by 14.4 degrees, and that angle is the measurement.
id: example.stereo.det.east
label: Contact (east)

controller:
  id: example.detector.yolo

priority: PriorityImmediate

detection:
  detectorEntityId: example.stereo.cam.east
  confidence: 0.79
  image_bbox:
    x: 1456
    y: 975
    width: 90
    height: 210
    frame_width: 3840
    frame_height: 2160

classification:
  taxonomy:
    - confidence:
        confidence: 0.75
      vehicle:
        land:
          multi_wheeled: {}

routing:
  channels:
    - {}

---
# The fused result: the point where the two bearings cross.
#
# The fusion engine reads both detections, intersects the two lines, and
# pushes this entity. `evidence` names the two detections, so an operator can
# ask which cameras produced this position, and a later fuser can re-use them.
#
# The covariance is the interesting part. Both cameras are 808 m from the
# object and know their angle to 0.5 degrees. That gives 5 m of error across
# the line of sight, and 40 m along it. A short baseline always makes the
# ellipse long in the viewing direction. To improve the depth, move the
# cameras apart. To improve both, add a third camera.
id: example.stereo.track.1
label: TRK-0007

controller:
  id: example.fusion.stereo

track:
  # which fusion engine produced this
  tracker: example.fusion.stereo
  confidence: 0.88

  evidence:
    - example.stereo.det.west
    - example.stereo.det.east

geo:
  latitude: 54.1872
  longitude: 12.08755
  altitude: 12
  # meters squared, local ENU frame. xx=east, yy=north
  covariance:
    mxx: 25
    myy: 1590

classification:
  taxonomy:
    - confidence:
        confidence: 0.85
      vehicle:
        land:
          multi_wheeled: {}

symbol:
  milStd2525C: SUGPU-----

routing:
  channels:
    - {}

Time of arrival

Three tripwire sensors with range rings that cross at the solved track position south of them

A tripwire cannot give a direction or a distance. It reports that a sound reached it, and when. Sound travels 343 m each second, so a difference between two arrival times is a difference in distance. Three sensors that are not in one line give two independent differences, which is enough to solve for the position and for the moment of the event.

The measurement is the timestamp, so each sensor puts its arrival time in lifetime.fresh. That is the field for when an observation happened.

The event in this file lies south of all three sensors, outside the triangle they form. Nothing in the input says that. It comes only from the order of the three arrival times, and from the gaps between them.

The covariance follows from the clock. These sensors share time to about 10 ms over a radio, and 10 ms of sound is 3.4 m. That gives 4.5 m of error east and 16.9 m north. The ellipse points north because all three sensors measure the distance to the south in nearly the same direction. An event inside the triangle gives a round ellipse. Better clocks give a smaller one.

time-of-arrival.yamlhydris ec apply time-of-arrival.yaml
# Time of arrival: three tripwire sensors, and the event that made them fire.
#
# Seven entities:
#   example.toa.wire.a    a tripwire sensor
#   example.toa.wire.b    a tripwire sensor, 202 m east of A
#   example.toa.wire.c    a tripwire sensor, 195 m north of A
#   example.toa.det.b     B heard something first, at 09:14:02.386
#   example.toa.det.a     A heard it 114 ms later, at 09:14:02.500
#   example.toa.det.c     C heard it last, at 09:14:02.816
#   example.toa.track.1   where the sound came from
#
# The event is south of all three sensors, outside the triangle they form.
# Nothing in the input says that. It comes only from the order of the three
# arrival times, and from the gaps between them.
#
# One tripwire cannot say where a sound came from. It says only that a sound
# reached it, and when. Sound travels 343 m each second, so the difference
# between two arrival times is a difference in distance. Three sensors give
# two independent differences, which is enough for a position in two
# dimensions. The fusion engine solves for the position and the moment of the
# event together.
#
# The measurement is the timestamp. Each sensor puts its arrival time in
# `lifetime.fresh`, which is the field for when an observation happened.
#
# push it with:  hydris ec apply time-of-arrival.yaml

---
# Sensor A.
id: example.toa.wire.a
label: Tripwire A

geo:
  latitude: 54.1800
  longitude: 12.0860
  altitude: 4

device:
  class: tripwire
  category: Ground
  state: DeviceStateActive
  unique_hardware_id: a84041c6545d1401

link:
  status: LinkStatusConnected
  rssi_dbm: -88

power:
  battery_charge_remaining: 0.76
  voltage: 3.9

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          emplaced: {}
          acoustic: {}

symbol:
  milStd2525C: SFGPESE---

routing:
  channels:
    - {}

---
# Sensor B, 202 m east of A.
id: example.toa.wire.b
label: Tripwire B

geo:
  latitude: 54.1800
  longitude: 12.0891
  altitude: 4

device:
  class: tripwire
  category: Ground
  state: DeviceStateActive
  unique_hardware_id: a84041c6545d1402

link:
  status: LinkStatusConnected
  rssi_dbm: -91

power:
  battery_charge_remaining: 0.71
  voltage: 3.8

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          emplaced: {}
          acoustic: {}

symbol:
  milStd2525C: SFGPESE---

routing:
  channels:
    - {}

---
# Sensor C, 195 m north of A. Three sensors that are not in one line give a
# position. Three sensors in one line do not.
id: example.toa.wire.c
label: Tripwire C

geo:
  latitude: 54.1815
  longitude: 12.08755
  altitude: 4

device:
  class: tripwire
  category: Ground
  state: DeviceStateActive
  unique_hardware_id: a84041c6545d1403

link:
  status: LinkStatusConnected
  rssi_dbm: -84

power:
  battery_charge_remaining: 0.80
  voltage: 3.9

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          emplaced: {}
          acoustic: {}

symbol:
  milStd2525C: SFGPESE---

routing:
  channels:
    - {}

---
# What sensor A reported. The sound reached it 114 ms after B.
#
# The sensor knows the time, and nothing else. It cannot give a direction or a
# distance, so the detection sits on the sensor inside a large circle. Do not
# guess a position here. The circle is honest, and the fusion engine replaces
# it with a real one.
id: example.toa.det.a
label: Acoustic Trip A

controller:
  id: example.toa.wire.a

priority: PriorityImmediate

# the arrival time. this is the whole measurement
lifetime:
  fresh: "2026-03-01T09:14:02.500Z"

detection:
  detectorEntityId: example.toa.wire.a
  confidence: 0.6

pose:
  parent: example.toa.wire.a
  cartesian:
    covariance:
      mxx: 250000
      myy: 250000

routing:
  channels:
    - {}

---
# What sensor B reported. It is nearest to the event, so it heard the sound
# first.
id: example.toa.det.b
label: Acoustic Trip B

controller:
  id: example.toa.wire.b

priority: PriorityImmediate

lifetime:
  fresh: "2026-03-01T09:14:02.386Z"

detection:
  detectorEntityId: example.toa.wire.b
  confidence: 0.5

pose:
  parent: example.toa.wire.b
  cartesian:
    covariance:
      mxx: 250000
      myy: 250000

routing:
  channels:
    - {}

---
# What sensor C reported. It is the furthest away, so it heard the sound last,
# 430 ms after B. That large gap is what puts the event south of the others.
id: example.toa.det.c
label: Acoustic Trip C

controller:
  id: example.toa.wire.c

priority: PriorityImmediate

lifetime:
  fresh: "2026-03-01T09:14:02.816Z"

detection:
  detectorEntityId: example.toa.wire.c
  confidence: 0.7

pose:
  parent: example.toa.wire.c
  cartesian:
    covariance:
      mxx: 250000
      myy: 250000

routing:
  channels:
    - {}

---
# The fused result.
#
# The fusion engine reads the three arrival times, solves for the position and
# the moment of the event, and pushes this entity.
#
# The output is a track. A detection is what one sensor measured. A track is a
# located thing, and the rule is that simple: if you can say where it is, it
# is a track. Three tripwires that could each say only "something is near me"
# become one point that a person can act on.
#
# `track.evidence` names the three detections, so an operator can ask which
# sensors produced this point, and a later stage can use them again.
#
# `lifetime.fresh` holds the moment the fusion engine solved for. It is 386 ms
# before the first sensor heard anything, because the sound needed that long
# to travel 132 m.
#
# The covariance follows from the clock. These sensors share time over a radio
# to about 10 ms, and 10 ms of sound is 3.4 m. The result is 4.5 m of error
# east and 16.9 m north. The ellipse points north because the event lies south
# of all three sensors. All three measure the distance to the south in nearly
# the same direction, so nothing constrains it well. An event inside the
# triangle gives a round ellipse. Better clocks give a smaller one.
#
# There is no symbol here. The classification is still pending, so the engine
# derives a pending symbol from the taxonomy.
id: example.toa.track.1
label: TRK-0031

controller:
  id: example.fusion.toa

lifetime:
  fresh: "2026-03-01T09:14:02.000Z"

track:
  # which fusion engine produced this
  tracker: example.fusion.toa
  confidence: 0.83

  evidence:
    - example.toa.det.a
    - example.toa.det.b
    - example.toa.det.c

geo:
  latitude: 54.1790
  longitude: 12.0880
  altitude: 4
  # meters squared, local ENU frame. xx=east, yy=north, xy=correlation
  covariance:
    mxx: 20.1
    myy: 285.5
    mxy: -42.2

classification:
  taxonomy:
    - confidence:
        confidence: 0.5
        pending: true
      person: {}

routing:
  channels:
    - {}

Vehicles

Ship

Ship outline with bow and stern cameras and a navigation radar, each coverage area rotated with the hull

A hull, its outline, two fixed cameras and a navigation radar. Each part gives an offset in meters from the ship.

The hull has an orientation. The engine rotates the offset of each child by it. The bow camera stays at the bow through a turn, and its coverage cone turns with the ship. A driver that receives a new position and heading pushes two components on one entity, and the other five entities follow.

Watch the sign of the quaternion. A bearing runs clockwise from north, and quaternion yaw runs counter-clockwise. A heading of 030 is z = sin(-15°), not sin(15°).

The two sensors declare their coverage in different ways. The cameras give a fov and a range_max, and the engine builds the cone. The engine cannot guess the shape of the radar coverage, so sensor.coverage names a shape entity that gives it.

ship.yamlhydris ec apply ship.yaml
# A vessel and everything bolted to it.
#
# The mast in camera-tower.yaml is a pose tree that never moves. This is the
# same idea on something that does: one hull, its outline, two fixed cameras
# and a navigation radar, all declared as offsets in meters from the ship.
#
# The hull carries the only latitude and longitude in the file, and an
# orientation. Because the children hang off it by pose, the engine rotates
# their offsets by the ship's heading before resolving them - so the bow camera
# stays at the bow when the ship turns, and its coverage cone swings with it.
# A driver that receives a new position and heading pushes two components on
# one entity, and the whole assembly follows.
#
# `assembly` is what ties the parts together for a UI: the hull points at its
# outline, each part points back at the hull as its parent, so an operator can
# select the ship and see its sensors, or select a sensor and know whose it is.
#
# push it with:  hydris ec apply ship.yaml

---
# The platform itself.
id: example.ship.gannet
label: MV GANNET

geo:
  latitude: 54.2050
  longitude: 12.0980
  altitude: 0

# heading 030, as a quaternion about the up axis. bearings are clockwise from
# north while quaternion yaw is counter-clockwise, so the z term is negative:
# z = sin(-heading/2), w = cos(-heading/2).
# everything below is positioned relative to this
orientation:
  orientation: { x: 0, y: 0, z: -0.2588, w: 0.9659 }

kinematics:
  velocityEnu:
    east: 1.5
    north: 2.6

# overall extent, used for scale and collision-ish reasoning
bounds:
  width_m: 32
  height_m: 200
  depth_m: 12

# our platform, so it is an asset with a state. the sensors below name it as
# their device parent, which puts the whole vessel in the device tree
device:
  class: vessel
  category: Sea
  state: DeviceStateActive

# the parts of this assembly that draw its outline
assembly:
  outline:
    - example.ship.gannet.hull

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      vehicle:
        sea: {}

symbol:
  milStd2525C: SFSPXM----

routing:
  channels:
    - {}

---
# The hull outline, drawn in the ship's own frame: north is forward, east is
# starboard, in meters. A 200 m vessel with a pointed bow at +100.
# The engine converts this to WGS84 and re-converts it whenever the ship moves.
id: example.ship.gannet.hull

local_shape:
  relative_to: example.ship.gannet
  geometry:
    polygon:
      outer:
        points:
          - { east_m: 0, north_m: 100 }
          - { east_m: 12, north_m: 85 }
          - { east_m: 16, north_m: 50 }
          - { east_m: 16, north_m: -70 }
          - { east_m: 14, north_m: -95 }
          - { east_m: -14, north_m: -95 }
          - { east_m: -16, north_m: -70 }
          - { east_m: -16, north_m: 50 }
          - { east_m: -12, north_m: 85 }
          - { east_m: 0, north_m: 100 }

routing:
  channels:
    - {}

---
# Forward-looking camera, 90 m ahead of the ship's origin and 15 m up.
# Fixed, not PTZ: no focal point, so its coverage cone points wherever the
# hull is pointing.
id: example.ship.gannet.cam.bow
label: Bow Camera

assembly:
  parent: example.ship.gannet

# `assembly.parent` is the physical grouping for an operator, `device.parent`
# is the tree the engine and the configuration UI walk. Here they agree,
# but they answer different questions and often do not.
device:
  parent: example.ship.gannet
  class: camera
  state: DeviceStateActive

pose:
  parent: example.ship.gannet
  cartesian:
    east_m: 0
    north_m: 90
    up_m: 15

camera:
  fov: 60
  range_max: 5000
  streams:
    - label: Bow
      url: rtsp://gannet.example.org/bow
      protocol: MediaStreamProtocolRtsp
      role: MediaStreamRoleMain
      codec: H264

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          electro_optical: {}

symbol:
  milStd2525C: SFGPE-----

routing:
  channels:
    - {}

---
# Aft-facing camera, wider and shorter ranged.
id: example.ship.gannet.cam.stern
label: Stern Camera

assembly:
  parent: example.ship.gannet

device:
  parent: example.ship.gannet
  class: camera
  state: DeviceStateActive

pose:
  parent: example.ship.gannet
  cartesian:
    east_m: 0
    north_m: -85
    up_m: 10

camera:
  fov: 90
  range_max: 3000
  streams:
    - label: Stern
      url: rtsp://gannet.example.org/stern
      protocol: MediaStreamProtocolRtsp
      role: MediaStreamRoleMain
      codec: H264

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          electro_optical: {}

symbol:
  milStd2525C: SFGPE-----

routing:
  channels:
    - {}

---
# Navigation radar on the mast, 25 m above the waterline.
# Unlike a camera, its coverage is not derived from a field of view - it points
# at shape entities that describe it, which is how any sensor with a coverage
# footprint the engine cannot guess declares one.
id: example.ship.gannet.radar
label: Navigation Radar

assembly:
  parent: example.ship.gannet

device:
  parent: example.ship.gannet
  class: radar
  state: DeviceStateActive

pose:
  parent: example.ship.gannet
  cartesian:
    east_m: 0
    north_m: 0
    up_m: 25

sensor:
  coverage:
    - example.ship.gannet.radar.coverage

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor:
          radar: {}

symbol:
  milStd2525C: SFGPESR---

routing:
  channels:
    - {}

---
# 20 km all-round radar coverage, again in the ship's frame so it travels
# with the hull.
id: example.ship.gannet.radar.coverage

local_shape:
  relative_to: example.ship.gannet
  geometry:
    circle:
      center: { east_m: 0, north_m: 0 }
      radius_m: 20000
  extrusion:
    fill:
      color: "#38bdf8"
      opacity: 0.06

routing:
  channels:
    - {}

UAV

Telemetry from an unmanned aircraft, at the rate a MAVLink driver pushes it. Each subsystem has its own component: battery, link quality, GNSS fix and mission progress. A UI shows all of it and knows nothing about aircraft. The same power and link components mean the same thing on a boat, a radio or a ground robot.

uav.yamlhydris ec apply uav.yaml
# Live telemetry from an unmanned aircraft.
#
# This is the shape of entity a MAVLink or vendor driver pushes a few times per
# second. Every subsystem gets its own component, so a UI can show battery,
# link quality and mission progress without a bespoke drone schema - and the
# same components mean the same thing on a boat, a radio or a ground robot.
#
# push it with:  hydris ec apply uav.yaml

id: example.uav.spectre
label: SPECTRE-1

geo:
  latitude: 54.1866
  longitude: 12.0952
  altitude: 118

# full 3D attitude as a unit quaternion. the outer key is the component,
# the inner one is the field inside it.
# this is a heading of 030: quaternion yaw runs counter-clockwise while
# bearings run clockwise from north, so z = sin(-heading/2)
orientation:
  orientation: { x: 0, y: 0, z: -0.2588, w: 0.9659 }

# heading only, for consumers that do not care about roll and pitch
bearing:
  azimuth: 30

kinematics:
  # ground-referenced velocity in m/s, NOT body frame
  velocityEnu:
    east: 8.1
    north: 14.0
    up: 0.4
  angularVelocityBody:
    roll_rate: 0
    pitch_rate: 0
    yaw_rate: 0.12

navigation:
  mode: NavigationModeAutonomous
  armed: true
  waypoint_current: 3
  waypoint_total: 7

power:
  battery_charge_remaining: 0.62
  voltage: 22.4
  current_a: 18.7
  remaining_seconds: 900

gnss:
  fix_type: GnssFixTypeRtkFixed
  satellites_visible: 24
  satellites_used: 19
  hdop: 0.7

# our aircraft, so it gets a device component and shows up as an asset with a
# readiness state: positioned, device active, link connected
device:
  class: uav
  category: Air
  state: DeviceStateActive
  unique_hardware_id: spectre.0f31a7

link:
  status: LinkStatusConnected
  rssi_dbm: -67
  snr_db: 14
  link_quality_percent: 92
  rf_mode: 50Hz
  packet_rate_hz: 50

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      vehicle:
        unmanned: {}
        air:
          rotary: {}

administrative:
  manufacturer: Example Robotics
  model: Spectre 4
  owner: Team North

symbol:
  milStd2525C: SFAPMFQ---

routing:
  channels:
    - {}

A contact from AIS

The two files above are vehicles you control. This one is a vehicle you only watch. Almost nothing in it is maritime. geo, bearing and kinematics are the components the UAV uses. Only transponder holds the AIS data. An aircraft from ADS-B is the same entity with transponder.adsb.

It has no device component, because you do not own it.

vessel.yamlhydris ec apply vessel.yaml
# A ship built from an AIS message.
#
# Note how little of this is maritime-specific: geo, bearing and kinematics are
# the same components a drone uses. Only `transponder` carries the AIS payload.
# An ADS-B aircraft is the same entity with transponder.adsb instead.
#
# push it with:  hydris ec apply vessel.yaml

id: example.vessel.aurora
label: AURORA

geo:
  latitude: 54.2210
  longitude: 12.1440

bearing:
  azimuth: 212

kinematics:
  velocityEnu:
    east: -3.1
    north: -4.9

transponder:
  ais:
    mmsi: 211234560
    imo: 9312345
    callsign: DHAU
    vessel_name: AURORA

# static hull data from the AIS class A report
administrative:
  flag: DE
  owner: Example Shipping GmbH
  length_m: 154.0
  width_m: 23.5
  tonnage_gt: 18700
  year_built: 2011

navigation:
  mode: NavigationModeUnderway

# voyage data. timestamps are RFC 3339 strings, always UTC
mission:
  destination: ROSTOCK
  eta: "2035-06-01T08:30:00Z"

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      vehicle:
        sea: {}

symbol:
  milStd2525C: SNSP------

routing:
  channels:
    - {}

Team

A person is an entity, and a person carries equipment. The equipment attaches to the person exactly as the ship sensors attach to the hull.

Three components do three different jobs, and a UI uses all three:

  • pose gives where the device sits on the person, so it moves with them.
  • assembly says which person the device is part of.
  • device.parent puts the device under the person in the device tree.

The mission component on the team leader names the members, the task and the destination. That is what makes two people a team.

Chat is also an entity, so a message uses the same routing, priority and lifetime as everything else. A bridge can carry it to TAK, to Meshtastic or to a radio. sender and to hold entity ids, not names.

team.yamlhydris ec apply team.yaml
# A team on foot, with the kit each person carries.
#
# Eight entities:
#   example.team.alpha.camp          the base the team works from
#   example.team.alpha.alice         the team leader
#   example.team.alpha.bob           a team member
#   example.team.alpha.alice.vitals  her vital signs monitor
#   example.team.alpha.alice.wind    her wind sensor
#   example.team.alpha.alice.radio   her radio
#   example.team.alpha.chat.1        a message she sent
#   example.team.alpha.chat.2        the answer
#
# A person is an entity like any other. A person carries devices, so the
# devices attach to the person in the same way that the ship sensors attach to
# the hull: `pose` for where the device sits, `assembly` for what it is part
# of, and `device.parent` for the device tree.
#
# Bob carries the same kit. This file gives it to Alice only, to stay short.
#
# push it with:  hydris ec apply team.yaml

---
# The base. The team leader and the radios refer to it.
id: example.team.alpha.camp
label: Base Camp

geo:
  latitude: 54.1770
  longitude: 12.0840
  altitude: 5

device:
  class: camp
  category: Ground
  state: DeviceStateActive

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      infrastructure: {}

symbol:
  milStd2525C: SFGPU-----

routing:
  channels:
    - {}

---
# The team leader.
#
# The mission component makes this a team rather than two separate people.
# It names the members, the task and the destination. Put it on the leader.
id: example.team.alpha.alice
label: Alpha - Alice

geo:
  latitude: 54.1795
  longitude: 12.0902
  altitude: 12

kinematics:
  velocityEnu:
    east: 0.9
    north: 1.1
    up: 0.1

mission:
  members:
    - example.team.alpha.alice
    - example.team.alpha.bob
  description: Shore patrol, north perimeter
  destination: North Mast

device:
  parent: example.team.alpha.camp
  class: person
  category: Ground
  state: DeviceStateActive

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      person: {}

symbol:
  milStd2525C: SFGPUC----

routing:
  channels:
    - {}

---
# A team member. Same shape, no mission component.
id: example.team.alpha.bob
label: Alpha - Bob

geo:
  latitude: 54.1791
  longitude: 12.0897
  altitude: 11

kinematics:
  velocityEnu:
    east: 1.0
    north: 0.8

device:
  parent: example.team.alpha.camp
  class: person
  category: Ground
  state: DeviceStateActive

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      person: {}

symbol:
  milStd2525C: SFGPUC----

routing:
  channels:
    - {}

---
# Vital signs from a chest strap.
#
# `pose` puts the device on the person, so it moves with her. `assembly` says
# it is part of her kit. `device.parent` puts it under her in the device tree.
# The three answer different questions, and a UI uses all three.
id: example.team.alpha.alice.vitals
label: Alice Vital Monitor

assembly:
  parent: example.team.alpha.alice

device:
  parent: example.team.alpha.alice
  class: vitals
  state: DeviceStateActive

pose:
  parent: example.team.alpha.alice
  cartesian:
    east_m: 0.1
    north_m: 0
    up_m: 0.4

# an empty sensor component marks the entity as a sensor
sensor: {}

metric:
  metrics:
    - id: 1
      kind: MetricKindHeartRate
      unit: MetricUnitBeatsPerMinute
      label: Heart Rate
      double: 118
      alerting: AlertLevelWarning
      range:
        min_double: 40
        max_double: 200
    - id: 2
      kind: MetricKindOxygenSaturation
      unit: MetricUnitPercent
      label: SpO2
      double: 94
    - id: 3
      kind: MetricKindBodyTemperature
      unit: MetricUnitCelsius
      label: Body Temperature
      double: 37.1

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor: {}

routing:
  channels:
    - {}

---
# A wind sensor on top of the backpack.
id: example.team.alpha.alice.wind
label: Alice Wind Sensor

assembly:
  parent: example.team.alpha.alice

device:
  parent: example.team.alpha.alice
  class: weather
  state: DeviceStateActive

pose:
  parent: example.team.alpha.alice
  cartesian:
    east_m: 0
    north_m: 0
    up_m: 0.3

sensor: {}

metric:
  metrics:
    - id: 1
      kind: MetricKindWindSpeed
      unit: MetricUnitMeterPerSecond
      label: Wind Speed
      double: 12.4
    - id: 2
      kind: MetricKindWindDirection
      unit: MetricUnitDegree
      label: Wind Direction
      double: 285

classification:
  taxonomy:
    - confidence:
        confidence: 1.0
      equipment:
        sensor: {}

routing:
  channels:
    - {}

---
# The radio on her hip.
#
# `link.via` names the entity on the other end. That is how a client draws the
# radio network, and how it explains a lost link.
id: example.team.alpha.alice.radio
label: Alice Radio

assembly:
  parent: example.team.alpha.alice

device:
  parent: example.team.alpha.alice
  class: radio
  state: DeviceStateActive

pose:
  parent: example.team.alpha.alice
  cartesian:
    east_m: 0.15
    north_m: 0
    up_m: -0.2

link:
  status: LinkStatusDegraded
  via: example.team.alpha.camp
  rssi_dbm: -97
  snr_db: -3
  link_quality_percent: 34

power:
  battery_charge_remaining: 0.41
  voltage: 3.7

symbol:
  milStd2525C: SFGPE-----

routing:
  channels:
    - {}

---
# A message.
#
# Chat is an entity, so it uses the same routing, priority and lifetime as
# everything else. A bridge can carry it to TAK, to Meshtastic or to a radio.
# `sender` and `to` are entity ids, not names.
id: example.team.alpha.chat.1
label: Alice

chat:
  sender: example.team.alpha.alice
  message: Alpha at the north mast, wind is up

priority: PriorityImmediate

routing:
  channels:
    - {}

---
# The answer. `reply_to` links the two.
# A client that has no threads shows this as a normal message.
id: example.team.alpha.chat.2
label: Bob

chat:
  sender: example.team.alpha.bob
  to: example.team.alpha.alice
  reply_to: example.team.alpha.chat.1
  message: Copy, hold there. I am 60 m behind you

priority: PriorityImmediate

routing:
  channels:
    - {}

Push entities for real

The examples are complete entities. A live feed does not push that way. Two rules apply.

Push only what changed. Push takes changes and replacements:

  • changes replaces each component in your message, and leaves out every component you did not send. A driver that sends {id, geo} at 10 Hz keeps label, power and link as they were.
  • replacements replaces the whole entity. The engine removes every component you did not send.

The engine replaces a whole component. It never merges the fields inside one. If you push a geo with only a latitude, the longitude and the altitude are lost. Read Entity Merge before two controllers write to one entity.

Give short-lived things a lifetime. No example sets lifetime.until, because a fixed time in a file is either in the past or far in the future. A real feed sets one:

lifetime:
  fresh: "2026-03-01T09:14:02Z"   # when the sensor made this observation
  until: "2026-03-01T09:14:32Z"   # gone 30 seconds later, unless something refreshes it

fresh also rejects updates that arrive out of order. The engine drops a push when its fresh is older than the value the component already has. Detections and tracks need both fields. Markers, zones and vehicles usually need neither.

Next steps

On this page