commit 820fe674eaae2cd6e4b2ca349e0c18692c94db49 Author: AlexsandrSnytkin Date: Thu Feb 26 23:13:01 2026 +0700 test_version_in_local 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 0000000..3ea6cfa Binary files /dev/null and b/__pycache__/service.cpython-311.pyc differ diff --git a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc new file mode 100644 index 0000000..a1b2d17 Binary files /dev/null and b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc differ diff --git a/__pycache__/test_triangulation.cpython-311-pytest-8.2.2.pyc b/__pycache__/test_triangulation.cpython-311-pytest-8.2.2.pyc new file mode 100644 index 0000000..11b1475 Binary files /dev/null and b/__pycache__/test_triangulation.cpython-311-pytest-8.2.2.pyc differ diff --git a/__pycache__/triangulation.cpython-311.pyc b/__pycache__/triangulation.cpython-311.pyc new file mode 100644 index 0000000..da514b1 Binary files /dev/null and b/__pycache__/triangulation.cpython-311.pyc differ 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; + } +}