feat: implement multidistance rssi->distance model parameter estimation

This commit is contained in:
2026-05-21 18:31:07 +02:00
parent 7b02a37abe
commit bacf56156b
9 changed files with 438 additions and 78 deletions
@@ -0,0 +1,53 @@
defmodule Localiser.Web.Channels.CalibrationChannel do
use Phoenix.Channel
alias Localiser.Domain.Sensors
alias Localiser.Localisation.Sensor.Server, as: SensorServer
@impl true
def join("calibration:" <> sensor_id, _params, socket) do
case Sensors.get_sensor_by_sensor_id(sensor_id) do
nil ->
{:error, %{reason: "sensor not found"}}
_sensor ->
Phoenix.PubSub.subscribe(Localiser.PubSub, "calibration:#{sensor_id}")
state = SensorServer.calibration_state(sensor_id)
{:ok, state, assign(socket, :sensor_id, sensor_id)}
end
end
@impl true
def handle_info({:calibration_mode_entered, _sensor_id, samples_needed}, socket) do
push(socket, "mode_entered", %{samples_needed: samples_needed})
{:noreply, socket}
end
def handle_info({:stage_started, _sensor_id, distance, completed_count}, socket) do
push(socket, "stage_started", %{distance: distance, completed_stages: completed_count})
{:noreply, socket}
end
def handle_info({:calibration_reading, _sensor_id, rssi, is_outlier, %{stage: {cur, total}}}, socket) do
push(socket, "reading", %{rssi: rssi, outlier: is_outlier, stage_progress: %{current: cur, total: total}})
{:noreply, socket}
end
def handle_info({:stage_complete, _sensor_id, distance, readings, mean_rssi}, socket) do
rendered = Enum.map(readings, fn {rssi, outlier} -> %{rssi: rssi, outlier: outlier} end)
push(socket, "stage_complete", %{distance: distance, readings: rendered, mean_rssi: mean_rssi})
{:noreply, socket}
end
def handle_info({:calibration_finished, _sensor_id, rssi_ref, path_loss_exp}, socket) do
push(socket, "finished", %{rssi_ref: rssi_ref, path_loss_exp: path_loss_exp})
{:noreply, socket}
end
def handle_info({:calibration_cancelled, _sensor_id}, socket) do
push(socket, "cancelled", %{})
{:noreply, socket}
end
def handle_info(_msg, socket), do: {:noreply, socket}
end
@@ -23,8 +23,8 @@ defmodule Localiser.Web.Channels.SensorsChannel do
{:noreply, socket}
end
def handle_info({:calibration_complete, sensor_id}, socket) do
push(socket, "calibration_complete", %{sensor_id: sensor_id})
def handle_info({:calibration_complete, sensor_id, rssi_ref, path_loss_exp}, socket) do
push(socket, "calibration_complete", %{sensor_id: sensor_id, rssi_ref: rssi_ref, path_loss_exp: path_loss_exp})
{:noreply, socket}
end
@@ -108,21 +108,39 @@ defmodule Localiser.Web.Controllers.SensorController do
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_start,
summary: "Begin RSSI calibration",
operation :calibration_begin,
summary: "Enter calibration mode (between-stages)",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Calibration params", "application/json", Schemas.CalibrationStartParams, required: true},
responses: [
ok: {"Calibration started", "application/json", Schemas.CalibrationStatus},
bad_request: {"Missing reference_distance", "application/json", Schemas.Error},
ok: {"Calibration mode entered", "application/json", Schemas.CalibrationBeginResponse},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_stop,
summary: "Abort active calibration",
operation :calibration_stage_start,
summary: "Start collecting samples for a specific distance",
parameters: [id: [in: :path, type: :integer, required: true]],
request_body: {"Stage params", "application/json", Schemas.CalibrationStageParams, required: true},
responses: [
ok: {"Stage started", "application/json", Schemas.CalibrationStageResponse},
bad_request: {"Missing or invalid distance", "application/json", Schemas.Error},
unprocessable_entity: {"Stage already active", "application/json", Schemas.Error},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_finish,
summary: "Run regression over completed stages and save calibration",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Calibration aborted", "application/json", Schemas.CalibrationStatus},
ok: {"Calibration saved", "application/json", Schemas.CalibrationFinishResponse},
unprocessable_entity: {"Insufficient stages or stage active", "application/json", Schemas.Error},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
operation :calibration_cancel,
summary: "Abort calibration, discard all stages",
parameters: [id: [in: :path, type: :integer, required: true]],
responses: [
ok: {"Calibration cancelled", "application/json", Schemas.CalibrationStatus},
unauthorized: {"Unauthorized", "application/json", Schemas.Error}
]
@@ -252,24 +270,77 @@ defmodule Localiser.Web.Controllers.SensorController do
json(conn, %{status: "ok"})
end
def calibration_start(conn, %{"id" => id, "reference_distance" => ref_dist}) do
def calibration_begin(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
:ok = SensorServer.begin_calibration(sensor.sensor_id, ref_dist)
json(conn, %{status: "calibrating"})
:ok = SensorServer.begin_calibration_mode(sensor.sensor_id)
json(conn, %{status: "calibration_mode", samples_needed: calibration_samples_needed()})
end
def calibration_start(conn, _params) do
def calibration_stage_start(conn, %{"id" => id, "distance" => distance})
when is_number(distance) and distance > 0 do
sensor = Sensors.get_sensor!(id)
case SensorServer.start_stage(sensor.sensor_id, distance * 1.0) do
{:ok, samples_needed} ->
json(conn, %{status: "stage_active", distance: distance, samples_needed: samples_needed})
{:error, :already_active} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "a stage is already active"})
{:error, :not_in_calibration_mode} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "sensor is not in calibration mode"})
end
end
def calibration_stage_start(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "reference_distance is required"})
|> json(%{error: "distance is required and must be a positive number"})
end
def calibration_stop(conn, %{"id" => id}) do
def calibration_finish(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
case SensorServer.finish_calibration(sensor.sensor_id) do
{:ok, %{rssi_ref: rssi_ref, path_loss_exp: path_loss_exp}} ->
json(conn, %{status: "idle", rssi_ref: rssi_ref, path_loss_exp: path_loss_exp})
{:error, :insufficient_stages} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "at least 2 completed stages required"})
{:error, :stage_active} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "cannot finish while a stage is active"})
{:error, :not_in_calibration_mode} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: "sensor is not in calibration mode"})
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{error: inspect(reason)})
end
end
def calibration_cancel(conn, %{"id" => id}) do
sensor = Sensors.get_sensor!(id)
:ok = SensorServer.abort_calibration(sensor.sensor_id)
json(conn, %{status: "idle"})
end
defp calibration_samples_needed do
Application.get_env(:localiser, :calibration_samples, 30)
end
defp render_sensor(sensor) do
%{
id: sensor.id,
+4 -2
View File
@@ -101,8 +101,10 @@ defmodule Localiser.Web.Router do
delete "/sensors/:id/place", SensorController, :unplace
post "/sensors/:id/factory_reset", SensorController, :factory_reset
post "/sensors/:id/reconfigure", SensorController, :reconfigure
post "/sensors/:id/calibration/start", SensorController, :calibration_start
post "/sensors/:id/calibration/stop", SensorController, :calibration_stop
post "/sensors/:id/calibration/begin", SensorController, :calibration_begin
post "/sensors/:id/calibration/stage", SensorController, :calibration_stage_start
post "/sensors/:id/calibration/finish", SensorController, :calibration_finish
delete "/sensors/:id/calibration", SensorController, :calibration_cancel
get "/sensors/:id/version", SensorController, :get_version
end
end
+44 -6
View File
@@ -140,11 +140,49 @@ defmodule Localiser.Web.Schemas do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationStatus", type: :object,
properties: %{status: %Schema{type: :string, enum: ["calibrating", "idle"]}},
properties: %{status: %Schema{type: :string, enum: ["idle"]}},
required: [:status]
})
end
defmodule CalibrationBeginResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationBeginResponse", type: :object,
properties: %{
status: %Schema{type: :string, enum: ["calibration_mode"]},
samples_needed: %Schema{type: :integer}
},
required: [:status, :samples_needed]
})
end
defmodule CalibrationStageResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationStageResponse", type: :object,
properties: %{
status: %Schema{type: :string, enum: ["stage_active"]},
distance: %Schema{type: :number, format: :float},
samples_needed: %Schema{type: :integer}
},
required: [:status, :distance, :samples_needed]
})
end
defmodule CalibrationFinishResponse do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationFinishResponse", type: :object,
properties: %{
status: %Schema{type: :string, enum: ["idle"]},
rssi_ref: %Schema{type: :integer},
path_loss_exp: %Schema{type: :number, format: :float}
},
required: [:status, :rssi_ref, :path_loss_exp]
})
end
# ── Request body schemas ────────────────────────────────────────────────────
defmodule SetupParams do
@@ -292,17 +330,17 @@ defmodule Localiser.Web.Schemas do
})
end
defmodule CalibrationStartParams do
defmodule CalibrationStageParams do
require OpenApiSpex
OpenApiSpex.schema(%{
title: "CalibrationStartParams", type: :object,
title: "CalibrationStageParams", type: :object,
properties: %{
reference_distance: %Schema{
distance: %Schema{
type: :number, format: :float,
description: "Known tag-to-sensor distance in metres"
description: "Known tag-to-sensor distance in metres (must be > 0)"
}
},
required: [:reference_distance]
required: [:distance]
})
end
+1
View File
@@ -4,6 +4,7 @@ defmodule Localiser.Web.UserSocket do
channel "particles:*", Localiser.Web.Channels.ParticlesChannel
channel "rooms:occupancy", Localiser.Web.Channels.RoomChannel
channel "sensors", Localiser.Web.Channels.SensorsChannel
channel "calibration:*", Localiser.Web.Channels.CalibrationChannel
@impl true
def connect(%{"token" => token}, socket, _connect_info) do