From 820fe674eaae2cd6e4b2ca349e0c18692c94db49 Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Thu, 26 Feb 2026 23:13:01 +0700 Subject: [PATCH] test_version_in_local --- Dockerfile | 12 + README.md | 182 +++++ __pycache__/service.cpython-311.pyc | Bin 0 -> 34064 bytes ...e_integration.cpython-311-pytest-8.2.2.pyc | Bin 0 -> 15340 bytes ...triangulation.cpython-311-pytest-8.2.2.pyc | Bin 0 -> 12853 bytes __pycache__/triangulation.cpython-311.pyc | Bin 0 -> 22696 bytes cli.py | 410 +++++++++++ config.template.json | 60 ++ docker-compose.yml | 55 ++ docker/config.docker.test.json | 60 ++ docker/mock_output_sink.py | 59 ++ docker/mock_receiver.py | 57 ++ service.py | 678 ++++++++++++++++++ setup.ps1 | 24 + setup.sh | 31 + test_service_integration.py | 207 ++++++ test_triangulation.py | 84 +++ triangulation.py | 394 ++++++++++ web/app.js | 140 ++++ web/index.html | 86 +++ web/styles.css | 201 ++++++ 21 files changed, 2740 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 __pycache__/service.cpython-311.pyc create mode 100644 __pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc create mode 100644 __pycache__/test_triangulation.cpython-311-pytest-8.2.2.pyc create mode 100644 __pycache__/triangulation.cpython-311.pyc create mode 100644 cli.py create mode 100644 config.template.json create mode 100644 docker-compose.yml create mode 100644 docker/config.docker.test.json create mode 100644 docker/mock_output_sink.py create mode 100644 docker/mock_receiver.py create mode 100644 service.py create mode 100644 setup.ps1 create mode 100644 setup.sh create mode 100644 test_service_integration.py create mode 100644 test_triangulation.py create mode 100644 triangulation.py create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/styles.css diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a5f0ec1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY . /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +EXPOSE 8081 + +CMD ["python", "service.py", "--config", "docker/config.docker.json"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8091190 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Triangulation Service + +Сервис решает 3D-трилатерацию по 3 ресиверам: +- центры сфер: координаты ресиверов; +- радиусы сфер: расстояния, оцененные из RSSI с учетом частоты; +- расчет идет по одинаковым частотам, которые есть у всех 3 ресиверов; +- формируется таблица `frequency_table` (по каждой частоте отдельное решение); +- выбирается итоговая частота `selected_frequency_hz` по минимальному `rmse_m`. + +## Что реализовано + +- Автоматический polling 3 входных серверов (`http_sources`). +- Валидация входных payload с подробными ошибками. +- API: + - `GET /health` + - `GET /result` + - `GET /frequencies` + - `POST /refresh` + - `GET /config` + - `POST /config` +- UI (`/ui`) с: + - входными данными ресиверов; + - таблицей пересечений по частотам; + - итоговой позицией; + - статусом отправки на конечный сервер. +- Опциональный push результата на внешний сервер (`runtime.output_server`). + +## Структура проекта + +- [service.py](/c:/Users/snytk/triangulation/service.py) - автосервис + API + UI статик. +- [triangulation.py](/c:/Users/snytk/triangulation/triangulation.py) - математика. +- [config.template.json](/c:/Users/snytk/triangulation/config.template.json) - шаблон конфига. +- [web/index.html](/c:/Users/snytk/triangulation/web/index.html), [web/styles.css](/c:/Users/snytk/triangulation/web/styles.css), [web/app.js](/c:/Users/snytk/triangulation/web/app.js) - UI. +- [docker-compose.yml](/c:/Users/snytk/triangulation/docker-compose.yml) - test/prod профили. +- [docker/config.docker.test.json](/c:/Users/snytk/triangulation/docker/config.docker.test.json) - тестовый конфиг. +- [docker/mock_receiver.py](/c:/Users/snytk/triangulation/docker/mock_receiver.py) - mock входные сервера (random RSSI). +- [docker/mock_output_sink.py](/c:/Users/snytk/triangulation/docker/mock_output_sink.py) - mock конечный сервер. + +## Docker Compose: test/prod режимы + +`docker-compose.yml` разделен на профили: + +- `test`: + - `triangulation-test` + - `receiver-r0`, `receiver-r1`, `receiver-r2` + - `output-sink` + +- `prod`: + - `triangulation-prod` (читает ваш `./config.json`) + +Это позволяет легко отключить тестовый режим и перейти на реальные сервера. + +## Быстрый старт: Test Mode + +Поднимает все контейнеры для end-to-end проверки: +- 3 входных mock сервера с random данными; +- основной сервис; +- output-sink, принимающий отправленные результаты. + +```bash +docker compose --profile test up --build +``` + +Открыть: +- UI: `http://localhost:8081/ui` +- Полный результат: `http://localhost:8081/result` +- Частоты: `http://localhost:8081/frequencies` +- Полученные output-sink данные: `http://localhost:8080/latest` + +Остановить: +```bash +docker compose --profile test down +``` + +## Быстрый старт: Prod Mode + +1. Создайте `config.json` из шаблона: +```bash +cp config.template.json config.json +``` + +2. Заполните ваши реальные: +- `input.receivers[].source_url` +- `input.receivers[].center` +- `runtime.output_server` + +3. Запустите: +```bash +docker compose --profile prod up --build +``` + +Остановить: +```bash +docker compose --profile prod down +``` + +## Как проверить, что данные приходят и отправляются + +В UI (`/ui`) видно: +- блок `Ресиверы`: входящие samples; +- таблица `Таблица пересечений по частотам`: решения по каждой общей частоте; +- блок `Отправка на конечный сервер`: статус доставки (`ok/error`), HTTP-код, время, target. + +Дополнительно: +- `GET /result` возвращает `output_delivery`. +- `GET /frequencies` тоже возвращает `output_delivery`. +- `GET http://localhost:8080/latest` показывает, что именно принял output-sink. + +## Конфиг (основные поля) + +Пример: [config.template.json](/c:/Users/snytk/triangulation/config.template.json) + +Критичные поля: +- `input.mode`: только `"http_sources"` для автосервиса. +- `input.receivers`: ровно 3 ресивера. +- `input.aggregation`: `"median"` или `"mean"`. +- `runtime.poll_interval_s`: период опроса. +- `runtime.output_server.enabled`: push во внешний сервер. + +## Формат входных payload + +Поддержка: +- объект с `measurements`/`samples`/`data`; +- или сразу массив измерений. + +Измерение: +- `frequency_hz` (или `freq_hz`/`frequency`/`freq`) +- `amplitude_dbm` (или `rssi_dbm`/`amplitude`/`rssi`) + +Пример: +```json +{ + "receiver_id": "r0", + "measurements": [ + { "frequency_hz": 433920000, "rssi_dbm": -61.5 }, + { "frequency_hz": 868100000, "rssi_dbm": -67.2 } + ] +} +``` + +Если `receiver_id` передан, сервис сверяет его с ожидаемым receiver из конфига. + +## Валидация и ошибки некорректного контекста + +Проверяется: +- тип payload; +- наличие измерений; +- числовые и конечные значения; +- `frequency_hz > 0`; +- соответствие `receiver_id` при наличии; +- наличие общих частот у всех 3 ресиверов. + +Ошибки содержат: +- `source_url=...` +- номер строки `row #...` +- проблемное поле. + +## Тесты + +Запуск: +```bash +pytest -q +``` + +Покрытие: +- математика триангуляции; +- влияние частоты на RSSI->distance; +- интеграция `AutoService.refresh_once()`; +- валидационные сценарии; +- ошибки контекста (нет общих частот, bad field, receiver mismatch, network error, output reject). + +Файл интеграционных тестов: +- [test_service_integration.py](/c:/Users/snytk/triangulation/test_service_integration.py) + +## Локальный запуск без Docker + +```bash +python service.py --config config.json +``` + +UI: +- `http://127.0.0.1:8081/ui` diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ea6cfa19bae614df779dd49bf1f1345d0dcd60d GIT binary patch literal 34064 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 literal 0 HcmV?d00001 diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..c7ec06d --- /dev/null +++ b/cli.py @@ -0,0 +1,410 @@ +from __future__ import annotations + +import argparse +import json +import statistics +from pathlib import Path +from typing import Dict, List, Sequence, Tuple +from urllib import error, request + +from triangulation import ( + PropagationModel, + ReceiverSignal, + Sphere, + rssi_to_distance_m, + send_payload_to_server, + solve_and_prepare_payload, + solve_three_sphere_intersection, +) + +Point3D = Tuple[float, float, float] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="3D trilateration from config file or direct CLI parameters." + ) + parser.add_argument( + "--config", + type=str, + default="config.json", + help="Path to JSON config. Used when --receiver is not provided.", + ) + + parser.add_argument( + "--receiver", + action="append", + nargs=6, + metavar=("ID", "X", "Y", "Z", "AMPLITUDE_DBM", "FREQ_HZ"), + help="Direct receiver measurement. Provide exactly 3 times for CLI mode.", + ) + parser.add_argument("--tx-power-dbm", type=float) + parser.add_argument("--tx-gain-dbi", type=float, default=0.0) + parser.add_argument("--rx-gain-dbi", type=float, default=0.0) + parser.add_argument("--path-loss-exponent", type=float, default=2.0) + parser.add_argument("--reference-distance-m", type=float, default=1.0) + parser.add_argument("--min-distance-m", type=float, default=1e-3) + parser.add_argument("--tolerance", type=float, default=1e-3) + parser.add_argument( + "--z-preference", + choices=("positive", "negative"), + default="positive", + ) + parser.add_argument("--server-ip", type=str, default="") + parser.add_argument("--server-port", type=int, default=8080) + parser.add_argument("--server-path", type=str, default="/triangulation") + parser.add_argument("--timeout-s", type=float, default=3.0) + parser.add_argument("--no-print-json", action="store_true") + return parser.parse_args() + + +def _load_json(path: str) -> Dict[str, object]: + config_path = Path(path) + if not config_path.exists(): + raise SystemExit( + f"Config file '{path}' not found. Create it from config.template.json or pass CLI receivers." + ) + with config_path.open("r", encoding="utf-8") as file: + loaded = json.load(file) + if not isinstance(loaded, dict): + raise SystemExit("Config root must be a JSON object.") + return loaded + + +def _center_from_obj(obj: Dict[str, object]) -> Point3D: + center = obj.get("center") + if not isinstance(center, dict): + raise ValueError("Receiver must contain 'center' object.") + return (float(center["x"]), float(center["y"]), float(center["z"])) + + +def _model_from_obj(obj: Dict[str, object]) -> PropagationModel: + return PropagationModel( + tx_power_dbm=float(obj["tx_power_dbm"]), + tx_gain_dbi=float(obj.get("tx_gain_dbi", 0.0)), + rx_gain_dbi=float(obj.get("rx_gain_dbi", 0.0)), + path_loss_exponent=float(obj.get("path_loss_exponent", 2.0)), + reference_distance_m=float(obj.get("reference_distance_m", 1.0)), + min_distance_m=float(obj.get("min_distance_m", 1e-3)), + ) + + +def _float_from_measurement(item: Dict[str, object], keys: Sequence[str], name: str) -> float: + for key in keys: + if key in item: + return float(item[key]) + raise ValueError(f"Missing '{name}' in source measurement.") + + +def _parse_source_payload(payload: object) -> List[Tuple[float, float]]: + if isinstance(payload, dict): + raw_measurements = payload.get("measurements") + if raw_measurements is None: + raw_measurements = payload.get("samples") + if raw_measurements is None: + raw_measurements = payload.get("data") + elif isinstance(payload, list): + raw_measurements = payload + else: + raise ValueError("Source payload must be array or object with measurements.") + + if not isinstance(raw_measurements, list) or not raw_measurements: + raise ValueError("Source payload has no measurements.") + + parsed: List[Tuple[float, float]] = [] + for item in raw_measurements: + if not isinstance(item, dict): + raise ValueError("Each measurement item must be an object.") + frequency_hz = _float_from_measurement( + item, + keys=("frequency_hz", "freq_hz", "frequency", "freq"), + name="frequency_hz", + ) + amplitude_dbm = _float_from_measurement( + item, + keys=("amplitude_dbm", "rssi_dbm", "amplitude", "rssi"), + name="amplitude_dbm", + ) + parsed.append((frequency_hz, amplitude_dbm)) + return parsed + + +def _fetch_source_measurements(url: str, timeout_s: float) -> List[Tuple[float, float]]: + req = request.Request(url=url, method="GET", headers={"Accept": "application/json"}) + try: + with request.urlopen(req, timeout=timeout_s) as response: + payload = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {exc.code} for '{url}': {body}") + except error.URLError as exc: + raise RuntimeError(f"Cannot reach '{url}': {exc.reason}") + except TimeoutError: + raise RuntimeError(f"Timeout while reading '{url}'") + except json.JSONDecodeError as exc: + raise RuntimeError(f"Invalid JSON from '{url}': {exc}") + return _parse_source_payload(payload) + + +def _aggregate_radius( + measurements: Sequence[Tuple[float, float]], + model: PropagationModel, + method: str, +) -> float: + distances = [ + rssi_to_distance_m(amplitude_dbm=amplitude, frequency_hz=frequency, model=model) + for frequency, amplitude in measurements + ] + if method == "mean": + return sum(distances) / len(distances) + if method == "median": + return float(statistics.median(distances)) + raise ValueError("aggregation must be 'median' or 'mean'") + + +def _run_direct_cli_mode(args: argparse.Namespace) -> int: + if not args.receiver or len(args.receiver) != 3: + raise SystemExit( + "CLI mode requires exactly 3 --receiver entries. Otherwise use --config." + ) + if args.tx_power_dbm is None: + raise SystemExit("CLI mode requires --tx-power-dbm.") + + signals: List[ReceiverSignal] = [] + for receiver_id, x, y, z, amplitude_dbm, frequency_hz in args.receiver: + signals.append( + ReceiverSignal( + receiver_id=receiver_id, + center=(float(x), float(y), float(z)), + amplitude_dbm=float(amplitude_dbm), + frequency_hz=float(frequency_hz), + ) + ) + + model = PropagationModel( + tx_power_dbm=args.tx_power_dbm, + tx_gain_dbi=args.tx_gain_dbi, + rx_gain_dbi=args.rx_gain_dbi, + path_loss_exponent=args.path_loss_exponent, + reference_distance_m=args.reference_distance_m, + min_distance_m=args.min_distance_m, + ) + + result, payload = solve_and_prepare_payload( + signals=signals, + model=model, + tolerance=args.tolerance, + z_preference=args.z_preference, + ) + _print_result(result, payload, print_json=not args.no_print_json) + return _send_if_needed( + payload=payload, + server_ip=args.server_ip, + port=args.server_port, + path=args.server_path, + timeout_s=args.timeout_s, + ) + + +def _run_config_mode(config: Dict[str, object]) -> int: + model_obj = config.get("model") + solver_obj = config.get("solver", {}) + output_obj = config.get("output", {}) + input_obj = config.get("input") + + if not isinstance(model_obj, dict): + raise SystemExit("Config must contain object 'model'.") + if not isinstance(input_obj, dict): + raise SystemExit("Config must contain object 'input'.") + if not isinstance(solver_obj, dict): + raise SystemExit("'solver' must be object.") + if not isinstance(output_obj, dict): + raise SystemExit("'output' must be object.") + + model = _model_from_obj(model_obj) + tolerance = float(solver_obj.get("tolerance", 1e-3)) + z_preference = str(solver_obj.get("z_preference", "positive")) + if z_preference not in ("positive", "negative"): + raise SystemExit("solver.z_preference must be 'positive' or 'negative'.") + + input_mode = str(input_obj.get("mode", "manual")) + if input_mode == "manual": + payload, result = _solve_from_manual_config(input_obj, model, tolerance, z_preference) + elif input_mode == "http_sources": + payload, result = _solve_from_sources_config(input_obj, model, tolerance, z_preference) + else: + raise SystemExit("input.mode must be 'manual' or 'http_sources'.") + + print_json = bool(output_obj.get("print_json", True)) + _print_result(result, payload, print_json=print_json) + + server_ip = str(output_obj.get("server_ip", "")) + return _send_if_needed( + payload=payload, + server_ip=server_ip, + port=int(output_obj.get("server_port", 8080)), + path=str(output_obj.get("server_path", "/triangulation")), + timeout_s=float(output_obj.get("timeout_s", 3.0)), + ) + + +def _solve_from_manual_config( + input_obj: Dict[str, object], + model: PropagationModel, + tolerance: float, + z_preference: str, +): + receivers = input_obj.get("receivers") + if not isinstance(receivers, list) or len(receivers) != 3: + raise SystemExit("input.receivers must contain exactly 3 receivers.") + + signals: List[ReceiverSignal] = [] + for receiver in receivers: + if not isinstance(receiver, dict): + raise SystemExit("Each receiver in input.receivers must be an object.") + signals.append( + ReceiverSignal( + receiver_id=str(receiver["receiver_id"]), + center=_center_from_obj(receiver), + amplitude_dbm=float(receiver["amplitude_dbm"]), + frequency_hz=float(receiver["frequency_hz"]), + ) + ) + + result, payload = solve_and_prepare_payload( + signals=signals, + model=model, + tolerance=tolerance, + z_preference=z_preference, # type: ignore[arg-type] + ) + return payload, result + + +def _solve_from_sources_config( + input_obj: Dict[str, object], + model: PropagationModel, + tolerance: float, + z_preference: str, +): + receivers = input_obj.get("receivers") + if not isinstance(receivers, list) or len(receivers) != 3: + raise SystemExit("input.receivers must contain exactly 3 receivers.") + + timeout_s = float(input_obj.get("source_timeout_s", 3.0)) + aggregation = str(input_obj.get("aggregation", "median")) + if aggregation not in ("median", "mean"): + raise SystemExit("input.aggregation must be 'median' or 'mean'.") + + spheres: List[Sphere] = [] + receiver_payloads: List[Dict[str, object]] = [] + + for receiver in receivers: + if not isinstance(receiver, dict): + raise SystemExit("Each receiver in input.receivers must be an object.") + receiver_id = str(receiver["receiver_id"]) + center = _center_from_obj(receiver) + source_url = str(receiver["source_url"]) + measurements = _fetch_source_measurements(source_url, timeout_s=timeout_s) + radius = _aggregate_radius(measurements, model=model, method=aggregation) + spheres.append(Sphere(center=center, radius=radius)) + + sample_payload = [] + for frequency_hz, amplitude_dbm in measurements: + sample_payload.append( + { + "frequency_hz": frequency_hz, + "amplitude_dbm": amplitude_dbm, + "distance_m": rssi_to_distance_m( + amplitude_dbm=amplitude_dbm, + frequency_hz=frequency_hz, + model=model, + ), + } + ) + + receiver_payloads.append( + { + "receiver_id": receiver_id, + "center": {"x": center[0], "y": center[1], "z": center[2]}, + "source_url": source_url, + "aggregation": aggregation, + "radius_m": radius, + "samples": sample_payload, + } + ) + + result = solve_three_sphere_intersection( + spheres=spheres, + tolerance=tolerance, + z_preference=z_preference, # type: ignore[arg-type] + ) + + for idx, residual in enumerate(result.residuals): + receiver_payloads[idx]["residual_m"] = residual + + payload = { + "position": {"x": result.point[0], "y": result.point[1], "z": result.point[2]}, + "exact": result.exact, + "rmse_m": result.rmse, + "model": { + "tx_power_dbm": model.tx_power_dbm, + "tx_gain_dbi": model.tx_gain_dbi, + "rx_gain_dbi": model.rx_gain_dbi, + "path_loss_exponent": model.path_loss_exponent, + "reference_distance_m": model.reference_distance_m, + }, + "receivers": receiver_payloads, + } + return payload, result + + +def _print_result(result, payload: Dict[str, object], print_json: bool) -> None: + x, y, z = result.point + print(f"point: ({x:.6f}, {y:.6f}, {z:.6f})") + print( + "residuals: " + f"({result.residuals[0]:.6e}, {result.residuals[1]:.6e}, {result.residuals[2]:.6e})" + ) + print(f"rmse: {result.rmse:.6e}") + print(f"exact: {result.exact}") + if print_json: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + + +def _send_if_needed( + payload: Dict[str, object], + server_ip: str, + port: int, + path: str, + timeout_s: float, +) -> int: + if not server_ip: + return 0 + status, body = send_payload_to_server( + server_ip=server_ip, + payload=payload, + port=port, + path=path, + timeout_s=timeout_s, + ) + print(f"server_status: {status}") + if body: + print(f"server_response: {body}") + if status == 0 or status >= 400: + return 2 + return 0 + + +def main() -> int: + args = parse_args() + try: + if args.receiver: + return _run_direct_cli_mode(args) + config = _load_json(args.config) + return _run_config_mode(config) + except (RuntimeError, ValueError, KeyError) as exc: + raise SystemExit(str(exc)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/config.template.json b/config.template.json new file mode 100644 index 0000000..fa59ef3 --- /dev/null +++ b/config.template.json @@ -0,0 +1,60 @@ +{ + "model": { + "tx_power_dbm": 20.0, + "tx_gain_dbi": 0.0, + "rx_gain_dbi": 0.0, + "path_loss_exponent": 2.0, + "reference_distance_m": 1.0, + "min_distance_m": 0.001 + }, + "solver": { + "tolerance": 0.001, + "z_preference": "positive" + }, + "runtime": { + "listen_host": "0.0.0.0", + "listen_port": 8081, + "poll_interval_s": 1.0, + "output_server": { + "enabled": false, + "ip": "192.168.1.100", + "port": 8080, + "path": "/triangulation", + "timeout_s": 3.0 + } + }, + "input": { + "mode": "http_sources", + "aggregation": "median", + "source_timeout_s": 3.0, + "receivers": [ + { + "receiver_id": "r0", + "center": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "source_url": "http://10.0.0.11:9000/measurements" + }, + { + "receiver_id": "r1", + "center": { + "x": 10.0, + "y": 0.0, + "z": 0.0 + }, + "source_url": "http://10.0.0.12:9000/measurements" + }, + { + "receiver_id": "r2", + "center": { + "x": 0.0, + "y": 8.0, + "z": 0.0 + }, + "source_url": "http://10.0.0.13:9000/measurements" + } + ] + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..866c460 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + triangulation-test: + build: . + container_name: triangulation-test + command: ["python", "service.py", "--config", "docker/config.docker.test.json"] + ports: + - "8081:8081" + depends_on: + - receiver-r0 + - receiver-r1 + - receiver-r2 + - output-sink + profiles: ["test"] + + receiver-r0: + build: . + container_name: receiver-r0 + command: ["python", "docker/mock_receiver.py", "--receiver-id", "r0", "--port", "9000", "--base-rssi", "-61.0"] + expose: + - "9000" + profiles: ["test"] + + receiver-r1: + build: . + container_name: receiver-r1 + command: ["python", "docker/mock_receiver.py", "--receiver-id", "r1", "--port", "9000", "--base-rssi", "-64.0"] + expose: + - "9000" + profiles: ["test"] + + receiver-r2: + build: . + container_name: receiver-r2 + command: ["python", "docker/mock_receiver.py", "--receiver-id", "r2", "--port", "9000", "--base-rssi", "-63.0"] + expose: + - "9000" + profiles: ["test"] + + output-sink: + build: . + container_name: output-sink + command: ["python", "docker/mock_output_sink.py", "--port", "8080"] + ports: + - "8080:8080" + profiles: ["test"] + + triangulation-prod: + build: . + container_name: triangulation-prod + command: ["python", "service.py", "--config", "/app/config.json"] + ports: + - "8081:8081" + volumes: + - ./config.json:/app/config.json:ro + profiles: ["prod"] diff --git a/docker/config.docker.test.json b/docker/config.docker.test.json new file mode 100644 index 0000000..2fbdfa4 --- /dev/null +++ b/docker/config.docker.test.json @@ -0,0 +1,60 @@ +{ + "model": { + "tx_power_dbm": 20.0, + "tx_gain_dbi": 0.0, + "rx_gain_dbi": 0.0, + "path_loss_exponent": 2.0, + "reference_distance_m": 1.0, + "min_distance_m": 0.001 + }, + "solver": { + "tolerance": 0.001, + "z_preference": "positive" + }, + "runtime": { + "listen_host": "0.0.0.0", + "listen_port": 8081, + "poll_interval_s": 1.0, + "output_server": { + "enabled": true, + "ip": "output-sink", + "port": 8080, + "path": "/triangulation", + "timeout_s": 3.0 + } + }, + "input": { + "mode": "http_sources", + "aggregation": "median", + "source_timeout_s": 3.0, + "receivers": [ + { + "receiver_id": "r0", + "center": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "source_url": "http://receiver-r0:9000/measurements" + }, + { + "receiver_id": "r1", + "center": { + "x": 10.0, + "y": 0.0, + "z": 0.0 + }, + "source_url": "http://receiver-r1:9000/measurements" + }, + { + "receiver_id": "r2", + "center": { + "x": 0.0, + "y": 8.0, + "z": 0.0 + }, + "source_url": "http://receiver-r2:9000/measurements" + } + ] + } +} diff --git a/docker/mock_output_sink.py b/docker/mock_output_sink.py new file mode 100644 index 0000000..3870211 --- /dev/null +++ b/docker/mock_output_sink.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import argparse +import json +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=8080) + args = parser.parse_args() + + latest = {"count": 0, "last_payload": None} + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args2) -> None: + return + + def do_GET(self) -> None: + if self.path != "/latest": + self.send_response(404) + self.end_headers() + return + raw = json.dumps(latest).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_POST(self) -> None: + if self.path != "/triangulation": + self.send_response(404) + self.end_headers() + return + + content_length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(content_length) if content_length > 0 else b"{}" + payload = json.loads(body.decode("utf-8")) + selected = payload.get("selected_frequency_hz") + latest["count"] = int(latest["count"]) + 1 + latest["last_payload"] = payload + print(f"received payload, selected_frequency_hz={selected}") + + raw = json.dumps({"status": "ok"}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler) + print(f"mock_output_sink listening on :{args.port}") + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docker/mock_receiver.py b/docker/mock_receiver.py new file mode 100644 index 0000000..c6a4a90 --- /dev/null +++ b/docker/mock_receiver.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import argparse +import json +import random +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Dict, List + + +def _build_payload(receiver_id: str, base_rssi: float) -> Dict[str, object]: + noise_a = random.uniform(-1.2, 1.2) + noise_b = random.uniform(-1.2, 1.2) + rows: List[Dict[str, float]] = [ + {"frequency_hz": 433_920_000.0, "rssi_dbm": base_rssi + noise_a}, + {"frequency_hz": 868_100_000.0, "rssi_dbm": base_rssi - 4.0 + noise_b}, + ] + return { + "receiver_id": receiver_id, + "timestamp_unix": time.time(), + "measurements": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--receiver-id", required=True) + parser.add_argument("--port", type=int, default=9000) + parser.add_argument("--base-rssi", type=float, default=-62.0) + args = parser.parse_args() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args2) -> None: + return + + def do_GET(self) -> None: + if self.path != "/measurements": + self.send_response(404) + self.end_headers() + return + + payload = _build_payload(args.receiver_id, args.base_rssi) + raw = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler) + print(f"mock_receiver({args.receiver_id}) listening on :{args.port}") + server.serve_forever() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/service.py b/service.py new file mode 100644 index 0000000..1cad317 --- /dev/null +++ b/service.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +import argparse +import json +import math +import mimetypes +import statistics +import threading +import time +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple +from urllib import error, parse, request + +from triangulation import ( + PropagationModel, + Sphere, + rssi_to_distance_m, + send_payload_to_server, + solve_three_sphere_intersection, +) + +Point3D = Tuple[float, float, float] + + +def _load_json(path: str) -> Dict[str, object]: + file_path = Path(path) + if not file_path.exists(): + raise SystemExit(f"Config file not found: {path}") + with file_path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + if not isinstance(data, dict): + raise SystemExit("Config root must be a JSON object.") + return data + + +def _center_from_obj(obj: Dict[str, object]) -> Point3D: + center = obj.get("center") + if not isinstance(center, dict): + raise ValueError("Receiver center must be an object.") + return (float(center["x"]), float(center["y"]), float(center["z"])) + + +def _parse_model(config: Dict[str, object]) -> PropagationModel: + model_obj = config.get("model") + if not isinstance(model_obj, dict): + raise ValueError("Config must contain object 'model'.") + return PropagationModel( + tx_power_dbm=float(model_obj["tx_power_dbm"]), + tx_gain_dbi=float(model_obj.get("tx_gain_dbi", 0.0)), + rx_gain_dbi=float(model_obj.get("rx_gain_dbi", 0.0)), + path_loss_exponent=float(model_obj.get("path_loss_exponent", 2.0)), + reference_distance_m=float(model_obj.get("reference_distance_m", 1.0)), + min_distance_m=float(model_obj.get("min_distance_m", 1e-3)), + ) + + +def _float_from_measurement( + item: Dict[str, object], + keys: Sequence[str], + field_name: str, + source_label: str, + row_index: int, +) -> float: + for key in keys: + if key in item: + value = item[key] + try: + parsed = float(value) + except (TypeError, ValueError): + raise ValueError( + f"{source_label}: row #{row_index} field '{key}' must be numeric, got {value!r}." + ) from None + if not math.isfinite(parsed): + raise ValueError( + f"{source_label}: row #{row_index} field '{key}' must be finite, got {value!r}." + ) + return parsed + raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.") + + +def parse_source_payload( + payload: object, + source_label: str, + expected_receiver_id: Optional[str] = None, +) -> List[Tuple[float, float]]: + if isinstance(payload, dict): + if expected_receiver_id is not None and "receiver_id" in payload: + payload_receiver_id = str(payload["receiver_id"]) + if payload_receiver_id != expected_receiver_id: + raise ValueError( + f"{source_label}: payload receiver_id '{payload_receiver_id}' " + f"does not match expected '{expected_receiver_id}'." + ) + raw_items = payload.get("measurements") + if raw_items is None: + raw_items = payload.get("samples") + if raw_items is None: + raw_items = payload.get("data") + elif isinstance(payload, list): + raw_items = payload + else: + raise ValueError(f"{source_label}: payload must be list or object.") + + if not isinstance(raw_items, list) or not raw_items: + raise ValueError(f"{source_label}: payload contains no measurements.") + + parsed_items: List[Tuple[float, float]] = [] + for row_index, row in enumerate(raw_items, start=1): + if not isinstance(row, dict): + raise ValueError(f"{source_label}: row #{row_index} must be an object.") + frequency_hz = _float_from_measurement( + row, + keys=("frequency_hz", "freq_hz", "frequency", "freq"), + field_name="frequency_hz", + source_label=source_label, + row_index=row_index, + ) + amplitude_dbm = _float_from_measurement( + row, + keys=("amplitude_dbm", "rssi_dbm", "amplitude", "rssi"), + field_name="amplitude_dbm", + source_label=source_label, + row_index=row_index, + ) + if frequency_hz <= 0.0: + raise ValueError( + f"{source_label}: row #{row_index} field 'frequency_hz' must be > 0." + ) + parsed_items.append((frequency_hz, amplitude_dbm)) + return parsed_items + + +def aggregate_radius( + measurements: Sequence[Tuple[float, float]], + model: PropagationModel, + method: str, +) -> float: + distances = [ + rssi_to_distance_m(amplitude_dbm=amplitude_dbm, frequency_hz=frequency_hz, model=model) + for frequency_hz, amplitude_dbm in measurements + ] + if method == "median": + return float(statistics.median(distances)) + if method == "mean": + return float(sum(distances) / len(distances)) + raise ValueError("aggregation must be 'median' or 'mean'") + + +def _group_by_frequency( + measurements: Sequence[Tuple[float, float]], +) -> Dict[float, List[Tuple[float, float]]]: + grouped: Dict[float, List[Tuple[float, float]]] = {} + for frequency_hz, amplitude_dbm in measurements: + if frequency_hz not in grouped: + grouped[frequency_hz] = [] + grouped[frequency_hz].append((frequency_hz, amplitude_dbm)) + return grouped + + +def _fetch_measurements( + url: str, + timeout_s: float, + expected_receiver_id: Optional[str] = None, +) -> List[Tuple[float, float]]: + source_label = f"source_url={url}" + req = request.Request(url=url, method="GET", headers={"Accept": "application/json"}) + try: + with request.urlopen(req, timeout=timeout_s) as response: + payload = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {exc.code} for '{url}': {body}") + except error.URLError as exc: + raise RuntimeError(f"Cannot reach '{url}': {exc.reason}") + except TimeoutError: + raise RuntimeError(f"Timeout while reading '{url}'") + except json.JSONDecodeError as exc: + raise RuntimeError(f"Invalid JSON from '{url}': {exc}") + try: + return parse_source_payload( + payload=payload, + source_label=source_label, + expected_receiver_id=expected_receiver_id, + ) + except ValueError as exc: + raise RuntimeError(str(exc)) from None + + +class AutoService: + def __init__(self, config: Dict[str, object], config_path: Optional[str] = None) -> None: + self.config = config + self.config_path = config_path + self.model = _parse_model(config) + + solver_obj = config.get("solver", {}) + runtime_obj = config.get("runtime", {}) + input_obj = config.get("input") + if not isinstance(solver_obj, dict): + raise ValueError("solver must be object.") + if not isinstance(runtime_obj, dict): + raise ValueError("runtime must be object.") + if not isinstance(input_obj, dict): + raise ValueError("input must be object.") + + self.tolerance = float(solver_obj.get("tolerance", 1e-3)) + self.z_preference = str(solver_obj.get("z_preference", "positive")) + if self.z_preference not in ("positive", "negative"): + raise ValueError("solver.z_preference must be 'positive' or 'negative'.") + + self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0)) + output_obj = runtime_obj.get("output_server", {}) + if output_obj is None: + output_obj = {} + if not isinstance(output_obj, dict): + raise ValueError("runtime.output_server must be object.") + + self.output_enabled = bool(output_obj.get("enabled", False)) + self.output_ip = str(output_obj.get("ip", "")) + self.output_port = int(output_obj.get("port", 8080)) + self.output_path = str(output_obj.get("path", "/triangulation")) + self.output_timeout_s = float(output_obj.get("timeout_s", 3.0)) + if self.output_enabled and not self.output_ip: + raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.") + + self.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0)) + self.aggregation = str(input_obj.get("aggregation", "median")) + if self.aggregation not in ("median", "mean"): + raise ValueError("input.aggregation must be 'median' or 'mean'.") + + input_mode = str(input_obj.get("mode", "http_sources")) + if input_mode != "http_sources": + raise ValueError("Automatic service requires input.mode = 'http_sources'.") + + receivers = input_obj.get("receivers") + if not isinstance(receivers, list) or len(receivers) != 3: + raise ValueError("input.receivers must contain exactly 3 objects.") + + parsed_receivers: List[Dict[str, object]] = [] + for receiver in receivers: + if not isinstance(receiver, dict): + raise ValueError("Each receiver must be object.") + parsed_receivers.append( + { + "receiver_id": str(receiver["receiver_id"]), + "center": _center_from_obj(receiver), + "source_url": str(receiver["source_url"]), + } + ) + self.receivers = parsed_receivers + + self.state_lock = threading.Lock() + self.latest_payload: Optional[Dict[str, object]] = None + self.last_error: str = "no data yet" + self.updated_at_utc: Optional[str] = None + self.last_output_delivery: Dict[str, object] = { + "enabled": self.output_enabled, + "status": "disabled" if not self.output_enabled else "pending", + "http_status": None, + "response_body": "", + "sent_at_utc": None, + } + + self.stop_event = threading.Event() + self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True) + + def start(self) -> None: + self.poll_thread.start() + + def stop(self) -> None: + self.stop_event.set() + self.poll_thread.join(timeout=2.0) + + def refresh_once(self) -> None: + spheres_all: List[Sphere] = [] + receiver_payloads: List[Dict[str, object]] = [] + grouped_by_receiver: List[Dict[float, List[Tuple[float, float]]]] = [] + + for receiver in self.receivers: + receiver_id = str(receiver["receiver_id"]) + center = receiver["center"] + source_url = str(receiver["source_url"]) + measurements = _fetch_measurements( + source_url, + timeout_s=self.source_timeout_s, + expected_receiver_id=receiver_id, + ) + grouped = _group_by_frequency(measurements) + grouped_by_receiver.append(grouped) + + radius_m = aggregate_radius(measurements, model=self.model, method=self.aggregation) + spheres_all.append(Sphere(center=center, radius=radius_m)) + + samples = [] + for frequency_hz, amplitude_dbm in measurements: + samples.append( + { + "frequency_hz": frequency_hz, + "amplitude_dbm": amplitude_dbm, + "distance_m": rssi_to_distance_m( + amplitude_dbm=amplitude_dbm, + frequency_hz=frequency_hz, + model=self.model, + ), + } + ) + + receiver_payloads.append( + { + "receiver_id": receiver_id, + "center": {"x": center[0], "y": center[1], "z": center[2]}, + "source_url": source_url, + "aggregation": self.aggregation, + "radius_m_all_freq": radius_m, + "samples": samples, + } + ) + + common_frequencies = ( + set(grouped_by_receiver[0].keys()) + & set(grouped_by_receiver[1].keys()) + & set(grouped_by_receiver[2].keys()) + ) + if not common_frequencies: + raise RuntimeError("No common frequencies across all 3 receivers.") + + frequency_rows: List[Dict[str, object]] = [] + best_row: Optional[Dict[str, object]] = None + for frequency_hz in sorted(common_frequencies): + spheres_for_frequency: List[Sphere] = [] + row_receivers: List[Dict[str, object]] = [] + + for index, receiver in enumerate(self.receivers): + center = receiver["center"] + measurement_subset = grouped_by_receiver[index][frequency_hz] + radius_m = aggregate_radius( + measurement_subset, model=self.model, method=self.aggregation + ) + spheres_for_frequency.append(Sphere(center=center, radius=radius_m)) + row_receivers.append( + { + "receiver_id": str(receiver["receiver_id"]), + "radius_m": radius_m, + "samples_count": len(measurement_subset), + } + ) + + result = solve_three_sphere_intersection( + spheres=spheres_for_frequency, + tolerance=self.tolerance, + z_preference=self.z_preference, # type: ignore[arg-type] + ) + for index, residual in enumerate(result.residuals): + row_receivers[index]["residual_m"] = residual + receiver_payloads[index].setdefault("per_frequency", []).append( + { + "frequency_hz": frequency_hz, + "radius_m": spheres_for_frequency[index].radius, + "residual_m": residual, + "samples_count": len(grouped_by_receiver[index][frequency_hz]), + } + ) + + row = { + "frequency_hz": frequency_hz, + "position": { + "x": result.point[0], + "y": result.point[1], + "z": result.point[2], + }, + "exact": result.exact, + "rmse_m": result.rmse, + "receivers": row_receivers, + } + frequency_rows.append(row) + if best_row is None or float(row["rmse_m"]) < float(best_row["rmse_m"]): + best_row = row + + if best_row is None: + raise RuntimeError("Cannot build frequency table for trilateration.") + + payload = { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "selected_frequency_hz": best_row["frequency_hz"], + "position": best_row["position"], + "exact": best_row["exact"], + "rmse_m": best_row["rmse_m"], + "frequency_table": frequency_rows, + "model": { + "tx_power_dbm": self.model.tx_power_dbm, + "tx_gain_dbi": self.model.tx_gain_dbi, + "rx_gain_dbi": self.model.rx_gain_dbi, + "path_loss_exponent": self.model.path_loss_exponent, + "reference_distance_m": self.model.reference_distance_m, + }, + "receivers": receiver_payloads, + } + + with self.state_lock: + self.latest_payload = payload + self.updated_at_utc = payload["timestamp_utc"] # type: ignore[index] + self.last_error = "" + + if self.output_enabled: + status_code, response_body = send_payload_to_server( + server_ip=self.output_ip, + payload=payload, + port=self.output_port, + path=self.output_path, + timeout_s=self.output_timeout_s, + ) + with self.state_lock: + self.last_output_delivery = { + "enabled": True, + "status": "ok" if 200 <= status_code < 300 else "error", + "http_status": status_code, + "response_body": response_body, + "sent_at_utc": datetime.now(timezone.utc).isoformat(), + "target": { + "ip": self.output_ip, + "port": self.output_port, + "path": self.output_path, + }, + } + if status_code < 200 or status_code >= 300: + raise RuntimeError( + "Output server rejected payload: " + f"HTTP {status_code}, body={response_body}" + ) + + def _poll_loop(self) -> None: + while not self.stop_event.is_set(): + try: + self.refresh_once() + except Exception as exc: + with self.state_lock: + self.last_error = str(exc) + self.stop_event.wait(self.poll_interval_s) + + def snapshot(self) -> Dict[str, object]: + with self.state_lock: + return { + "updated_at_utc": self.updated_at_utc, + "last_error": self.last_error, + "payload": self.latest_payload, + "output_delivery": self.last_output_delivery, + } + + +def _make_handler(service: AutoService): + class ServiceHandler(BaseHTTPRequestHandler): + def _write_bytes( + self, + status_code: int, + content: bytes, + content_type: str, + ) -> None: + self.send_response(status_code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def _write_json(self, status_code: int, payload: Dict[str, object]) -> None: + raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self._write_bytes( + status_code=status_code, + content=raw, + content_type="application/json; charset=utf-8", + ) + + def _write_static(self, relative_path: str) -> None: + web_root = Path(__file__).resolve().parent / "web" + file_path = (web_root / relative_path).resolve() + if not str(file_path).startswith(str(web_root.resolve())): + self._write_json(404, {"error": "not_found"}) + return + if not file_path.exists() or not file_path.is_file(): + self._write_json(404, {"error": "not_found"}) + return + + mime_type, _ = mimetypes.guess_type(str(file_path)) + if mime_type is None: + mime_type = "application/octet-stream" + self._write_bytes(200, file_path.read_bytes(), mime_type) + + def log_message(self, format: str, *args) -> None: + return + + def do_GET(self) -> None: + path = parse.urlparse(self.path).path + snapshot = service.snapshot() + + if path == "/" or path == "/ui": + self._write_static("index.html") + return + + if path.startswith("/static/"): + self._write_static(path.removeprefix("/static/")) + return + + if path == "/health": + status = "ok" if snapshot["payload"] else "warming_up" + http_code = 200 if status == "ok" else 503 + self._write_json( + http_code, + { + "status": status, + "updated_at_utc": snapshot["updated_at_utc"], + "error": snapshot["last_error"], + }, + ) + return + + if path == "/result": + payload = snapshot["payload"] + if payload is None: + self._write_json( + 503, + { + "status": "warming_up", + "updated_at_utc": snapshot["updated_at_utc"], + "error": snapshot["last_error"], + }, + ) + return + self._write_json( + 200, + { + "status": "ok", + "updated_at_utc": snapshot["updated_at_utc"], + "data": payload, + "output_delivery": snapshot["output_delivery"], + }, + ) + return + + if path == "/frequencies": + payload = snapshot["payload"] + if payload is None: + self._write_json( + 503, + { + "status": "warming_up", + "updated_at_utc": snapshot["updated_at_utc"], + "error": snapshot["last_error"], + }, + ) + return + self._write_json( + 200, + { + "status": "ok", + "updated_at_utc": snapshot["updated_at_utc"], + "selected_frequency_hz": payload.get("selected_frequency_hz"), + "frequency_table": payload.get("frequency_table", []), + "output_delivery": snapshot["output_delivery"], + }, + ) + return + + if path == "/config": + self._write_json( + 200, + { + "status": "ok", + "config_path": service.config_path, + "config": service.config, + }, + ) + return + + self._write_json(404, {"error": "not_found"}) + + def do_POST(self) -> None: + path = parse.urlparse(self.path).path + if path == "/config": + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._write_json(400, {"status": "error", "error": "Invalid Content-Length"}) + return + body = self.rfile.read(content_length) if content_length > 0 else b"" + try: + new_config = json.loads(body.decode("utf-8")) + except json.JSONDecodeError as exc: + self._write_json(400, {"status": "error", "error": f"Invalid JSON: {exc}"}) + return + if not isinstance(new_config, dict): + self._write_json(400, {"status": "error", "error": "Config must be JSON object"}) + return + + try: + AutoService(new_config) + except Exception as exc: + self._write_json( + 400, + {"status": "error", "error": f"Config validation failed: {exc}"}, + ) + return + + service.config = new_config + if service.config_path: + Path(service.config_path).write_text( + json.dumps(new_config, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + self._write_json( + 200, + { + "status": "ok", + "saved": bool(service.config_path), + "restart_required": True, + "config_path": service.config_path, + }, + ) + return + + if path != "/refresh": + self._write_json(404, {"error": "not_found"}) + return + + try: + service.refresh_once() + except Exception as exc: + self._write_json(500, {"status": "error", "error": str(exc)}) + return + + snapshot = service.snapshot() + self._write_json( + 200, + { + "status": "ok", + "updated_at_utc": snapshot["updated_at_utc"], + }, + ) + + return ServiceHandler + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Automatic trilateration service: polls 3 receiver servers and exposes result API." + ) + parser.add_argument("--config", type=str, default="config.json") + parser.add_argument("--host", type=str, default="") + parser.add_argument("--port", type=int, default=0) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + config = _load_json(args.config) + runtime = config.get("runtime", {}) + if not isinstance(runtime, dict): + raise SystemExit("runtime must be object.") + + host = args.host or str(runtime.get("listen_host", "0.0.0.0")) + port = args.port or int(runtime.get("listen_port", 8081)) + + service = AutoService(config, config_path=args.config) + service.start() + + server = ThreadingHTTPServer((host, port), _make_handler(service)) + print(f"service_listen: http://{host}:{port}") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + service.stop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.ps1 b/setup.ps1 new file mode 100644 index 0000000..c39d794 --- /dev/null +++ b/setup.ps1 @@ -0,0 +1,24 @@ +param( + [string]$VenvDir = ".venv" +) + +$ErrorActionPreference = "Stop" + +Write-Host "Creating virtual environment in '$VenvDir'..." +python -m venv $VenvDir + +$pythonExe = Join-Path $VenvDir "Scripts\python.exe" +if (-not (Test-Path $pythonExe)) { + throw "Python executable not found in virtual environment: $pythonExe" +} + +Write-Host "Upgrading pip..." +& $pythonExe -m pip install --upgrade pip + +Write-Host "Installing required packages..." +& $pythonExe -m pip install pytest + +Write-Host "" +Write-Host "Done." +Write-Host "Activate venv: .\$VenvDir\Scripts\Activate.ps1" +Write-Host "Run tests: pytest -q" diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..2f3eff5 --- /dev/null +++ b/setup.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +VENV_DIR="${1:-.venv}" + +if ! command -v python3 >/dev/null 2>&1; then + echo "[setup] python3 not found. Installing..." + sudo apt-get update + sudo apt-get install -y python3 python3-venv python3-pip +fi + +if ! dpkg -s python3-venv >/dev/null 2>&1; then + echo "[setup] Installing python3-venv..." + sudo apt-get update + sudo apt-get install -y python3-venv python3-pip +fi + +echo "[setup] Creating virtual environment: ${VENV_DIR}" +python3 -m venv "${VENV_DIR}" + +PYTHON_BIN="${VENV_DIR}/bin/python" + +echo "[setup] Upgrading pip" +"${PYTHON_BIN}" -m pip install --upgrade pip + +echo "[setup] Installing required Python packages" +"${PYTHON_BIN}" -m pip install pytest + +echo "[setup] Done" +echo "Activate: source ${VENV_DIR}/bin/activate" +echo "Run tests: pytest -q" diff --git a/test_service_integration.py b/test_service_integration.py new file mode 100644 index 0000000..e9e5298 --- /dev/null +++ b/test_service_integration.py @@ -0,0 +1,207 @@ +import json +from typing import Any, Dict, List +from urllib import error + +import pytest + +import service + + +class _FakeResponse: + def __init__(self, payload: object, status: int = 200): + self._raw = json.dumps(payload).encode("utf-8") + self.status = status + + def read(self) -> bytes: + return self._raw + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def _base_config() -> Dict[str, object]: + return { + "model": { + "tx_power_dbm": 20.0, + "tx_gain_dbi": 0.0, + "rx_gain_dbi": 0.0, + "path_loss_exponent": 2.0, + "reference_distance_m": 1.0, + "min_distance_m": 0.001, + }, + "solver": {"tolerance": 0.001, "z_preference": "positive"}, + "runtime": { + "poll_interval_s": 1.0, + "output_server": { + "enabled": False, + "ip": "192.168.1.10", + "port": 8080, + "path": "/triangulation", + "timeout_s": 3.0, + }, + }, + "input": { + "mode": "http_sources", + "aggregation": "median", + "source_timeout_s": 1.0, + "receivers": [ + { + "receiver_id": "r0", + "center": {"x": 0.0, "y": 0.0, "z": 0.0}, + "source_url": "http://r0.local/measurements", + }, + { + "receiver_id": "r1", + "center": {"x": 10.0, "y": 0.0, "z": 0.0}, + "source_url": "http://r1.local/measurements", + }, + { + "receiver_id": "r2", + "center": {"x": 0.0, "y": 8.0, "z": 0.0}, + "source_url": "http://r2.local/measurements", + }, + ], + }, + } + + +def _install_urlopen(monkeypatch: pytest.MonkeyPatch, responses: Dict[str, object]) -> None: + def _fake_urlopen(req: Any, timeout: float = 0.0): + url = getattr(req, "full_url", str(req)) + payload_or_exc = responses[url] + if isinstance(payload_or_exc, Exception): + raise payload_or_exc + return _FakeResponse(payload_or_exc) + + monkeypatch.setattr(service.request, "urlopen", _fake_urlopen) + + +def test_refresh_once_builds_frequency_table_for_common_frequencies( + monkeypatch: pytest.MonkeyPatch, +): + config = _base_config() + freq_a = 433_920_000.0 + freq_b = 868_100_000.0 + responses = { + "http://r0.local/measurements": { + "receiver_id": "r0", + "measurements": [ + {"frequency_hz": freq_a, "rssi_dbm": -61.0}, + {"frequency_hz": freq_b, "rssi_dbm": -68.0}, + ], + }, + "http://r1.local/measurements": { + "receiver_id": "r1", + "measurements": [ + {"frequency_hz": freq_a, "rssi_dbm": -64.0}, + {"frequency_hz": freq_b, "rssi_dbm": -70.0}, + ], + }, + "http://r2.local/measurements": { + "receiver_id": "r2", + "measurements": [ + {"frequency_hz": freq_a, "rssi_dbm": -63.0}, + {"frequency_hz": freq_b, "rssi_dbm": -69.0}, + ], + }, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + svc.refresh_once() + snapshot = svc.snapshot() + payload = snapshot["payload"] + + assert snapshot["last_error"] == "" + assert payload is not None + assert payload["selected_frequency_hz"] in (freq_a, freq_b) + table = payload["frequency_table"] + assert isinstance(table, list) + assert len(table) == 2 + for row in table: + assert row["frequency_hz"] in (freq_a, freq_b) + assert "position" in row + assert len(row["receivers"]) == 3 + + +def test_refresh_once_fails_when_no_common_frequencies(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 100.0, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 200.0, "rssi_dbm": -60.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 300.0, "rssi_dbm": -60.0}]}, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match="No common frequencies across all 3 receivers"): + svc.refresh_once() + + +def test_refresh_once_reports_row_validation_error_with_source_context( + monkeypatch: pytest.MonkeyPatch, +): + config = _base_config() + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": "bad"}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]}, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match=r"source_url=http://r1\.local/measurements: row #1 field 'rssi_dbm' must be numeric"): + svc.refresh_once() + + +def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + responses = { + "http://r0.local/measurements": {"receiver_id": "r0", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"receiver_id": "WRONG", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -59.0}]}, + "http://r2.local/measurements": {"receiver_id": "r2", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]}, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match="does not match expected 'r1'"): + svc.refresh_once() + + +def test_refresh_once_raises_when_output_server_rejects_payload( + monkeypatch: pytest.MonkeyPatch, +): + config = _base_config() + config["runtime"]["output_server"]["enabled"] = True # type: ignore[index] + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -59.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]}, + } + _install_urlopen(monkeypatch, responses) + monkeypatch.setattr( + service, + "send_payload_to_server", + lambda **_: (500, "internal error"), + ) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match="Output server rejected payload: HTTP 500"): + svc.refresh_once() + + +def test_refresh_once_propagates_source_http_error(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": error.URLError("connection refused"), + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]}, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match="Cannot reach 'http://r1.local/measurements': connection refused"): + svc.refresh_once() diff --git a/test_triangulation.py b/test_triangulation.py new file mode 100644 index 0000000..4c62dfa --- /dev/null +++ b/test_triangulation.py @@ -0,0 +1,84 @@ +import math + +from triangulation import ( + PropagationModel, + ReceiverSignal, + SPEED_OF_LIGHT_M_S, + build_result_payload, + rssi_to_distance_m, + solve_from_signal_amplitudes, + solve_three_sphere_intersection, + Sphere, +) + + +def _distance_to_rssi(distance_m: float, frequency_hz: float, model: PropagationModel) -> float: + fspl_ref_db = 20.0 * math.log10( + 4.0 * math.pi * model.reference_distance_m * frequency_hz / SPEED_OF_LIGHT_M_S + ) + rx_power_at_ref_dbm = ( + model.tx_power_dbm + model.tx_gain_dbi + model.rx_gain_dbi - fspl_ref_db + ) + return rx_power_at_ref_dbm - 10.0 * model.path_loss_exponent * math.log10( + distance_m / model.reference_distance_m + ) + + +def test_exact_three_sphere_intersection(): + true_point = (2.0, 3.0, 4.0) + spheres = [ + Sphere(center=(0.0, 0.0, 0.0), radius=math.dist(true_point, (0.0, 0.0, 0.0))), + Sphere(center=(10.0, 0.0, 0.0), radius=math.dist(true_point, (10.0, 0.0, 0.0))), + Sphere(center=(0.0, 8.0, 0.0), radius=math.dist(true_point, (0.0, 8.0, 0.0))), + ] + + result = solve_three_sphere_intersection(spheres=spheres, z_preference="positive") + assert result.exact + assert math.isclose(result.point[0], true_point[0], abs_tol=1e-9, rel_tol=0.0) + assert math.isclose(result.point[1], true_point[1], abs_tol=1e-9, rel_tol=0.0) + assert math.isclose(result.point[2], true_point[2], abs_tol=1e-9, rel_tol=0.0) + + +def test_frequency_affects_distance_conversion(): + model = PropagationModel(tx_power_dbm=20.0) + amplitude = -60.0 + + low_freq_distance = rssi_to_distance_m(amplitude, 433e6, model) + high_freq_distance = rssi_to_distance_m(amplitude, 2.4e9, model) + assert high_freq_distance < low_freq_distance + + +def test_pipeline_from_signal_to_payload(): + model = PropagationModel(tx_power_dbm=18.0, path_loss_exponent=2.0) + true_point = (3.0, 2.0, 1.5) + receiver_centers = [ + ("r0", (0.0, 0.0, 0.0)), + ("r1", (8.0, 0.0, 0.0)), + ("r2", (0.0, 7.0, 0.0)), + ] + freqs = [915e6, 920e6, 930e6] + + signals = [] + for (receiver_id, center), freq in zip(receiver_centers, freqs): + distance = math.dist(true_point, center) + amplitude = _distance_to_rssi(distance, freq, model) + signals.append( + ReceiverSignal( + receiver_id=receiver_id, + center=center, + amplitude_dbm=amplitude, + frequency_hz=freq, + ) + ) + + result, spheres = solve_from_signal_amplitudes( + signals=signals, model=model, z_preference="positive" + ) + payload = build_result_payload(signals, spheres, result, model) + + assert result.exact + assert math.isclose(result.point[0], true_point[0], abs_tol=1e-7, rel_tol=0.0) + assert math.isclose(result.point[1], true_point[1], abs_tol=1e-7, rel_tol=0.0) + assert math.isclose(result.point[2], true_point[2], abs_tol=1e-7, rel_tol=0.0) + assert "position" in payload + assert len(payload["receivers"]) == 3 diff --git a/triangulation.py b/triangulation.py new file mode 100644 index 0000000..7e95166 --- /dev/null +++ b/triangulation.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import math +from dataclasses import dataclass +from typing import Dict, Iterable, List, Literal, Optional, Sequence, Tuple +from urllib import error, request +import json + + +Point3D = Tuple[float, float, float] +SPEED_OF_LIGHT_M_S = 299_792_458.0 + + +@dataclass(frozen=True) +class Sphere: + center: Point3D + radius: float + + +@dataclass(frozen=True) +class PropagationModel: + tx_power_dbm: float + tx_gain_dbi: float = 0.0 + rx_gain_dbi: float = 0.0 + path_loss_exponent: float = 2.0 + reference_distance_m: float = 1.0 + min_distance_m: float = 1e-3 + + +@dataclass(frozen=True) +class ReceiverSignal: + receiver_id: str + center: Point3D + amplitude_dbm: float + frequency_hz: float + + +@dataclass(frozen=True) +class TrilaterationResult: + point: Point3D + residuals: Tuple[float, float, float] + rmse: float + exact: bool + candidate_points: Tuple[Point3D, ...] + + +def _validate(spheres: Sequence[Sphere]) -> None: + if len(spheres) != 3: + raise ValueError("Expected exactly 3 spheres.") + + for idx, sphere in enumerate(spheres, start=1): + if sphere.radius < 0: + raise ValueError(f"Radius for sphere #{idx} must be non-negative.") + + +def _vec_sub(a: Point3D, b: Point3D) -> Point3D: + return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) + + +def _vec_add(a: Point3D, b: Point3D) -> Point3D: + return (a[0] + b[0], a[1] + b[1], a[2] + b[2]) + + +def _vec_scale(a: Point3D, scale: float) -> Point3D: + return (a[0] * scale, a[1] * scale, a[2] * scale) + + +def _vec_dot(a: Point3D, b: Point3D) -> float: + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def _vec_cross(a: Point3D, b: Point3D) -> Point3D: + return ( + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ) + + +def _vec_norm(a: Point3D) -> float: + return math.sqrt(_vec_dot(a, a)) + + +def _vec_unit(a: Point3D) -> Point3D: + n = _vec_norm(a) + if math.isclose(n, 0.0, abs_tol=1e-12): + raise ValueError("Degenerate receiver geometry: duplicated centers.") + return _vec_scale(a, 1.0 / n) + + +def _distance(p1: Point3D, p2: Point3D) -> float: + d = _vec_sub(p1, p2) + return _vec_norm(d) + + +def rssi_to_distance_m( + amplitude_dbm: float, frequency_hz: float, model: PropagationModel +) -> float: + if frequency_hz <= 0.0: + raise ValueError("Frequency must be positive.") + if model.reference_distance_m <= 0.0: + raise ValueError("reference_distance_m must be positive.") + if model.path_loss_exponent <= 0.0: + raise ValueError("path_loss_exponent must be positive.") + + fspl_ref_db = 20.0 * math.log10( + 4.0 * math.pi * model.reference_distance_m * frequency_hz / SPEED_OF_LIGHT_M_S + ) + rx_power_at_ref_dbm = ( + model.tx_power_dbm + model.tx_gain_dbi + model.rx_gain_dbi - fspl_ref_db + ) + distance = model.reference_distance_m * 10.0 ** ( + (rx_power_at_ref_dbm - amplitude_dbm) / (10.0 * model.path_loss_exponent) + ) + return max(distance, model.min_distance_m) + + +def _spheres_from_signals( + signals: Sequence[ReceiverSignal], model: PropagationModel +) -> List[Sphere]: + if len(signals) != 3: + raise ValueError("Expected exactly 3 receiver signals.") + + spheres: List[Sphere] = [] + for signal in signals: + radius = rssi_to_distance_m( + amplitude_dbm=signal.amplitude_dbm, + frequency_hz=signal.frequency_hz, + model=model, + ) + spheres.append(Sphere(center=signal.center, radius=radius)) + return spheres + + +def _sphere_intersection_candidates(spheres: Sequence[Sphere]) -> Tuple[Point3D, ...]: + p1, p2, p3 = spheres[0].center, spheres[1].center, spheres[2].center + r1, r2, r3 = spheres[0].radius, spheres[1].radius, spheres[2].radius + + ex = _vec_unit(_vec_sub(p2, p1)) + p3p1 = _vec_sub(p3, p1) + i = _vec_dot(ex, p3p1) + temp = _vec_sub(p3p1, _vec_scale(ex, i)) + ey = _vec_unit(temp) + ez = _vec_cross(ex, ey) + d = _distance(p1, p2) + j = _vec_dot(ey, p3p1) + if math.isclose(j, 0.0, abs_tol=1e-12): + raise ValueError("Degenerate receiver geometry: centers are collinear.") + + x = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d) + y = (r1 * r1 - r3 * r3 + i * i + j * j) / (2.0 * j) - (i / j) * x + z_sq = r1 * r1 - x * x - y * y + + base = _vec_add(p1, _vec_add(_vec_scale(ex, x), _vec_scale(ey, y))) + if z_sq < 0.0: + return (base,) + + z = math.sqrt(z_sq) + return ( + _vec_add(base, _vec_scale(ez, z)), + _vec_add(base, _vec_scale(ez, -z)), + ) + + +def _solve_3x3(a: List[List[float]], b: List[float]) -> Optional[List[float]]: + m = [row[:] + [rhs] for row, rhs in zip(a, b)] + n = 3 + for col in range(n): + pivot = max(range(col, n), key=lambda r: abs(m[r][col])) + if math.isclose(m[pivot][col], 0.0, abs_tol=1e-12): + return None + if pivot != col: + m[col], m[pivot] = m[pivot], m[col] + + pivot_value = m[col][col] + for k in range(col, n + 1): + m[col][k] /= pivot_value + + for row in range(n): + if row == col: + continue + factor = m[row][col] + for k in range(col, n + 1): + m[row][k] -= factor * m[col][k] + + return [m[0][n], m[1][n], m[2][n]] + + +def _gauss_newton_point( + spheres: Sequence[Sphere], initial_point: Point3D, iterations: int = 40 +) -> Point3D: + x, y, z = initial_point + damping = 1e-6 + + for _ in range(iterations): + residuals: List[float] = [] + j_rows: List[Point3D] = [] + for sphere in spheres: + cx, cy, cz = sphere.center + dx = x - cx + dy = y - cy + dz = z - cz + dist = math.sqrt(dx * dx + dy * dy + dz * dz) + if dist < 1e-9: + dist = 1e-9 + residuals.append(dist - sphere.radius) + j_rows.append((dx / dist, dy / dist, dz / dist)) + + jt_j = [[0.0, 0.0, 0.0] for _ in range(3)] + jt_r = [0.0, 0.0, 0.0] + for (jx, jy, jz), r in zip(j_rows, residuals): + jt_j[0][0] += jx * jx + jt_j[0][1] += jx * jy + jt_j[0][2] += jx * jz + jt_j[1][0] += jy * jx + jt_j[1][1] += jy * jy + jt_j[1][2] += jy * jz + jt_j[2][0] += jz * jx + jt_j[2][1] += jz * jy + jt_j[2][2] += jz * jz + jt_r[0] += jx * r + jt_r[1] += jy * r + jt_r[2] += jz * r + + for i in range(3): + jt_j[i][i] += damping + + delta = _solve_3x3(jt_j, [-jt_r[0], -jt_r[1], -jt_r[2]]) + if delta is None: + break + + x += delta[0] + y += delta[1] + z += delta[2] + + if max(abs(delta[0]), abs(delta[1]), abs(delta[2])) < 1e-8: + break + + return (x, y, z) + + +def _residuals(point: Point3D, spheres: Sequence[Sphere]) -> Tuple[float, float, float]: + values = tuple(_distance(point, sphere.center) - sphere.radius for sphere in spheres) + return (values[0], values[1], values[2]) + + +def _rmse(residuals: Iterable[float]) -> float: + residual_list = list(residuals) + return math.sqrt(sum(r * r for r in residual_list) / len(residual_list)) + + +def solve_three_sphere_intersection( + spheres: Sequence[Sphere], + tolerance: float = 1e-3, + z_preference: Literal["positive", "negative"] = "positive", +) -> TrilaterationResult: + _validate(spheres) + + try: + analytic_candidates = list(_sphere_intersection_candidates(spheres)) + except ValueError: + analytic_candidates = [] + + all_candidates = analytic_candidates[:] + center_guess = ( + (spheres[0].center[0] + spheres[1].center[0] + spheres[2].center[0]) / 3.0, + (spheres[0].center[1] + spheres[1].center[1] + spheres[2].center[1]) / 3.0, + (spheres[0].center[2] + spheres[1].center[2] + spheres[2].center[2]) / 3.0, + ) + all_candidates.append(_gauss_newton_point(spheres, center_guess)) + if analytic_candidates: + all_candidates.append(_gauss_newton_point(spheres, analytic_candidates[0])) + if len(analytic_candidates) == 2: + all_candidates.append(_gauss_newton_point(spheres, analytic_candidates[1])) + + scored: List[Tuple[float, float, Point3D, Tuple[float, float, float]]] = [] + for point in all_candidates: + current_residuals = _residuals(point, spheres) + score_rmse = _rmse(current_residuals) + z_bias = -point[2] if z_preference == "positive" else point[2] + scored.append((score_rmse, z_bias, point, current_residuals)) + scored.sort(key=lambda x: (x[0], x[1])) + + best_rmse, _, best_point, best_residuals = scored[0] + exact = all(abs(r) <= tolerance for r in best_residuals) + return TrilaterationResult( + point=best_point, + residuals=best_residuals, + rmse=best_rmse, + exact=exact, + candidate_points=tuple(p for _, _, p, _ in scored), + ) + + +def solve_from_signal_amplitudes( + signals: Sequence[ReceiverSignal], + model: PropagationModel, + tolerance: float = 1e-3, + z_preference: Literal["positive", "negative"] = "positive", +) -> Tuple[TrilaterationResult, List[Sphere]]: + spheres = _spheres_from_signals(signals, model) + result = solve_three_sphere_intersection( + spheres=spheres, tolerance=tolerance, z_preference=z_preference + ) + return result, spheres + + +def build_result_payload( + signals: Sequence[ReceiverSignal], + spheres: Sequence[Sphere], + result: TrilaterationResult, + model: PropagationModel, +) -> Dict[str, object]: + receivers = [] + for signal, sphere, residual in zip(signals, spheres, result.residuals): + receivers.append( + { + "receiver_id": signal.receiver_id, + "center": { + "x": signal.center[0], + "y": signal.center[1], + "z": signal.center[2], + }, + "frequency_hz": signal.frequency_hz, + "amplitude_dbm": signal.amplitude_dbm, + "radius_m": sphere.radius, + "residual_m": residual, + } + ) + + return { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "position": {"x": result.point[0], "y": result.point[1], "z": result.point[2]}, + "exact": result.exact, + "rmse_m": result.rmse, + "model": { + "tx_power_dbm": model.tx_power_dbm, + "tx_gain_dbi": model.tx_gain_dbi, + "rx_gain_dbi": model.rx_gain_dbi, + "path_loss_exponent": model.path_loss_exponent, + "reference_distance_m": model.reference_distance_m, + }, + "receivers": receivers, + } + + +def send_payload_to_server( + server_ip: str, + payload: Dict[str, object], + port: int = 8080, + path: str = "/triangulation", + timeout_s: float = 3.0, +) -> Tuple[int, str]: + if not path.startswith("/"): + path = "/" + path + url = f"http://{server_ip}:{port}{path}" + data = json.dumps(payload).encode("utf-8") + req = request.Request( + url=url, + data=data, + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with request.urlopen(req, timeout=timeout_s) as response: + body = response.read().decode("utf-8", errors="replace") + return response.status, body + except error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + return exc.code, body + except error.URLError as exc: + return 0, str(exc.reason) + + +def solve_and_prepare_payload( + signals: Sequence[ReceiverSignal], + model: PropagationModel, + tolerance: float = 1e-3, + z_preference: Literal["positive", "negative"] = "positive", +) -> Tuple[TrilaterationResult, Dict[str, object]]: + result, spheres = solve_from_signal_amplitudes( + signals=signals, + model=model, + tolerance=tolerance, + z_preference=z_preference, + ) + payload = build_result_payload( + signals=signals, + spheres=spheres, + result=result, + model=model, + ) + return result, payload diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..5ffcd66 --- /dev/null +++ b/web/app.js @@ -0,0 +1,140 @@ +const state = { + result: null, + frequencies: null, + health: null, +}; + +function byId(id) { + return document.getElementById(id); +} + +function fmt(value, digits = 6) { + if (value === null || value === undefined) return "-"; + if (typeof value !== "number") return String(value); + return Number.isFinite(value) ? value.toFixed(digits) : String(value); +} + +async function getJson(url) { + const res = await fetch(url); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || data.status || `HTTP ${res.status}`); + } + return data; +} + +async function postJson(url, payload) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || data.status || `HTTP ${res.status}`); + } + return data; +} + +function render() { + const data = state.result?.data; + const delivery = state.result?.output_delivery || state.frequencies?.output_delivery; + byId("updated-at").textContent = `updated: ${state.result?.updated_at_utc || "n/a"}`; + byId("health-status").textContent = `health: ${state.health?.status || "n/a"}`; + byId("delivery-status").textContent = `delivery: ${delivery?.status || "n/a"}`; + + if (!data) { + byId("selected-freq").textContent = "-"; + byId("pos-x").textContent = "-"; + byId("pos-y").textContent = "-"; + byId("pos-z").textContent = "-"; + byId("rmse").textContent = "-"; + byId("receivers-list").textContent = "Нет данных"; + byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); + byId("freq-table").querySelector("tbody").innerHTML = ""; + return; + } + + byId("selected-freq").textContent = fmt(data.selected_frequency_hz, 1); + byId("pos-x").textContent = fmt(data.position?.x); + byId("pos-y").textContent = fmt(data.position?.y); + byId("pos-z").textContent = fmt(data.position?.z); + byId("rmse").textContent = fmt(data.rmse_m); + + const receivers = data.receivers || []; + byId("receivers-list").textContent = JSON.stringify(receivers, null, 2); + byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); + + const rows = data.frequency_table || []; + const tbody = byId("freq-table").querySelector("tbody"); + tbody.innerHTML = rows + .map( + (row) => ` + + ${fmt(row.frequency_hz, 1)} + ${fmt(row.position?.x)} + ${fmt(row.position?.y)} + ${fmt(row.position?.z)} + ${fmt(row.rmse_m)} + ${row.exact ? "yes" : "no"} + ` + ) + .join(""); +} + +async function loadAll() { + const [healthRes, resultRes, freqRes] = await Promise.allSettled([ + getJson("/health"), + getJson("/result"), + getJson("/frequencies"), + ]); + state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" }; + state.result = resultRes.status === "fulfilled" ? resultRes.value : null; + state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null; + render(); +} + +async function refreshNow() { + await postJson("/refresh", {}); + await loadAll(); +} + +async function loadConfig() { + try { + const config = await getJson("/config"); + byId("config-editor").value = JSON.stringify(config.config, null, 2); + byId("config-state").textContent = "config: loaded"; + } catch (err) { + byId("config-state").textContent = `config: ${err.message}`; + } +} + +async function saveConfig() { + const raw = byId("config-editor").value.trim(); + try { + const parsed = JSON.parse(raw); + const result = await postJson("/config", parsed); + byId("config-state").textContent = result.restart_required + ? "config: saved, restart required" + : "config: saved"; + } catch (err) { + byId("config-state").textContent = `config: ${err.message}`; + } +} + +function bindUi() { + byId("refresh-now").addEventListener("click", refreshNow); + byId("load-config").addEventListener("click", loadConfig); + byId("save-config").addEventListener("click", saveConfig); +} + +async function boot() { + bindUi(); + await loadConfig(); + await loadAll(); + setInterval(loadAll, 2000); +} + +boot().catch((err) => { + byId("health-status").textContent = `health: ${err.message}`; +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..760d9bf --- /dev/null +++ b/web/index.html @@ -0,0 +1,86 @@ + + + + + + Triangulation Control Panel + + + +
+
+
+
+

Triangulation

+

RF Positioning Dashboard

+

+ Автоматический мониторинг входящих измерений и результатов пересечения + 3 сфер по общим частотам. +

+
+ + updated: n/a + health: n/a + delivery: n/a +
+
+ +
+
+

Итоговая позиция

+
+
Selected Freq: -
+
X: -
+
Y: -
+
Z: -
+
RMSE: -
+
+
+ +
+

Ресиверы

+
+
+
+ +
+

Отправка на конечный сервер

+
+
+ +
+

Таблица пересечений по частотам

+
+ + + + + + + + + + + + +
Frequency (Hz)XYZRMSEExact
+
+
+ +
+

Конфигурация

+

+ Изменения сохраняются в конфиг-файл сервиса. После сохранения нужен + перезапуск для применения. +

+
+ + + config: n/a +
+ +
+
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..769cc92 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,201 @@ +:root { + --bg: #f4f6f8; + --card: #ffffff; + --text: #101418; + --muted: #5b6872; + --line: #dbe2e8; + --accent: #0e6e6b; + --accent-soft: #d9f2f1; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", "Noto Sans", sans-serif; + color: var(--text); + background: radial-gradient(circle at 20% 20%, #eef7ff 0%, var(--bg) 45%), + linear-gradient(180deg, #f8fbff, #f4f6f8); + min-height: 100vh; +} + +.container { + width: min(1100px, 94vw); + margin: 32px auto; + display: grid; + gap: 18px; + position: relative; + z-index: 2; +} + +.card { + background: color-mix(in oklab, var(--card), transparent 8%); + border: 1px solid var(--line); + border-radius: 16px; + padding: 18px; + box-shadow: 0 12px 30px rgba(16, 20, 24, 0.06); + animation: rise 450ms ease both; +} + +.hero h1 { + margin: 6px 0; + font-size: clamp(1.2rem, 3vw, 1.9rem); +} + +.kicker { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent); + font-weight: 700; + font-size: 0.78rem; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; +} + +.hero-actions, +.editor-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; +} + +.btn { + border: 1px solid var(--line); + background: #fff; + color: var(--text); + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + transition: transform 150ms ease, background-color 150ms ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.badge { + border: 1px solid var(--line); + background: var(--accent-soft); + border-radius: 999px; + padding: 4px 10px; + font-size: 0.82rem; +} + +.result-box { + display: grid; + gap: 7px; +} + +.muted { + color: var(--muted); +} + +.small { + font-size: 0.86rem; +} + +.mono { + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + text-align: left; + padding: 8px; + border-bottom: 1px solid var(--line); + font-size: 0.9rem; +} + +tbody tr { + transition: background-color 180ms ease; +} + +tbody tr:hover { + background: #f5fbfb; +} + +.editor { + width: 100%; + min-height: 280px; + border: 1px solid var(--line); + border-radius: 12px; + padding: 10px; + background: #fbfdff; + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-size: 0.85rem; + margin-top: 10px; +} + +.bg-glow { + position: fixed; + width: 360px; + height: 360px; + border-radius: 50%; + filter: blur(48px); + opacity: 0.42; + pointer-events: none; + z-index: 1; + animation: drift 12s ease-in-out infinite alternate; +} + +.bg-glow-a { + background: #a2e9db; + top: -90px; + right: -70px; +} + +.bg-glow-b { + background: #c4dcff; + bottom: -120px; + left: -90px; + animation-delay: 1.4s; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(7px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes drift { + from { + transform: translate(0, 0) scale(1); + } + to { + transform: translate(30px, -15px) scale(1.12); + } +} + +@media (max-width: 800px) { + .grid { + grid-template-columns: 1fr; + } +}