prop/lib/microwaveprop/beacon_measurements/beacon_measurement.ex
Graham McIntire bd731685de
feat(api): ingest endpoint for propmonitor beacon measurements
POST /api/v1/beacon-monitor/measurements accepts one measurement per
integration window from a propmonitor client, authenticated by the
BeaconMonitor token. Records noise floor, signal peak/avg dBFS, SNR,
signal-active-fraction, gain, and frequency for later correlation
against weather and propagation scores.

Beacon UUID must resolve to an approved + on-the-air beacon (404
otherwise — the client drops 404s without retry). Retries with the
same (monitor, beacon, measured_at) are idempotent: a unique index
makes the second insert a 409. Every accepted upload stamps the
monitor's last_seen_at — measurements *are* the heartbeat.

MonitorAuth is a separate plug from the user API-token Auth plug;
monitors are not users. The rate limiter buckets each monitor by id so
retry storms after a 5xx don't burn the shared anon-IP bucket.
2026-05-13 16:07:04 -05:00

76 lines
2.7 KiB
Elixir

defmodule Microwaveprop.BeaconMeasurements.BeaconMeasurement do
@moduledoc """
A single beacon-signal-level measurement uploaded by a propmonitor
client. One row per integration window per (monitor, beacon).
All `*_dbfs` fields are uncalibrated and relative to the SDR ADC
full-scale; `gain_db` is the SDR's reported gain at upload time and
is needed to interpret dBFS trends across gain changes. SNR is
already gain-independent so it is the preferred calibration input.
`signal_*_dbfs` and `snr_*_db` are computed only over FFT frames
whose in-band power exceeded `noise_floor + 3 dB`; `signal_active_fraction`
reports the duty cycle of those frames. When `signal_active_fraction
== 0.0`, no signal was heard and the peak/avg fields sit near the
noise floor (SNR ≈ 0 dB).
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Beacons.Beacon
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "beacon_measurements" do
belongs_to :beacon, Beacon
belongs_to :monitor, BeaconMonitor
field :measured_at, :utc_datetime
field :frequency_hz, :integer
field :integration_s, :integer
field :passband_hz, :float
field :gain_db, :float
field :noise_floor_dbfs, :float
field :signal_peak_dbfs, :float
field :signal_avg_dbfs, :float
field :snr_peak_db, :float
field :snr_avg_db, :float
field :signal_active_fraction, :float
field :propmonitor_version, :string
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@cast_fields ~w(beacon_id monitor_id measured_at frequency_hz integration_s
passband_hz gain_db noise_floor_dbfs signal_peak_dbfs
signal_avg_dbfs snr_peak_db snr_avg_db
signal_active_fraction propmonitor_version)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(measurement, attrs) do
measurement
|> cast(attrs, @cast_fields)
|> validate_required(@cast_fields)
|> validate_number(:frequency_hz, greater_than: 0)
|> validate_number(:integration_s, greater_than_or_equal_to: 5, less_than_or_equal_to: 3600)
|> validate_number(:passband_hz, greater_than: 0.0)
|> validate_number(:gain_db, greater_than_or_equal_to: -10.0, less_than_or_equal_to: 80.0)
|> validate_number(:signal_active_fraction,
greater_than_or_equal_to: 0.0,
less_than_or_equal_to: 1.0
)
|> validate_length(:propmonitor_version, max: 32)
|> foreign_key_constraint(:beacon_id)
|> foreign_key_constraint(:monitor_id)
|> unique_constraint([:monitor_id, :beacon_id, :measured_at],
name: :beacon_measurements_monitor_beacon_measured_at_idx
)
end
end