test_version_in_local
commit
820fe674ea
@ -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"]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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())
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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"]
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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"
|
||||
@ -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"
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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) => `
|
||||
<tr>
|
||||
<td>${fmt(row.frequency_hz, 1)}</td>
|
||||
<td>${fmt(row.position?.x)}</td>
|
||||
<td>${fmt(row.position?.y)}</td>
|
||||
<td>${fmt(row.position?.z)}</td>
|
||||
<td>${fmt(row.rmse_m)}</td>
|
||||
<td>${row.exact ? "yes" : "no"}</td>
|
||||
</tr>`
|
||||
)
|
||||
.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}`;
|
||||
});
|
||||
@ -0,0 +1,86 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Triangulation Control Panel</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-glow bg-glow-a"></div>
|
||||
<div class="bg-glow bg-glow-b"></div>
|
||||
<main class="container">
|
||||
<header class="hero card">
|
||||
<p class="kicker">Triangulation</p>
|
||||
<h1>RF Positioning Dashboard</h1>
|
||||
<p class="muted">
|
||||
Автоматический мониторинг входящих измерений и результатов пересечения
|
||||
3 сфер по общим частотам.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<button id="refresh-now" class="btn btn-primary">Refresh</button>
|
||||
<span id="updated-at" class="badge">updated: n/a</span>
|
||||
<span id="health-status" class="badge">health: n/a</span>
|
||||
<span id="delivery-status" class="badge">delivery: n/a</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card">
|
||||
<h2>Итоговая позиция</h2>
|
||||
<div class="result-box">
|
||||
<div><span class="muted">Selected Freq:</span> <b id="selected-freq">-</b></div>
|
||||
<div><span class="muted">X:</span> <b id="pos-x">-</b></div>
|
||||
<div><span class="muted">Y:</span> <b id="pos-y">-</b></div>
|
||||
<div><span class="muted">Z:</span> <b id="pos-z">-</b></div>
|
||||
<div><span class="muted">RMSE:</span> <b id="rmse">-</b></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Ресиверы</h2>
|
||||
<div id="receivers-list" class="mono small"></div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Отправка на конечный сервер</h2>
|
||||
<div id="delivery-details" class="mono small"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Таблица пересечений по частотам</h2>
|
||||
<div class="table-wrap">
|
||||
<table id="freq-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Frequency (Hz)</th>
|
||||
<th>X</th>
|
||||
<th>Y</th>
|
||||
<th>Z</th>
|
||||
<th>RMSE</th>
|
||||
<th>Exact</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Конфигурация</h2>
|
||||
<p class="muted">
|
||||
Изменения сохраняются в конфиг-файл сервиса. После сохранения нужен
|
||||
перезапуск для применения.
|
||||
</p>
|
||||
<div class="editor-actions">
|
||||
<button id="load-config" class="btn">Load</button>
|
||||
<button id="save-config" class="btn btn-primary">Save</button>
|
||||
<span id="config-state" class="badge">config: n/a</span>
|
||||
</div>
|
||||
<textarea id="config-editor" class="editor" spellcheck="false"></textarea>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue