feat: enhance CAD configuration with dynamic symbol number handling and update OpenAPI documentation

This commit is contained in:
Rightup
2026-07-12 22:34:51 +01:00
parent 4990322cef
commit ad87ca3db2
3 changed files with 511 additions and 139 deletions
+84 -9
View File
@@ -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
+422 -130
View File
@@ -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),
}
+5
View File
@@ -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