You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
411 lines
14 KiB
Python
411 lines
14 KiB
Python
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())
|