mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-29 21:13:37 +02:00
feat: enhance CAD configuration with dynamic symbol number handling and update OpenAPI documentation
This commit is contained in:
@@ -2181,15 +2181,35 @@ class APIEndpoints:
|
||||
return self._error("No configuration updates provided")
|
||||
|
||||
# Use ConfigManager to update and save configuration
|
||||
# Web changes (CORS, web_path) don't require live update
|
||||
# Persist web changes first, then apply to running HTTP server.
|
||||
result = self.config_manager.update_and_save(updates=updates, live_update=False)
|
||||
|
||||
if result.get("success"):
|
||||
live_applied = False
|
||||
frontend_switched = False
|
||||
app = (
|
||||
getattr(getattr(self.daemon_instance, "http_server", None), "app", None)
|
||||
if self.daemon_instance
|
||||
else None
|
||||
)
|
||||
if app and hasattr(app, "apply_web_config"):
|
||||
try:
|
||||
frontend_switched = bool(app.apply_web_config())
|
||||
live_applied = True
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to apply web config live: %s", exc)
|
||||
logger.info(f"Web configuration updated: {list(updates.keys())}")
|
||||
return self._success(
|
||||
{
|
||||
"persisted": result.get("saved", False),
|
||||
"message": "Web configuration saved successfully. Restart required for changes to take effect.",
|
||||
"live_applied": live_applied,
|
||||
"frontend_switched": frontend_switched,
|
||||
"restart_required": not live_applied,
|
||||
"message": (
|
||||
"Web configuration applied immediately."
|
||||
if live_applied
|
||||
else "Web configuration saved. Restart required for changes to take effect."
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -3569,7 +3589,21 @@ class APIEndpoints:
|
||||
samples = data.get("samples", 8)
|
||||
delay = data.get("delay", 100)
|
||||
known_signal_present = data.get("known_signal_present", False)
|
||||
cad_symbol_num = data.get("cad_symbol_num", 2)
|
||||
radio = getattr(self.daemon_instance, "radio", None) if self.daemon_instance else None
|
||||
default_cad_symbol_num = (
|
||||
self.config.get("radio", {}).get("cad", {}).get("symbol_num", 2)
|
||||
)
|
||||
radio_symbol_num = getattr(radio, "_custom_cad_symbol_num", None) if radio else None
|
||||
if radio_symbol_num in {1, 2, 4, 8, 16}:
|
||||
default_cad_symbol_num = radio_symbol_num
|
||||
try:
|
||||
default_cad_symbol_num = int(default_cad_symbol_num)
|
||||
except (TypeError, ValueError):
|
||||
default_cad_symbol_num = 2
|
||||
if default_cad_symbol_num not in {1, 2, 4, 8, 16}:
|
||||
default_cad_symbol_num = 2
|
||||
|
||||
cad_symbol_num = data.get("cad_symbol_num", default_cad_symbol_num)
|
||||
cad_timeout_ms = data.get("cad_timeout_ms", 500)
|
||||
self.cad_calibration.session_config = {
|
||||
"known_signal_present": known_signal_present,
|
||||
@@ -3618,6 +3652,15 @@ class APIEndpoints:
|
||||
return self._error("Radio CAD support is not available")
|
||||
if self.event_loop is None:
|
||||
return self._error("Event loop not available")
|
||||
default_cad_symbol_num = getattr(
|
||||
radio, "_custom_cad_symbol_num", None
|
||||
) or self.config.get("radio", {}).get("cad", {}).get("symbol_num", 2)
|
||||
try:
|
||||
default_cad_symbol_num = int(default_cad_symbol_num)
|
||||
except (TypeError, ValueError):
|
||||
default_cad_symbol_num = 2
|
||||
if default_cad_symbol_num not in {1, 2, 4, 8, 16}:
|
||||
default_cad_symbol_num = 2
|
||||
|
||||
samples = self.cad_calibration._normalize_int(
|
||||
data.get("samples", 1), default=1, minimum=1, maximum=32
|
||||
@@ -3635,10 +3678,13 @@ class APIEndpoints:
|
||||
maximum=255,
|
||||
)
|
||||
cad_symbol_num = self.cad_calibration._normalize_int(
|
||||
data.get("cad_symbol_num", 2), default=2, minimum=1, maximum=16
|
||||
data.get("cad_symbol_num", default_cad_symbol_num),
|
||||
default=default_cad_symbol_num,
|
||||
minimum=1,
|
||||
maximum=16,
|
||||
)
|
||||
if cad_symbol_num not in {1, 2, 4, 8, 16}:
|
||||
cad_symbol_num = 2
|
||||
cad_symbol_num = default_cad_symbol_num
|
||||
cad_timeout_ms = self.cad_calibration._normalize_int(
|
||||
data.get("cad_timeout_ms", 500), default=500, minimum=50, maximum=5000
|
||||
)
|
||||
@@ -3730,6 +3776,7 @@ class APIEndpoints:
|
||||
data = cherrypy.request.json or {}
|
||||
peak = data.get("peak")
|
||||
min_val = data.get("min_val")
|
||||
cad_symbol_num = data.get("cad_symbol_num")
|
||||
detection_rate = data.get("detection_rate", 0)
|
||||
|
||||
if peak is None or min_val is None:
|
||||
@@ -3741,8 +3788,17 @@ class APIEndpoints:
|
||||
except (TypeError, ValueError):
|
||||
return self._error("peak and min_val must be integers")
|
||||
|
||||
if cad_symbol_num is None:
|
||||
cad_symbol_num = self.config.get("radio", {}).get("cad", {}).get("symbol_num", 2)
|
||||
try:
|
||||
cad_symbol_num = int(cad_symbol_num)
|
||||
except (TypeError, ValueError):
|
||||
return self._error("cad_symbol_num must be an integer")
|
||||
|
||||
if not (0 <= peak <= 255) or not (0 <= min_val <= 255):
|
||||
return self._error("CAD thresholds must be between 0 and 255")
|
||||
if cad_symbol_num not in {1, 2, 4, 8, 16}:
|
||||
return self._error("cad_symbol_num must be one of: 1, 2, 4, 8, 16")
|
||||
|
||||
if (
|
||||
self.daemon_instance
|
||||
@@ -3751,7 +3807,14 @@ class APIEndpoints:
|
||||
):
|
||||
if hasattr(self.daemon_instance.radio, "set_custom_cad_thresholds"):
|
||||
self.daemon_instance.radio.set_custom_cad_thresholds(peak=peak, min_val=min_val)
|
||||
logger.info(f"Applied CAD settings to radio: peak={peak}, min={min_val}")
|
||||
if hasattr(self.daemon_instance.radio, "set_custom_cad_symbol_num"):
|
||||
self.daemon_instance.radio.set_custom_cad_symbol_num(cad_symbol_num)
|
||||
logger.info(
|
||||
"Applied CAD settings to radio: peak=%s, min=%s, symbols=%s",
|
||||
peak,
|
||||
min_val,
|
||||
cad_symbol_num,
|
||||
)
|
||||
|
||||
if "radio" not in self.config:
|
||||
self.config["radio"] = {}
|
||||
@@ -3760,18 +3823,30 @@ class APIEndpoints:
|
||||
|
||||
self.config["radio"]["cad"]["peak_threshold"] = peak
|
||||
self.config["radio"]["cad"]["min_threshold"] = min_val
|
||||
self.config["radio"]["cad"]["symbol_num"] = cad_symbol_num
|
||||
|
||||
saved = self.config_manager.save_to_file()
|
||||
if not saved:
|
||||
return self._error("Failed to save configuration to file")
|
||||
|
||||
logger.info(
|
||||
f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%"
|
||||
"Saved CAD settings to config: peak=%s, min=%s, symbols=%s, rate=%.1f%%",
|
||||
peak,
|
||||
min_val,
|
||||
cad_symbol_num,
|
||||
detection_rate,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"CAD settings saved: peak={peak}, min={min_val}",
|
||||
"settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate},
|
||||
"message": (
|
||||
f"CAD settings saved: peak={peak}, min={min_val}, symbols={cad_symbol_num}"
|
||||
),
|
||||
"settings": {
|
||||
"peak": peak,
|
||||
"min_val": min_val,
|
||||
"cad_symbol_num": cad_symbol_num,
|
||||
"detection_rate": detection_rate,
|
||||
},
|
||||
}
|
||||
except cherrypy.HTTPError:
|
||||
# Re-raise HTTP errors (like 405 Method Not Allowed) without logging
|
||||
|
||||
@@ -124,15 +124,36 @@ class CADCalibrationEngine:
|
||||
) -> list[dict]:
|
||||
semtech_peak, semtech_min = self._default_thresholds_for_sf(sf)
|
||||
if known_signal_present:
|
||||
required_detection_rate = 85.0
|
||||
|
||||
def _known_signal_sort_key(r: dict):
|
||||
detection_rate = float(r.get("detection_rate", 0.0) or 0.0)
|
||||
instability = int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0)
|
||||
peak = int(r.get("det_peak", semtech_peak))
|
||||
min_val = int(r.get("det_min", semtech_min))
|
||||
attempts = int(r.get("attempts", 0) or r.get("samples", 0) or 0)
|
||||
aggressiveness_penalty = max(0, semtech_peak - peak) + (
|
||||
2 * max(0, semtech_min - min_val)
|
||||
)
|
||||
is_stable = instability == 0
|
||||
meets_detection_floor = detection_rate >= required_detection_rate
|
||||
qualification_tier = (
|
||||
2 if (is_stable and meets_detection_floor) else (1 if is_stable else 0)
|
||||
)
|
||||
return (
|
||||
qualification_tier,
|
||||
-aggressiveness_penalty,
|
||||
min_val,
|
||||
peak,
|
||||
detection_rate,
|
||||
-instability,
|
||||
attempts,
|
||||
int(r.get("detections", 0) or 0),
|
||||
)
|
||||
|
||||
return sorted(
|
||||
results,
|
||||
key=lambda r: (
|
||||
r.get("detection_rate", 0.0),
|
||||
-(r.get("timeouts", 0) + r.get("errors", 0)),
|
||||
r.get("detections", 0),
|
||||
-abs(r.get("det_peak", semtech_peak) - semtech_peak),
|
||||
-abs(r.get("det_min", semtech_min) - semtech_min),
|
||||
),
|
||||
key=_known_signal_sort_key,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@@ -157,6 +178,33 @@ class CADCalibrationEngine:
|
||||
+ abs(float(result.get("detection_rate", 0.0)))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_cad_result_samples(base: dict, extra: dict) -> dict:
|
||||
merged = dict(base)
|
||||
attempts = int(base.get("attempts", 0) or 0) + int(extra.get("attempts", 0) or 0)
|
||||
detections = int(base.get("detections", 0) or 0) + int(extra.get("detections", 0) or 0)
|
||||
non_detections = int(base.get("non_detections", 0) or 0) + int(
|
||||
extra.get("non_detections", 0) or 0
|
||||
)
|
||||
timeouts = int(base.get("timeouts", 0) or 0) + int(extra.get("timeouts", 0) or 0)
|
||||
errors = int(base.get("errors", 0) or 0) + int(extra.get("errors", 0) or 0)
|
||||
cad_done_count = int(base.get("cad_done_count", 0) or 0) + int(
|
||||
extra.get("cad_done_count", 0) or 0
|
||||
)
|
||||
merged.update(
|
||||
{
|
||||
"samples": attempts,
|
||||
"attempts": attempts,
|
||||
"detections": detections,
|
||||
"non_detections": non_detections,
|
||||
"timeouts": timeouts,
|
||||
"errors": errors,
|
||||
"cad_done_count": cad_done_count,
|
||||
"detection_rate": (detections / attempts) * 100 if attempts > 0 else 0.0,
|
||||
}
|
||||
)
|
||||
return merged
|
||||
|
||||
def _build_zoom_candidates(
|
||||
self,
|
||||
centers: list[dict],
|
||||
@@ -254,19 +302,53 @@ class CADCalibrationEngine:
|
||||
semtech_peak, semtech_min = self._default_thresholds_for_sf(sf)
|
||||
|
||||
if known_signal_present:
|
||||
required_detection_rate = 95.0
|
||||
|
||||
def _known_signal_sort_key(r: dict):
|
||||
detection_rate = float(r.get("detection_rate", 0.0) or 0.0)
|
||||
instability = int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0)
|
||||
peak = int(r.get("det_peak", semtech_peak))
|
||||
min_val = int(r.get("det_min", semtech_min))
|
||||
attempts = int(r.get("attempts", 0) or r.get("samples", 0) or 0)
|
||||
aggressiveness_penalty = max(0, semtech_peak - peak) + (
|
||||
2 * max(0, semtech_min - min_val)
|
||||
)
|
||||
is_stable = instability == 0
|
||||
meets_detection_floor = detection_rate >= required_detection_rate
|
||||
qualification_tier = (
|
||||
2 if (is_stable and meets_detection_floor) else (1 if is_stable else 0)
|
||||
)
|
||||
return (
|
||||
qualification_tier,
|
||||
-aggressiveness_penalty,
|
||||
min_val,
|
||||
peak,
|
||||
detection_rate,
|
||||
-instability,
|
||||
attempts,
|
||||
int(r.get("detections", 0) or 0),
|
||||
)
|
||||
|
||||
ranked = sorted(
|
||||
results,
|
||||
key=lambda r: (
|
||||
r.get("detection_rate", 0.0),
|
||||
-(r.get("timeouts", 0) + r.get("errors", 0)),
|
||||
-abs(r.get("det_peak", semtech_peak) - semtech_peak),
|
||||
-abs(r.get("det_min", semtech_min) - semtech_min),
|
||||
),
|
||||
key=_known_signal_sort_key,
|
||||
reverse=True,
|
||||
)
|
||||
met_required = any(
|
||||
(float(r.get("detection_rate", 0.0) or 0.0) >= required_detection_rate)
|
||||
and (int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0) == 0)
|
||||
for r in results
|
||||
)
|
||||
return (
|
||||
ranked[0],
|
||||
"Recommended using known-signal measurements (maximize CAD_DETECTED while minimizing timeouts/errors).",
|
||||
(
|
||||
"Recommended using known-signal qualification-first selection "
|
||||
f"(require ≥{required_detection_rate:.0f}% detection with zero timeouts/errors, "
|
||||
"then choose the least-sensitive stable setting that meets it)."
|
||||
if met_required
|
||||
else "No candidate met the strict known-signal qualification floor; "
|
||||
"selected the most stable least-sensitive fallback from available results."
|
||||
),
|
||||
)
|
||||
|
||||
ranked = sorted(
|
||||
@@ -322,99 +404,33 @@ class CADCalibrationEngine:
|
||||
cad_symbol_num = int(self.session_config.get("cad_symbol_num", 2))
|
||||
cad_timeout_seconds = float(self.session_config.get("cad_timeout_seconds", 0.5))
|
||||
|
||||
# Coarse-to-fine search settings (bounded budget, no broad exhaustive sweeps)
|
||||
coarse_peak_lower = max(1, int(base_peak) - 12)
|
||||
coarse_peak_upper = min(255, int(base_peak) + 12)
|
||||
coarse_min_lower = max(1, int(base_min) - 5)
|
||||
coarse_min_upper = min(255, int(base_min) + 5)
|
||||
max_total_tests = 84
|
||||
current = 0
|
||||
estimated_total = 0
|
||||
self.progress = {"current": 0, "total": estimated_total}
|
||||
self.progress = {"current": 0, "total": 0}
|
||||
|
||||
# Run calibration in event loop with staged coarse-to-fine search
|
||||
if self.event_loop:
|
||||
stage_definitions: list[dict[str, Any]] = [
|
||||
{
|
||||
"stage_key": "coarse",
|
||||
"label": "coarse scan",
|
||||
"builder": lambda: [
|
||||
(peak, min_val)
|
||||
for peak in self._build_stepped_range(
|
||||
coarse_peak_lower, coarse_peak_upper, 4, anchor=int(base_peak)
|
||||
)
|
||||
for min_val in self._build_stepped_range(
|
||||
coarse_min_lower, coarse_min_upper, 2, anchor=int(base_min)
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"stage_key": "zoom1",
|
||||
"label": "zoom refinement 1",
|
||||
"builder": lambda: self._build_zoom_candidates(
|
||||
self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)[:3],
|
||||
peak_radius=4,
|
||||
min_radius=2,
|
||||
),
|
||||
},
|
||||
{
|
||||
"stage_key": "zoom2",
|
||||
"label": "zoom refinement 2",
|
||||
"builder": lambda: self._build_zoom_candidates(
|
||||
self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)[:2],
|
||||
peak_radius=2,
|
||||
min_radius=1,
|
||||
),
|
||||
},
|
||||
{
|
||||
"stage_key": "fine",
|
||||
"label": "fine polish",
|
||||
"builder": lambda: self._build_zoom_candidates(
|
||||
self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)[:1],
|
||||
peak_radius=1,
|
||||
min_radius=1,
|
||||
),
|
||||
},
|
||||
]
|
||||
best_score_before_stage: Optional[float] = None
|
||||
for stage_index, stage in enumerate(stage_definitions, start=1):
|
||||
if not self.running or current >= max_total_tests:
|
||||
break
|
||||
|
||||
raw_candidates: list[tuple[int, int]] = stage["builder"]()
|
||||
candidates = [
|
||||
candidate
|
||||
for candidate in raw_candidates
|
||||
if f"{candidate[0]}-{candidate[1]}" not in self.results
|
||||
if known_signal_present:
|
||||
semtech_peak, semtech_min = self._default_thresholds_for_sf(sf)
|
||||
required_detection_rate = 85.0
|
||||
evaluation_samples = max(30, min(60, samples * 3))
|
||||
max_escalation_steps = min(12, max(0, semtech_peak - 1))
|
||||
candidate_pairs = [
|
||||
(max(1, semtech_peak - step), semtech_min)
|
||||
for step in range(max_escalation_steps + 1)
|
||||
]
|
||||
remaining_budget = max_total_tests - current
|
||||
candidates = candidates[:remaining_budget]
|
||||
if not candidates:
|
||||
continue
|
||||
self.progress["total"] = len(candidate_pairs)
|
||||
|
||||
estimated_total = max(self.progress.get("total", 0), current + len(candidates))
|
||||
self.progress["total"] = estimated_total
|
||||
|
||||
peak_values = [candidate[0] for candidate in candidates]
|
||||
min_values = [candidate[1] for candidate in candidates]
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "status",
|
||||
"message": (
|
||||
f"Calibration stage {stage_index}/{len(stage_definitions)} "
|
||||
f"({stage['label']}): testing {len(candidates)} combinations"
|
||||
"Calibration stage 1/2 (default baseline): testing Semtech default "
|
||||
"thresholds first; escalate only if required."
|
||||
),
|
||||
"test_ranges": {
|
||||
"peak_min": min(peak_values),
|
||||
"peak_max": max(peak_values),
|
||||
"min_min": min(min_values),
|
||||
"min_max": max(min_values),
|
||||
"peak_min": candidate_pairs[-1][0],
|
||||
"peak_max": candidate_pairs[0][0],
|
||||
"min_min": semtech_min,
|
||||
"min_max": semtech_min,
|
||||
"spreading_factor": sf,
|
||||
"bandwidth": runtime_cfg["bandwidth"],
|
||||
"frequency": runtime_cfg["frequency"],
|
||||
@@ -422,32 +438,32 @@ class CADCalibrationEngine:
|
||||
"current_min": base_min,
|
||||
"cad_symbol_num": cad_symbol_num,
|
||||
"known_signal_present": known_signal_present,
|
||||
"total_tests": estimated_total,
|
||||
"pass_index": stage_index,
|
||||
"max_passes": len(stage_definitions),
|
||||
"stage": stage["stage_key"],
|
||||
"total_tests": len(candidate_pairs),
|
||||
"pass_index": 1,
|
||||
"max_passes": 2,
|
||||
"stage": "default-anchor",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for det_peak, det_min in candidates:
|
||||
qualified_candidate: Optional[dict] = None
|
||||
for index, (det_peak, det_min) in enumerate(candidate_pairs, start=1):
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
current += 1
|
||||
current = index
|
||||
self.progress["current"] = current
|
||||
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "progress",
|
||||
"current": current,
|
||||
"total": estimated_total,
|
||||
"total": len(candidate_pairs),
|
||||
"det_peak": det_peak,
|
||||
"det_min": det_min,
|
||||
"known_signal_present": known_signal_present,
|
||||
"pass_index": stage_index,
|
||||
"max_passes": len(stage_definitions),
|
||||
"stage": stage["stage_key"],
|
||||
"pass_index": 1,
|
||||
"max_passes": 2,
|
||||
"stage": "default-anchor",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -456,7 +472,7 @@ class CADCalibrationEngine:
|
||||
radio,
|
||||
det_peak,
|
||||
det_min,
|
||||
samples=samples,
|
||||
samples=evaluation_samples,
|
||||
cad_symbol_num=cad_symbol_num,
|
||||
cad_timeout_seconds=cad_timeout_seconds,
|
||||
),
|
||||
@@ -464,48 +480,304 @@ class CADCalibrationEngine:
|
||||
)
|
||||
|
||||
try:
|
||||
result = future.result(timeout=30)
|
||||
result = future.result(timeout=45)
|
||||
self.results[f"{det_peak}-{det_min}"] = result
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "result",
|
||||
"pass_index": stage_index,
|
||||
"stage": stage["stage_key"],
|
||||
"pass_index": 1,
|
||||
"stage": "default-anchor",
|
||||
**result,
|
||||
}
|
||||
)
|
||||
|
||||
instability = int(result.get("timeouts", 0) or 0) + int(
|
||||
result.get("errors", 0) or 0
|
||||
)
|
||||
detection_rate = float(result.get("detection_rate", 0.0) or 0.0)
|
||||
if instability == 0 and detection_rate >= required_detection_rate:
|
||||
qualified_candidate = result
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "status",
|
||||
"message": (
|
||||
f"Qualification met at P{det_peak}/M{det_min} "
|
||||
f"(rate {detection_rate:.1f}%, stable). "
|
||||
"Stopping escalation at first qualifying candidate."
|
||||
),
|
||||
}
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}")
|
||||
|
||||
if self.running and delay_ms > 0:
|
||||
time.sleep(delay_ms / 1000.0)
|
||||
|
||||
if not self.running or not self.results:
|
||||
break
|
||||
|
||||
ranked_results = self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)
|
||||
best_score_after_stage = self._search_objective_value(
|
||||
ranked_results[0], known_signal_present
|
||||
)
|
||||
min_improvement = 2.0 if known_signal_present else 1.0
|
||||
if (
|
||||
stage_index >= 2
|
||||
and best_score_before_stage is not None
|
||||
and (best_score_after_stage - best_score_before_stage) < min_improvement
|
||||
):
|
||||
if self.running and self.results and qualified_candidate is None:
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "status",
|
||||
"message": (
|
||||
f"Calibration converged after {stage['label']} "
|
||||
f"(improvement < 2%)."
|
||||
"No candidate met strict qualification floor "
|
||||
f"(≥{required_detection_rate:.0f}% with zero timeouts/errors). "
|
||||
"Using least-sensitive stable fallback from tested candidates."
|
||||
),
|
||||
}
|
||||
)
|
||||
break
|
||||
best_score_before_stage = best_score_after_stage
|
||||
else:
|
||||
# Quiet-mode keeps the previous coarse-to-fine search behaviour.
|
||||
coarse_peak_lower = max(1, int(base_peak) - 12)
|
||||
coarse_peak_upper = min(255, int(base_peak) + 12)
|
||||
coarse_min_lower = max(1, int(base_min) - 5)
|
||||
coarse_min_upper = min(255, int(base_min) + 5)
|
||||
max_total_tests = 84
|
||||
estimated_total = 0
|
||||
self.progress = {"current": 0, "total": estimated_total}
|
||||
stage_definitions: list[dict[str, Any]] = [
|
||||
{
|
||||
"stage_key": "coarse",
|
||||
"label": "coarse scan",
|
||||
"builder": lambda: [
|
||||
(peak, min_val)
|
||||
for peak in self._build_stepped_range(
|
||||
coarse_peak_lower, coarse_peak_upper, 4, anchor=int(base_peak)
|
||||
)
|
||||
for min_val in self._build_stepped_range(
|
||||
coarse_min_lower, coarse_min_upper, 2, anchor=int(base_min)
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"stage_key": "zoom1",
|
||||
"label": "zoom refinement 1",
|
||||
"builder": lambda: self._build_zoom_candidates(
|
||||
self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)[:3],
|
||||
peak_radius=4,
|
||||
min_radius=2,
|
||||
),
|
||||
},
|
||||
{
|
||||
"stage_key": "zoom2",
|
||||
"label": "zoom refinement 2",
|
||||
"builder": lambda: self._build_zoom_candidates(
|
||||
self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)[:2],
|
||||
peak_radius=2,
|
||||
min_radius=1,
|
||||
),
|
||||
},
|
||||
{
|
||||
"stage_key": "fine",
|
||||
"label": "fine polish",
|
||||
"builder": lambda: self._build_zoom_candidates(
|
||||
self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)[:1],
|
||||
peak_radius=1,
|
||||
min_radius=1,
|
||||
),
|
||||
},
|
||||
]
|
||||
best_score_before_stage: Optional[float] = None
|
||||
for stage_index, stage in enumerate(stage_definitions, start=1):
|
||||
if not self.running or current >= max_total_tests:
|
||||
break
|
||||
|
||||
raw_candidates: list[tuple[int, int]] = stage["builder"]()
|
||||
candidates = [
|
||||
candidate
|
||||
for candidate in raw_candidates
|
||||
if f"{candidate[0]}-{candidate[1]}" not in self.results
|
||||
]
|
||||
remaining_budget = max_total_tests - current
|
||||
candidates = candidates[:remaining_budget]
|
||||
if not candidates:
|
||||
continue
|
||||
|
||||
estimated_total = max(
|
||||
self.progress.get("total", 0), current + len(candidates)
|
||||
)
|
||||
self.progress["total"] = estimated_total
|
||||
|
||||
peak_values = [candidate[0] for candidate in candidates]
|
||||
min_values = [candidate[1] for candidate in candidates]
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "status",
|
||||
"message": (
|
||||
f"Calibration stage {stage_index}/{len(stage_definitions)} "
|
||||
f"({stage['label']}): testing {len(candidates)} combinations"
|
||||
),
|
||||
"test_ranges": {
|
||||
"peak_min": min(peak_values),
|
||||
"peak_max": max(peak_values),
|
||||
"min_min": min(min_values),
|
||||
"min_max": max(min_values),
|
||||
"spreading_factor": sf,
|
||||
"bandwidth": runtime_cfg["bandwidth"],
|
||||
"frequency": runtime_cfg["frequency"],
|
||||
"current_peak": base_peak,
|
||||
"current_min": base_min,
|
||||
"cad_symbol_num": cad_symbol_num,
|
||||
"known_signal_present": known_signal_present,
|
||||
"total_tests": estimated_total,
|
||||
"pass_index": stage_index,
|
||||
"max_passes": len(stage_definitions),
|
||||
"stage": stage["stage_key"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for det_peak, det_min in candidates:
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
current += 1
|
||||
self.progress["current"] = current
|
||||
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "progress",
|
||||
"current": current,
|
||||
"total": estimated_total,
|
||||
"det_peak": det_peak,
|
||||
"det_min": det_min,
|
||||
"known_signal_present": known_signal_present,
|
||||
"pass_index": stage_index,
|
||||
"max_passes": len(stage_definitions),
|
||||
"stage": stage["stage_key"],
|
||||
}
|
||||
)
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.test_cad_config(
|
||||
radio,
|
||||
det_peak,
|
||||
det_min,
|
||||
samples=samples,
|
||||
cad_symbol_num=cad_symbol_num,
|
||||
cad_timeout_seconds=cad_timeout_seconds,
|
||||
),
|
||||
self.event_loop,
|
||||
)
|
||||
|
||||
try:
|
||||
result = future.result(timeout=30)
|
||||
self.results[f"{det_peak}-{det_min}"] = result
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "result",
|
||||
"pass_index": stage_index,
|
||||
"stage": stage["stage_key"],
|
||||
**result,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"CAD test failed for peak={det_peak}, min={det_min}: {e}"
|
||||
)
|
||||
|
||||
if self.running and delay_ms > 0:
|
||||
time.sleep(delay_ms / 1000.0)
|
||||
|
||||
if not self.running or not self.results:
|
||||
break
|
||||
|
||||
ranked_results = self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)
|
||||
best_score_after_stage = self._search_objective_value(
|
||||
ranked_results[0], known_signal_present
|
||||
)
|
||||
min_improvement = 1.0
|
||||
if (
|
||||
stage_index >= 2
|
||||
and best_score_before_stage is not None
|
||||
and (best_score_after_stage - best_score_before_stage) < min_improvement
|
||||
):
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "status",
|
||||
"message": (
|
||||
f"Calibration converged after {stage['label']} "
|
||||
f"(improvement < {min_improvement:.0f}%)."
|
||||
),
|
||||
}
|
||||
)
|
||||
break
|
||||
best_score_before_stage = best_score_after_stage
|
||||
|
||||
# Adaptive confidence pass remains for quiet-mode only.
|
||||
if self.running and self.results:
|
||||
ranked_for_verify = self._rank_results_for_search(
|
||||
list(self.results.values()), known_signal_present, sf
|
||||
)
|
||||
finalist_count = min(3, len(ranked_for_verify))
|
||||
target_samples = max(30, min(60, samples * 3))
|
||||
extra_samples = max(0, target_samples - samples)
|
||||
if finalist_count > 0 and extra_samples > 0:
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "status",
|
||||
"message": (
|
||||
f"Verification pass: re-testing top {finalist_count} "
|
||||
f"candidates with +{extra_samples} samples each."
|
||||
),
|
||||
}
|
||||
)
|
||||
for index, candidate in enumerate(
|
||||
ranked_for_verify[:finalist_count], start=1
|
||||
):
|
||||
if not self.running:
|
||||
break
|
||||
det_peak = int(candidate.get("det_peak", 22))
|
||||
det_min = int(candidate.get("det_min", 10))
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "status",
|
||||
"message": (
|
||||
f"Verification {index}/{finalist_count}: "
|
||||
f"P{det_peak}/M{det_min}"
|
||||
),
|
||||
}
|
||||
)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.test_cad_config(
|
||||
radio,
|
||||
det_peak,
|
||||
det_min,
|
||||
samples=extra_samples,
|
||||
cad_symbol_num=cad_symbol_num,
|
||||
cad_timeout_seconds=cad_timeout_seconds,
|
||||
),
|
||||
self.event_loop,
|
||||
)
|
||||
try:
|
||||
verification_result = future.result(timeout=30)
|
||||
result_key = f"{det_peak}-{det_min}"
|
||||
base_result = self.results.get(result_key, candidate)
|
||||
merged_result = self._merge_cad_result_samples(
|
||||
base_result, verification_result
|
||||
)
|
||||
self.results[result_key] = merged_result
|
||||
self.broadcast_to_clients(
|
||||
{
|
||||
"type": "result",
|
||||
"stage": "verification",
|
||||
**merged_result,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"CAD finalist verification failed for peak=%s, min=%s: %s",
|
||||
det_peak,
|
||||
det_min,
|
||||
e,
|
||||
)
|
||||
|
||||
if self.running:
|
||||
best_result = None
|
||||
@@ -513,6 +785,9 @@ class CADCalibrationEngine:
|
||||
recommendation_reason = "No recommendation generated."
|
||||
signal_activity_observed = False
|
||||
known_signal_effective = known_signal_present
|
||||
quiet_mode_invalid = False
|
||||
quiet_mode_invalid_reason = ""
|
||||
aggregate_detection_rate = 0.0
|
||||
qualification = (
|
||||
"Known compatible LoRa signal present during calibration."
|
||||
if known_signal_present
|
||||
@@ -527,11 +802,25 @@ class CADCalibrationEngine:
|
||||
total_attempts = sum(int(r.get("attempts", 0) or 0) for r in all_results)
|
||||
total_detections = sum(int(r.get("detections", 0) or 0) for r in all_results)
|
||||
best_rate = float(best_result.get("detection_rate", 0.0) or 0.0)
|
||||
aggregate_detection_rate = (
|
||||
(float(total_detections) / float(total_attempts)) * 100.0
|
||||
if total_attempts > 0
|
||||
else 0.0
|
||||
)
|
||||
min_detection_floor = max(5, int(total_attempts * 0.03))
|
||||
signal_activity_observed = (
|
||||
total_detections >= min_detection_floor and best_rate >= 15.0
|
||||
)
|
||||
known_signal_effective = known_signal_present or signal_activity_observed
|
||||
if not known_signal_present and (
|
||||
signal_activity_observed or aggregate_detection_rate > 10.0
|
||||
):
|
||||
quiet_mode_invalid = True
|
||||
quiet_mode_invalid_reason = (
|
||||
"Quiet-mode run observed significant channel activity "
|
||||
f"(aggregate CAD detection {aggregate_detection_rate:.1f}%). "
|
||||
"Re-run quiet baseline during a truly idle channel."
|
||||
)
|
||||
|
||||
if not known_signal_present and signal_activity_observed:
|
||||
qualification = (
|
||||
@@ -551,6 +840,9 @@ class CADCalibrationEngine:
|
||||
"known_signal_present": known_signal_present,
|
||||
"signal_activity_observed": signal_activity_observed,
|
||||
"known_signal_effective": known_signal_effective,
|
||||
"quiet_mode_invalid": quiet_mode_invalid,
|
||||
"quiet_mode_invalid_reason": quiet_mode_invalid_reason,
|
||||
"aggregate_detection_rate": aggregate_detection_rate,
|
||||
"qualification": qualification,
|
||||
"total_tests": len(self.results),
|
||||
}
|
||||
|
||||
@@ -1446,6 +1446,11 @@ paths:
|
||||
maximum: 255
|
||||
description: CAD minimum value
|
||||
example: 64
|
||||
cad_symbol_num:
|
||||
type: integer
|
||||
enum: [1, 2, 4, 8, 16]
|
||||
default: 2
|
||||
description: CAD symbol count to persist for runtime CAD detections.
|
||||
responses:
|
||||
'200':
|
||||
description: Settings saved
|
||||
|
||||
Reference in New Issue
Block a user