kamil_adc support added

This commit is contained in:
Ayzen
2026-05-08 21:23:52 +03:00
parent bde86813e5
commit 907dbf29ce
36 changed files with 2161 additions and 221 deletions
@@ -425,8 +425,12 @@ auto load_run_config(const std::string& path) -> RunConfig {
static_cast<float>(as_number(required_field(*sweep_obj, "start_hz"), "radar.sweep.start_hz"));
config.radar.sweep.stop_hz =
static_cast<float>(as_number(required_field(*sweep_obj, "stop_hz"), "radar.sweep.stop_hz"));
config.radar.sweep.points =
number_to_u32(as_number(required_field(*sweep_obj, "points"), "radar.sweep.points"), "radar.sweep.points");
if (const auto* points_value = optional_field(*sweep_obj, "points"); points_value != nullptr) {
config.radar.sweep.points =
number_to_u32(as_number(*points_value, "radar.sweep.points"), "radar.sweep.points");
} else if (config.radar.model != "kamil_adc") {
throw std::runtime_error("Missing required config field: points");
}
config.radar.sweep.if_bandwidth_hz = static_cast<float>(
as_number(required_field(*sweep_obj, "if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz")
);
@@ -39,6 +39,7 @@ struct ProcessingLiveConfig {
float gpr_range_comp_power = 0.28F;
float gpr_angle_comp_power = 0.10F;
float gpr_comp_power = 0.2F;
std::string gpr_score_mode = "combined";
float gpr_speed_m_s = 0.0F;
float gpr_look_angle_deg = 0.0F;
float gpr_snr_thresh = 4.5F;
@@ -48,6 +49,7 @@ struct ProcessingLiveConfig {
bool gpr_background_subtract_enabled = true;
std::uint32_t gpr_background_mean_count = 10U;
bool gpr_remove_sidelobe_objects_enabled = true;
bool reprocess_current_result = true;
std::uint64_t history_command_seq = 0;
HistoryCommand history_command = HistoryCommand::None;
};
@@ -77,7 +77,10 @@ void DataProcessor::run(const std::atomic<bool>& stop_requested) {
last_applied_history_command_seq = live_config.history_command_seq;
}
if (should_replay_entire_history(live_config)) {
if (!live_config.reprocess_current_result) {
// Socket-fed speed updates should affect only future preprocessed collections,
// not replay the current history entry.
} else if (should_replay_entire_history(live_config)) {
for (std::size_t index = 0; index < preprocessed_history.size(); ++index) {
const auto replay_result = process_collection(
preprocessed_history[index],
@@ -41,6 +41,13 @@ using Json = nlohmann::json;
throw std::runtime_error(field_name + " must be one of: point, extended");
}
[[nodiscard]] auto parse_gpr_score_mode(const std::string& value, const std::string& field_name) -> std::string {
if (value == "peak" || value == "combined") {
return value;
}
throw std::runtime_error(field_name + " must be one of: peak, combined");
}
void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::string& value) {
if (value == "backprojection") {
return;
@@ -241,6 +248,12 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
}
config.gpr_comp_power = static_cast<float>(found->get<double>());
}
if (const auto found = root.find("gpr_score_mode"); found != root.end()) {
if (!found->is_string()) {
throw std::runtime_error("processing.gpr_score_mode must be string");
}
config.gpr_score_mode = parse_gpr_score_mode(found->get<std::string>(), "processing.gpr_score_mode");
}
if (const auto found = root.find("gpr_speed_m_s"); found != root.end()) {
if (!found->is_number()) {
throw std::runtime_error("processing.gpr_speed_m_s must be number");
@@ -292,6 +305,12 @@ void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::s
}
config.gpr_remove_sidelobe_objects_enabled = found->get<bool>();
}
if (const auto found = root.find("reprocess_current_result"); found != root.end()) {
if (!found->is_boolean()) {
throw std::runtime_error("processing.reprocess_current_result must be bool");
}
config.reprocess_current_result = found->get<bool>();
}
if (const auto found = root.find("history_command_seq"); found != root.end()) {
config.history_command_seq = parse_u64_number(*found, "processing.history_command_seq");
}
@@ -9,7 +9,11 @@ constexpr std::size_t kAscanOversample = 8U;
constexpr double kRangeWeightMax = 5.0;
constexpr double kAngleWeightMax = 2.0;
constexpr double kTotalWeightMax = 8.0;
constexpr double kCompensationReferenceDepthM = 3.0;
constexpr double kCompensationReferenceDepthM = 5.0;
constexpr bool kPairNormalize = true;
constexpr double kPairNormPercentile = 50.0;
constexpr double kPairNormEps = 1e-15;
constexpr double kSmoothSigma = 1.5;
constexpr std::size_t kMaxObjects = 10U;
@@ -30,6 +34,18 @@ constexpr double kSidelobeMinDxM = 0.35;
constexpr double kSidelobeMaxDzM = 0.70;
constexpr double kSidelobeMaxRelativePeak = 0.85;
constexpr double kLocalBgRadiusXM = 1.20;
constexpr double kLocalBgRadiusZM = 0.80;
constexpr double kLocalBgPercentile = 50.0;
constexpr double kLocalContrastEps = 1e-12;
constexpr double kScoreCohPeakWeight = 0.45;
constexpr double kScoreCoherenceFactorWeight = 0.25;
constexpr double kScoreProminenceWeight = 0.20;
constexpr double kScoreContrastWeight = 0.10;
constexpr double kScoreContrastCap = 6.0;
constexpr double kScoreCfEps = 1e-12;
using PairKey = std::uint64_t;
struct GeometrySelection {
@@ -72,10 +88,13 @@ struct GridDefinition {
struct BpMap {
std::vector<double> image{};
std::vector<std::complex<double>> coherent{};
std::vector<double> incoherent{};
std::vector<double> coherence_factor{};
};
struct ObjectRecord {
std::size_t index = 0U;
std::size_t peak_index = 0U;
double x_peak_m = 0.0;
double z_peak_m = 0.0;
double x_m = 0.0;
@@ -87,6 +106,18 @@ struct ObjectRecord {
double center_area_cm2 = 0.0;
double mean_value = 0.0;
double sum_value = 0.0;
double local_bg = 0.0;
double local_bg_p75 = 0.0;
double prominence = 0.0;
double contrast = 0.0;
double incoh_peak = 0.0;
double incoh_mean = 0.0;
double incoh_center_mean = 0.0;
double coherence_factor_peak = 0.0;
double coherence_factor_center = 0.0;
double score_old = 0.0;
double score_new = 0.0;
double selected_score = 0.0;
std::vector<std::uint8_t> region_mask{};
std::vector<std::uint8_t> center_mask{};
bool sidelobe_candidate = false;
@@ -178,6 +209,28 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
return median;
}
[[nodiscard]] auto percentile_copy(std::vector<double> values, double percentile) -> double {
if (values.empty()) {
return 0.0;
}
values.erase(
std::remove_if(values.begin(), values.end(), [](double value) { return !std::isfinite(value); }),
values.end()
);
if (values.empty()) {
return 0.0;
}
std::sort(values.begin(), values.end());
const double clamped_percentile = std::clamp(percentile, 0.0, 100.0);
const double position = (clamped_percentile / 100.0) * static_cast<double>(values.size() - 1U);
const auto lower_index = static_cast<std::size_t>(std::floor(position));
const auto upper_index = std::min<std::size_t>(lower_index + 1U, values.size() - 1U);
const double fraction = position - static_cast<double>(lower_index);
return (values[lower_index] * (1.0 - fraction)) + (values[upper_index] * fraction);
}
[[nodiscard]] auto build_axis(double min_value, double max_value, std::size_t count) -> std::vector<double> {
std::vector<double> axis{};
if (count == 0U) {
@@ -576,6 +629,50 @@ void validate_collection_trace_order(
return result;
}
void normalize_pair_ascans(
std::unordered_map<PairKey, AscanResult>& ascans_by_pair,
double velocity_mps,
double min_depth_m,
double max_depth_m
) {
if (!kPairNormalize) {
return;
}
for (auto& item : ascans_by_pair) {
auto& ascan = item.second;
if (ascan.samples.empty() || ascan.time_s.size() != ascan.samples.size()) {
continue;
}
std::vector<double> amplitudes{};
amplitudes.reserve(ascan.samples.size());
for (std::size_t index = 0U; index < ascan.samples.size(); ++index) {
const double depth_m = 0.5 * velocity_mps * ascan.time_s[index];
if (depth_m < min_depth_m || depth_m > max_depth_m) {
continue;
}
amplitudes.push_back(std::abs(ascan.samples[index]));
}
if (amplitudes.empty()) {
amplitudes.reserve(ascan.samples.size());
for (const auto& sample : ascan.samples) {
amplitudes.push_back(std::abs(sample));
}
}
double scale = percentile_copy(std::move(amplitudes), kPairNormPercentile);
if (!std::isfinite(scale) || !(scale > kPairNormEps)) {
scale = 1.0;
}
const double denominator = scale + kPairNormEps;
for (auto& sample : ascan.samples) {
sample /= denominator;
}
}
}
[[nodiscard]] auto build_grid(
const std::vector<double>& x_tx,
const std::vector<double>& x_rx,
@@ -706,6 +803,8 @@ void validate_collection_trace_order(
BpMap result{};
result.image.assign(cell_count, 0.0);
result.coherent.assign(cell_count, std::complex<double>(0.0, 0.0));
result.incoherent.assign(cell_count, 0.0);
result.coherence_factor.assign(cell_count, 0.0);
std::vector<double> contribution_count(cell_count, 0.0);
for (const auto& trace : selected_traces) {
@@ -714,6 +813,10 @@ void validate_collection_trace_order(
if (ascan_it == ascans_by_pair.end()) {
continue;
}
const auto& ascan = ascan_it->second;
if (ascan.time_s.empty()) {
continue;
}
const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth(
trace.tx_local_index,
@@ -736,10 +839,10 @@ void validate_collection_trace_order(
const double r_tx = tx_distances[cell_index];
const double r_rx = rx_distances[cell_index];
const double tau_s = (r_tx + r_rx) / velocity_mps;
const auto sample = interpolate_complex(ascan_it->second, tau_s);
if (sample == std::complex<double>(0.0, 0.0)) {
if (tau_s < ascan.time_s.front() || tau_s > ascan.time_s.back()) {
continue;
}
const auto sample = interpolate_complex(ascan, tau_s);
const double weight = compensation_weight(
r_tx,
@@ -751,6 +854,7 @@ void validate_collection_trace_order(
angle_power
);
result.coherent[cell_index] += sample * weight;
result.incoherent[cell_index] += std::abs(sample) * weight;
contribution_count[cell_index] += 1.0;
}
}
@@ -761,7 +865,10 @@ void validate_collection_trace_order(
continue;
}
result.coherent[index] /= contribution_count[index];
result.incoherent[index] /= contribution_count[index];
result.image[index] = std::abs(result.coherent[index]);
result.coherence_factor[index] =
std::clamp(result.image[index] / (result.incoherent[index] + kScoreCfEps), 0.0, 1.0);
}
return result;
@@ -786,6 +893,21 @@ void apply_depth_gate(
}
}
[[nodiscard]] auto normalize_bp_map(
const std::vector<double>& raw_image,
const GridDefinition& grid,
double min_depth_m,
double max_depth_m,
double smooth_sigma
) -> std::vector<double> {
auto normalized = raw_image;
normalize_in_place(normalized);
normalized = gaussian_filter_2d(normalized, grid.x_grid.size(), grid.z_grid.size(), smooth_sigma);
apply_depth_gate(normalized, grid.z_grid, grid.x_grid.size(), min_depth_m, max_depth_m);
normalize_in_place(normalized);
return normalized;
}
[[nodiscard]] auto neighbor_indices(
std::size_t index,
std::size_t width,
@@ -1045,6 +1167,7 @@ void apply_depth_gate(
ObjectRecord object{};
object.index = objects.size() + 1U;
object.peak_index = peak_index;
object.x_peak_m = x_peak;
object.z_peak_m = z_peak;
object.x_m = x_center;
@@ -1153,6 +1276,187 @@ void mark_sidelobe_candidates(
}
}
void add_local_prominence_metrics(
std::vector<ObjectRecord>& objects,
const std::vector<double>& bp_image,
const GridDefinition& grid,
double min_depth_m,
double max_depth_m
) {
const std::size_t width = grid.x_grid.size();
if (bp_image.empty() || width == 0U) {
return;
}
for (auto& object : objects) {
std::vector<double> bg_values{};
for (std::size_t index = 0U; index < bp_image.size(); ++index) {
const auto row = index / width;
const auto col = index % width;
const double x_m = grid.x_grid[col];
const double z_m = grid.z_grid[row];
const bool in_local_window =
std::abs(x_m - object.x_peak_m) <= kLocalBgRadiusXM &&
std::abs(z_m - object.z_peak_m) <= kLocalBgRadiusZM;
const bool in_depth_gate = z_m >= min_depth_m && z_m <= max_depth_m;
if (in_local_window && in_depth_gate && object.region_mask[index] == 0U) {
bg_values.push_back(bp_image[index]);
}
}
if (bg_values.size() < 10U) {
bg_values.clear();
for (std::size_t index = 0U; index < bp_image.size(); ++index) {
const auto row = index / width;
const double z_m = grid.z_grid[row];
if (z_m >= min_depth_m && z_m <= max_depth_m && object.region_mask[index] == 0U) {
bg_values.push_back(bp_image[index]);
}
}
}
object.local_bg = percentile_copy(bg_values, kLocalBgPercentile);
object.local_bg_p75 = percentile_copy(std::move(bg_values), 75.0);
object.prominence = object.peak - object.local_bg;
object.contrast = object.peak / (object.local_bg + kLocalContrastEps);
}
}
[[nodiscard]] auto mean_values_under_mask(
const std::vector<double>& values,
const std::vector<std::uint8_t>& mask
) -> double {
if (values.empty() || values.size() != mask.size()) {
return 0.0;
}
double sum = 0.0;
std::size_t count = 0U;
for (std::size_t index = 0U; index < values.size(); ++index) {
if (mask[index] == 0U) {
continue;
}
sum += values[index];
count += 1U;
}
return count > 0U ? sum / static_cast<double>(count) : 0.0;
}
[[nodiscard]] auto max_values_under_mask(
const std::vector<double>& values,
const std::vector<std::uint8_t>& mask
) -> double {
if (values.empty() || values.size() != mask.size()) {
return 0.0;
}
double maximum = 0.0;
bool has_value = false;
for (std::size_t index = 0U; index < values.size(); ++index) {
if (mask[index] == 0U) {
continue;
}
maximum = has_value ? std::max(maximum, values[index]) : values[index];
has_value = true;
}
return has_value ? maximum : 0.0;
}
void add_incoherent_support_metrics(
std::vector<ObjectRecord>& objects,
const std::vector<double>& bp_incoherent_image,
const std::vector<double>& bp_coherence_factor
) {
for (auto& object : objects) {
object.incoh_peak = max_values_under_mask(bp_incoherent_image, object.region_mask);
object.incoh_mean = mean_values_under_mask(bp_incoherent_image, object.region_mask);
object.incoh_center_mean = mean_values_under_mask(bp_incoherent_image, object.center_mask);
if (!(object.incoh_center_mean > 0.0)) {
object.incoh_center_mean = object.incoh_mean;
}
if (!bp_coherence_factor.empty() && object.peak_index < bp_coherence_factor.size()) {
object.coherence_factor_peak = std::clamp(bp_coherence_factor[object.peak_index], 0.0, 1.0);
} else {
object.coherence_factor_peak =
std::clamp(object.peak / (object.incoh_peak + kScoreCfEps), 0.0, 1.0);
}
const double center_cf = mean_values_under_mask(bp_coherence_factor, object.center_mask);
object.coherence_factor_center = std::clamp(
center_cf > 0.0
? center_cf
: object.mean_value / (object.incoh_center_mean + kScoreCfEps),
0.0,
1.0
);
}
}
[[nodiscard]] auto contrast_score_unit(double contrast) -> double {
if (!std::isfinite(contrast) || kScoreContrastCap <= 1.0) {
return 0.0;
}
return std::clamp((contrast - 1.0) / (kScoreContrastCap - 1.0), 0.0, 1.0);
}
[[nodiscard]] auto use_combined_gpr_score(const ProcessingLiveConfig& live_config) -> bool {
return live_config.gpr_score_mode == "combined";
}
void add_bp_score_metrics(
std::vector<ObjectRecord>& objects,
const ProcessingLiveConfig& live_config
) {
double total_weight =
kScoreCohPeakWeight +
kScoreCoherenceFactorWeight +
kScoreProminenceWeight +
kScoreContrastWeight;
if (!(total_weight > 0.0)) {
total_weight = 1.0;
}
const bool combined_score = use_combined_gpr_score(live_config);
for (auto& object : objects) {
const double coh_peak_score = std::clamp(object.peak, 0.0, 1.0);
const double coherence_factor_score = std::clamp(object.coherence_factor_peak, 0.0, 1.0);
const double prominence_score = std::clamp(object.prominence, 0.0, 1.0);
const double contrast_score = contrast_score_unit(object.contrast);
object.score_old = coh_peak_score;
object.score_new = (
(kScoreCohPeakWeight * coh_peak_score) +
(kScoreCoherenceFactorWeight * coherence_factor_score) +
(kScoreProminenceWeight * prominence_score) +
(kScoreContrastWeight * contrast_score)
) / total_weight;
object.selected_score = combined_score ? object.score_new : object.score_old;
}
}
[[nodiscard]] auto output_objects_sorted(
const std::vector<ObjectRecord>& objects,
const ProcessingLiveConfig& live_config
) -> std::vector<const ObjectRecord*> {
std::vector<const ObjectRecord*> visible{};
visible.reserve(objects.size());
for (const auto& object : objects) {
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
continue;
}
visible.push_back(&object);
}
std::sort(visible.begin(), visible.end(), [](const ObjectRecord* left, const ObjectRecord* right) {
if (left->selected_score == right->selected_score) {
return left->peak > right->peak;
}
return left->selected_score > right->selected_score;
});
return visible;
}
[[nodiscard]] auto flatten_table(
const std::vector<std::vector<float>>& rows,
std::uint32_t column_count
@@ -1248,6 +1552,7 @@ void mark_sidelobe_candidates(
if (ascans_by_pair.empty()) {
return results;
}
normalize_pair_ascans(ascans_by_pair, velocity_mps, min_depth_m, max_depth_m);
const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m, kGridZMinM);
if (grid.x_grid.empty() || grid.z_grid.empty()) {
@@ -1270,13 +1575,15 @@ void mark_sidelobe_candidates(
return results;
}
normalize_in_place(bp.image);
auto display_map = gaussian_filter_2d(bp.image, grid.x_grid.size(), grid.z_grid.size(), kSmoothSigma);
apply_depth_gate(display_map, grid.z_grid, grid.x_grid.size(), min_depth_m, max_depth_m);
normalize_in_place(display_map);
auto display_map = normalize_bp_map(bp.image, grid, min_depth_m, max_depth_m, kSmoothSigma);
const auto incoherent_display_map =
normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma);
auto objects = find_bp_objects(display_map, grid);
add_local_prominence_metrics(objects, display_map, grid, min_depth_m, max_depth_m);
add_incoherent_support_metrics(objects, incoherent_display_map, bp.coherence_factor);
mark_sidelobe_candidates(objects, selected_traces, selection.x_tx, selection.x_rx);
add_bp_score_metrics(objects, live_config);
if (live_config.gpr_remove_sidelobe_objects_enabled) {
for (const auto& object : objects) {
@@ -1295,15 +1602,12 @@ void mark_sidelobe_candidates(
std::vector<std::vector<float>> point_rows{};
point_rows.reserve(objects.size());
for (const auto& object : objects) {
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
continue;
}
for (const auto* object : output_objects_sorted(objects, live_config)) {
point_rows.push_back(
{
static_cast<float>(object.x_m),
static_cast<float>(object.z_m),
static_cast<float>(object.peak),
static_cast<float>(object->x_m),
static_cast<float>(object->z_m),
static_cast<float>(object->selected_score),
}
);
}
+3
View File
@@ -266,6 +266,9 @@ Notes:
- `executable_path` is mandatory and must name the real Raspberry Pi binary.
- The producer appends `tty:<tty_path>` automatically; do not put `tty:` in
`radar.kamil_adc.args`.
- The producer derives the sweep point count from the Kamil ADC TTY stream.
`radar.sweep.start_hz` and `stop_hz` define the synthetic frequency axis;
`radar.sweep.points` is not a Kamil ADC setting.
- The laser-control driver is vendored under `python_app.hardware_full.laser_control`.
- `laser_control` and `kamil_adc` are treated as one hardware configuration.
Changing either section requires restarting acquisition so the lasers are
+3 -1
View File
@@ -149,7 +149,9 @@ Fields:
The TTY frame format is strict: packet start is `0x000A 0xFFFF 0xFFFF 0xFFFF`,
then each sweep point is `0x000A step data1 data2`. Steps must arrive as
`1..points`. `S21` is `data1 + j*data2`; `S11` is stored as explicit zeros.
`1..N`; `N` is derived from the stream when the next packet start arrives.
`radar.sweep.points` is not used by the Kamil ADC producer. `S21` is
`data1 + j*data2`; `S11` is stored as explicit zeros.
### `radar.laser_control`
+132
View File
@@ -0,0 +1,132 @@
import json
import random
import socket
import struct
import threading
import time
from typing import Any, Dict, Tuple
HOST = "127.0.0.1"
PORT = 8888
CLIENT_DEVICE_ID = 0
MIN_TEST_VLC = 5.0
MAX_TEST_VLC = 6.0
SEND_INTERVAL_SECONDS = 1.0
RECV_TIMEOUT_SECONDS = 1.0
CONNECT_TIMEOUT_SECONDS = 5.0
MAX_PAYLOAD_BYTES = 64 * 1024
HEADER_STRUCT = struct.Struct("<II")
def encode_packet(payload: Dict[str, Any], device_id: int) -> bytes:
"""Encode a JSON payload using the protocol binary header."""
payload_bytes = json.dumps(
payload,
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
return HEADER_STRUCT.pack(device_id, len(payload_bytes)) + payload_bytes
def recv_exactly(sock: socket.socket, size: int) -> bytes:
"""Receive an exact number of bytes from the socket."""
chunks = bytearray()
while len(chunks) < size:
chunk = sock.recv(size - len(chunks))
if not chunk:
raise ConnectionError("Connection closed by peer.")
chunks.extend(chunk)
return bytes(chunks)
def read_packet(sock: socket.socket) -> Tuple[int, Any]:
"""Read and decode a single packet from the server."""
header_bytes = recv_exactly(sock, HEADER_STRUCT.size)
device_id, payload_length = HEADER_STRUCT.unpack(header_bytes)
if payload_length > MAX_PAYLOAD_BYTES:
raise ValueError(
"Payload length %d exceeds the %d byte limit."
% (payload_length, MAX_PAYLOAD_BYTES)
)
payload_bytes = recv_exactly(sock, payload_length)
payload = json.loads(payload_bytes.decode("utf-8"))
return device_id, payload
def send_vlc_message(sock: socket.socket, device_id: int, vlc: float) -> None:
"""Send one test client payload to the server."""
payload = {"vlc": vlc}
sock.sendall(encode_packet(payload, device_id=device_id))
print(f'>>> sent {json.dumps(payload, ensure_ascii=False)} device_id={device_id}')
def receive_loop(sock: socket.socket, stop_event: threading.Event) -> None:
"""Print all packets received from the locator server."""
while not stop_event.is_set():
try:
device_id, payload = read_packet(sock)
except socket.timeout:
continue
except (ConnectionError, OSError, ValueError, json.JSONDecodeError) as error:
if not stop_event.is_set():
print("Receiver stopped:", error)
stop_event.set()
return
print(f"<<< received device_id={device_id}")
print(json.dumps(payload, ensure_ascii=False, indent=2))
def send_loop(sock: socket.socket, stop_event: threading.Event) -> None:
"""Send test vlc packets until the client is stopped."""
send_vlc_message(
sock,
CLIENT_DEVICE_ID,
round(random.uniform(MIN_TEST_VLC, MAX_TEST_VLC), 2),
)
if SEND_INTERVAL_SECONDS <= 0:
while not stop_event.is_set():
time.sleep(0.1)
return
while not stop_event.is_set():
time.sleep(SEND_INTERVAL_SECONDS)
send_vlc_message(
sock,
CLIENT_DEVICE_ID,
round(random.uniform(MIN_TEST_VLC, MAX_TEST_VLC), 2),
)
def main() -> None:
"""Run the client until interrupted or disconnected."""
stop_event = threading.Event()
with socket.create_connection(
(HOST, PORT),
timeout=CONNECT_TIMEOUT_SECONDS,
) as sock:
sock.settimeout(RECV_TIMEOUT_SECONDS)
print(f"Connected to {HOST}:{PORT}")
receiver = threading.Thread(
target=receive_loop,
args=(sock, stop_event),
daemon=True,
)
receiver.start()
try:
send_loop(sock, stop_event)
except KeyboardInterrupt:
print("Stopping client.")
finally:
stop_event.set()
if __name__ == "__main__":
main()
+17
View File
@@ -10,6 +10,7 @@ from collections import deque
from datetime import datetime
import html
import json
import os
from pathlib import Path
import traceback
@@ -217,6 +218,7 @@ class AppWindow(
self._on_processing_mode_changed(self._processing_mode.currentText())
self._write_live_processing_config()
self._timer.start()
self._maybe_auto_start_pipeline()
def _start_locator_service(self) -> None:
"""Start embedded locator TCP service without failing the GUI."""
@@ -268,6 +270,13 @@ class AppWindow(
def _resolve_startup_profile_path(self) -> Path:
"""Resolve active profile path from session-state or root fallback path."""
env_profile_path = os.environ.get("RADAR_SYSTEM_PROFILE", "").strip()
if env_profile_path:
profile_path = Path(env_profile_path).expanduser()
if not profile_path.is_absolute():
profile_path = (self._project_root / profile_path).resolve(strict=False)
return profile_path
try:
session_state = self._gui_session_state_store.load()
except Exception as exc:
@@ -287,6 +296,14 @@ class AppWindow(
profile_path = (self._project_root / profile_path).resolve(strict=False)
return profile_path
def _maybe_auto_start_pipeline(self) -> None:
"""Schedule pipeline start when requested by launcher environment."""
auto_start = os.environ.get("RADAR_SYSTEM_AUTO_START", "").strip().lower()
if auto_start not in {"1", "true", "yes", "on"}:
return
self._log("Auto-start requested by launcher.")
QTimer.singleShot(500, self._start_run)
def _normalize_profile_path(self, path: Path) -> Path:
"""Return normalized absolute profile path."""
return path.expanduser().resolve(strict=False)
@@ -24,7 +24,12 @@ class AppWindowLiveProcessingMixin:
self._live_processing_config(),
)
def _live_processing_config(self, *, history_command: str = "none") -> ProcessingLiveConfig:
def _live_processing_config(
self,
*,
history_command: str = "none",
reprocess_current_result: bool = True,
) -> ProcessingLiveConfig:
"""Build live processing config from current processing widgets."""
self._sync_bscan_frequency_limits_with_radar()
self._sync_gpr_frequency_limits_with_radar()
@@ -71,6 +76,9 @@ class AppWindowLiveProcessingMixin:
gpr_range_comp_power=float(self._gpr_range_comp_power.value()),
gpr_angle_comp_power=float(self._gpr_angle_comp_power.value()),
gpr_comp_power=float(self._legacy_gpr_comp_power.value()),
gpr_score_mode=self._gpr_score_mode.currentText(),
gpr_max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
gpr_draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
gpr_speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
gpr_look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
gpr_snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
@@ -80,15 +88,27 @@ class AppWindowLiveProcessingMixin:
gpr_background_subtract_enabled=gpr_background_enabled,
gpr_background_mean_count=gpr_background_mean_count,
gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()),
reprocess_current_result=bool(reprocess_current_result),
history_command_seq=int(self._history_command_seq),
history_command=str(history_command),
)
def _write_live_processing_config(self, *, history_command: str = "none", bump_history_seq: bool = False) -> None:
def _write_live_processing_config(
self,
*,
history_command: str = "none",
bump_history_seq: bool = False,
reprocess_current_result: bool = True,
) -> None:
"""Persist current live processing config to runtime JSON file."""
if bump_history_seq:
self._history_command_seq += 1
self._live_config_writer.write(self._live_processing_config(history_command=history_command))
self._live_config_writer.write(
self._live_processing_config(
history_command=history_command,
reprocess_current_result=reprocess_current_result,
)
)
def _on_processing_live_settings_changed(self, *_args) -> None:
"""Handle live-processing setting changes and trigger redraw when needed."""
@@ -215,11 +235,14 @@ class AppWindowLiveProcessingMixin:
f"freq={self._gpr_start_freq_mhz.value():g}..{self._gpr_stop_freq_mhz.value():g} MHz, "
f"range_comp={self._gpr_range_comp_power.value():g}, "
f"angle_comp={self._gpr_angle_comp_power.value():g}, "
f"score_mode={self._gpr_score_mode.currentText()}, "
f"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
f"mean_count={self._gpr_background_mean_count.value()}, "
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
f"render_mode={self._gpr_render_mode.currentText()}, "
f"min_score={self._gpr_min_visible_score.value():g})"
f"min_score={self._gpr_min_visible_score.value():g}, "
f"max_draw={self._gpr_max_detected_objects_to_draw.value()}, "
f"draw_top={self._gpr_draw_top_m_objects.value()})"
)
elif mode == "legacy_gpr":
self._log(
@@ -70,6 +70,52 @@ class AppWindowConfigProfileIOMixin:
self._pass_through_y_min_db.setEnabled(enabled)
self._pass_through_y_max_db.setEnabled(enabled)
def _set_radar_settings_mode(self, adc_mode: bool) -> None:
"""Show the radar settings panel appropriate for the loaded radar model."""
self._vna_radar_settings_panel.setVisible(not adc_mode)
self._adc_radar_settings_panel.setVisible(adc_mode)
def _sync_adc_settings_controls(self) -> None:
"""Enable optical-board controls according to enabled flag and mode."""
if not hasattr(self, "_optical_enabled_checkbox"):
return
enabled = bool(self._optical_enabled_checkbox.isChecked())
mode = self._optical_mode_combo.currentText()
common_widgets = [
self._optical_port_input,
self._optical_mode_combo,
self._optical_pi_coeff1_p_input,
self._optical_pi_coeff1_i_input,
self._optical_pi_coeff2_p_input,
self._optical_pi_coeff2_i_input,
]
manual_widgets = [
self._optical_manual_temp1_input,
self._optical_manual_temp2_input,
self._optical_manual_current1_input,
self._optical_manual_current2_input,
]
variation_widgets = [
self._optical_variation_type_combo,
self._optical_static_temp1_input,
self._optical_static_temp2_input,
self._optical_static_current1_input,
self._optical_static_current2_input,
self._optical_min_value_input,
self._optical_max_value_input,
self._optical_step_input,
self._optical_time_step_input,
self._optical_delay_time_input,
]
for widget in common_widgets:
widget.setEnabled(enabled)
for widget in manual_widgets:
widget.setEnabled(enabled and mode == "manual")
for widget in variation_widgets:
widget.setEnabled(enabled and mode == "variation")
self._optical_manual_panel.setVisible(mode == "manual")
self._optical_variation_panel.setVisible(mode == "variation")
def _apply_history_limit_from_config(self, config) -> None:
"""Resize in-memory history buffers to match the loaded config."""
history_limit = self._history_limit_for_config(config)
@@ -101,12 +147,17 @@ class AppWindowConfigProfileIOMixin:
self._defaults_config = profile.run_config.clone()
self._gui_defaults = profile.gui
self._remember_active_profile_path(output_path)
points_text = (
"points=from Kamil ADC stream"
if profile.run_config.is_kamil_adc
else f"points={profile.run_config.radar.sweep.points}"
)
self._log(
f"Config profile saved: path={output_path}, "
f"combos={len(profile.run_config.combos)}, "
f"sweep={profile.run_config.radar.sweep.start_hz:g}.."
f"{profile.run_config.radar.sweep.stop_hz:g} Hz, "
f"points={profile.run_config.radar.sweep.points}, "
f"{points_text}, "
f"ifbw={profile.run_config.radar.sweep.if_bandwidth_hz:g} Hz, "
f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, "
f"processing_mode={profile.gui.processing.selected_mode}"
@@ -203,6 +254,9 @@ class AppWindowConfigProfileIOMixin:
self._gpr_max_depth_m,
self._gpr_range_comp_power,
self._gpr_angle_comp_power,
self._gpr_score_mode,
self._gpr_max_detected_objects_to_draw,
self._gpr_draw_top_m_objects,
self._gpr_start_freq_mhz,
self._gpr_stop_freq_mhz,
self._gpr_background_subtract_enabled,
@@ -225,6 +279,7 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_start_freq_mhz,
self._legacy_gpr_stop_freq_mhz,
self._legacy_gpr_speed_m_s,
self._legacy_gpr_ignore_socket_speed_enabled,
self._legacy_gpr_look_angle_deg,
self._legacy_gpr_background_subtract_enabled,
self._legacy_gpr_background_mean_count,
@@ -237,6 +292,35 @@ class AppWindowConfigProfileIOMixin:
self._save_count,
self._save_path_input,
self._save_name_input,
self._adc_project_dir_input,
self._adc_executable_path_input,
self._adc_tty_path_input,
self._adc_args_input,
self._adc_env_input,
self._adc_startup_timeout_s_input,
self._adc_sweep_timeout_s_input,
self._adc_stop_timeout_s_input,
self._optical_enabled_checkbox,
self._optical_port_input,
self._optical_mode_combo,
self._optical_pi_coeff1_p_input,
self._optical_pi_coeff1_i_input,
self._optical_pi_coeff2_p_input,
self._optical_pi_coeff2_i_input,
self._optical_manual_temp1_input,
self._optical_manual_temp2_input,
self._optical_manual_current1_input,
self._optical_manual_current2_input,
self._optical_variation_type_combo,
self._optical_static_temp1_input,
self._optical_static_temp2_input,
self._optical_static_current1_input,
self._optical_static_current2_input,
self._optical_min_value_input,
self._optical_max_value_input,
self._optical_step_input,
self._optical_time_step_input,
self._optical_delay_time_input,
)
with ExitStack() as blockers:
@@ -249,6 +333,42 @@ class AppWindowConfigProfileIOMixin:
self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}")
self._power_input.setText(f"{config.radar.sweep.power_dbm:g}")
self._settling_ms.setText(str(int(config.runtime.settling_ms)))
self._set_radar_settings_mode(config.is_kamil_adc)
adc = config.radar.kamil_adc
optical = config.radar.laser_control
manual = optical.manual
variation = optical.variation
self._adc_project_dir_input.setText(str(adc.project_dir))
self._adc_executable_path_input.setText(str(adc.executable_path))
self._adc_tty_path_input.setText(str(adc.tty_path))
self._adc_args_input.setPlainText("\n".join(adc.args))
self._adc_env_input.setPlainText(json.dumps(adc.env, indent=2, sort_keys=True) if adc.env else "{}")
self._adc_startup_timeout_s_input.setText(f"{float(adc.startup_timeout_s):g}")
self._adc_sweep_timeout_s_input.setText(f"{float(adc.sweep_timeout_s):g}")
self._adc_stop_timeout_s_input.setText(f"{float(adc.stop_timeout_s):g}")
self._optical_enabled_checkbox.setChecked(bool(optical.enabled))
self._optical_port_input.setText(str(optical.port))
self._set_combo_current_text(self._optical_mode_combo, str(optical.mode))
self._optical_pi_coeff1_p_input.setText(str(int(optical.pi_coeff1_p)))
self._optical_pi_coeff1_i_input.setText(str(int(optical.pi_coeff1_i)))
self._optical_pi_coeff2_p_input.setText(str(int(optical.pi_coeff2_p)))
self._optical_pi_coeff2_i_input.setText(str(int(optical.pi_coeff2_i)))
self._optical_manual_temp1_input.setText(f"{float(manual.temp1):g}")
self._optical_manual_temp2_input.setText(f"{float(manual.temp2):g}")
self._optical_manual_current1_input.setText(f"{float(manual.current1):g}")
self._optical_manual_current2_input.setText(f"{float(manual.current2):g}")
self._set_combo_current_text(self._optical_variation_type_combo, str(variation.variation_type))
self._optical_static_temp1_input.setText(f"{float(variation.static_temp1):g}")
self._optical_static_temp2_input.setText(f"{float(variation.static_temp2):g}")
self._optical_static_current1_input.setText(f"{float(variation.static_current1):g}")
self._optical_static_current2_input.setText(f"{float(variation.static_current2):g}")
self._optical_min_value_input.setText(f"{float(variation.min_value):g}")
self._optical_max_value_input.setText(f"{float(variation.max_value):g}")
self._optical_step_input.setText(f"{float(variation.step):g}")
self._optical_time_step_input.setText(str(int(variation.time_step)))
self._optical_delay_time_input.setText(str(int(variation.delay_time)))
self._sync_adc_settings_controls()
self._combos_text.setText(str(gui_state.switches.combos_text))
self._single_combo_output.setText(str(gui_state.switches.single_output))
@@ -289,6 +409,11 @@ class AppWindowConfigProfileIOMixin:
self._gpr_max_depth_m.setValue(float(gui_state.processing.gpr.max_depth_m))
self._gpr_range_comp_power.setValue(float(gui_state.processing.gpr.range_comp_power))
self._gpr_angle_comp_power.setValue(float(gui_state.processing.gpr.angle_comp_power))
self._set_combo_current_text(self._gpr_score_mode, gui_state.processing.gpr.score_mode)
self._gpr_max_detected_objects_to_draw.setValue(
int(gui_state.processing.gpr.max_detected_objects_to_draw)
)
self._gpr_draw_top_m_objects.setValue(int(gui_state.processing.gpr.draw_top_m_objects))
self._gpr_start_freq_mhz.setValue(float(gui_state.processing.gpr.start_freq_mhz))
self._gpr_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
self._gpr_background_subtract_enabled.setChecked(
@@ -316,6 +441,9 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_start_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.start_freq_mhz))
self._legacy_gpr_stop_freq_mhz.setValue(float(gui_state.processing.legacy_gpr.stop_freq_mhz))
self._legacy_gpr_speed_m_s.setValue(float(gui_state.processing.legacy_gpr.speed_m_s))
self._legacy_gpr_ignore_socket_speed_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.ignore_socket_speed_enabled)
)
self._legacy_gpr_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
self._legacy_gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.background_subtract_enabled)
@@ -2,6 +2,8 @@
from __future__ import annotations
import json
from python_app.models.gui_profile_model import (
GuiBscanStateModel,
GuiDataActionsStateModel,
@@ -42,6 +44,27 @@ class AppWindowConfigStateBuildersMixin:
values.append(int(token))
return values
@staticmethod
def _parse_multiline_string_list(text: str) -> list[str]:
"""Parse one command argument per non-empty line."""
return [line.strip() for line in text.splitlines() if line.strip()]
@staticmethod
def _parse_string_env_json(text: str) -> dict[str, str]:
"""Parse strict string-to-string environment JSON."""
cleaned = text.strip()
if not cleaned:
return {}
payload = json.loads(cleaned)
if not isinstance(payload, dict):
raise ValueError("Environment JSON must be an object")
env: dict[str, str] = {}
for key, value in payload.items():
if not isinstance(key, str) or not isinstance(value, str):
raise ValueError("Environment JSON must contain only string keys and values")
env[key] = value
return env
@staticmethod
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
"""Parse line-based Tx geometry editor text."""
@@ -172,6 +195,9 @@ class AppWindowConfigStateBuildersMixin:
max_depth_m=14.0,
range_comp_power=0.28,
angle_comp_power=0.10,
score_mode="combined",
max_detected_objects_to_draw=5,
draw_top_m_objects=2,
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
background_subtract_enabled=True,
@@ -193,6 +219,7 @@ class AppWindowConfigStateBuildersMixin:
start_freq_mhz=3000.0,
stop_freq_mhz=6000.0,
speed_m_s=0.0,
ignore_socket_speed_enabled=False,
look_angle_deg=0.0,
snr_thresh=4.5,
snr_comp_max=25.0,
@@ -280,6 +307,9 @@ class AppWindowConfigStateBuildersMixin:
max_depth_m=float(self._gpr_max_depth_m.value()),
range_comp_power=float(self._gpr_range_comp_power.value()),
angle_comp_power=float(self._gpr_angle_comp_power.value()),
score_mode=self._gpr_score_mode.currentText(),
max_detected_objects_to_draw=int(self._gpr_max_detected_objects_to_draw.value()),
draw_top_m_objects=int(self._gpr_draw_top_m_objects.value()),
start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()),
@@ -302,6 +332,7 @@ class AppWindowConfigStateBuildersMixin:
start_freq_mhz=float(self._legacy_gpr_start_freq_mhz.value()),
stop_freq_mhz=float(self._legacy_gpr_stop_freq_mhz.value()),
speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
ignore_socket_speed_enabled=bool(self._legacy_gpr_ignore_socket_speed_enabled.isChecked()),
look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
snr_comp_max=float(self._legacy_gpr_snr_comp_max.value()),
@@ -343,6 +374,8 @@ class AppWindowConfigStateBuildersMixin:
config.radar.sweep.points = int(self._points_input.text().strip())
config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
config.radar.sweep.power_dbm = float(self._power_input.text().strip())
if config.is_kamil_adc:
self._apply_adc_settings_to_config(config)
config.runtime.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path)
@@ -396,6 +429,16 @@ class AppWindowConfigStateBuildersMixin:
def _radar_key_from_ui(self) -> str:
"""Build current radar key directly from radar widgets only."""
model_name = self._defaults_config.radar.model or RunConfigModel.LIBREVNA_MODEL
extra_parts = self._defaults_config.radar_key_extra_parts()
if self._defaults_config.is_kamil_adc:
config = self._defaults_config.clone()
config.radar.sweep.start_hz = float(self._start_hz_input.text().strip())
config.radar.sweep.stop_hz = float(self._stop_hz_input.text().strip())
config.radar.sweep.points = int(self._points_input.text().strip())
config.radar.sweep.if_bandwidth_hz = float(self._ifbw_input.text().strip())
config.radar.sweep.power_dbm = float(self._power_input.text().strip())
self._apply_adc_settings_to_config(config)
extra_parts = config.radar_key_extra_parts()
return radar_key_from_config(
model_name=model_name,
serial=self._defaults_config.radar.serial,
@@ -404,9 +447,46 @@ class AppWindowConfigStateBuildersMixin:
sweep_points=int(self._points_input.text().strip()),
ifbw_hz=float(self._ifbw_input.text().strip()),
power_dbm=float(self._power_input.text().strip()),
extra_serials=self._defaults_config.radar_key_extra_parts() or None,
extra_serials=extra_parts or None,
)
def _apply_adc_settings_to_config(self, config: RunConfigModel) -> None:
"""Copy visible ADC collector and optical-board settings into config."""
adc = config.radar.kamil_adc
adc.project_dir = self._adc_project_dir_input.text().strip()
adc.executable_path = self._adc_executable_path_input.text().strip()
adc.tty_path = self._adc_tty_path_input.text().strip()
adc.args = self._parse_multiline_string_list(self._adc_args_input.toPlainText())
adc.env = self._parse_string_env_json(self._adc_env_input.toPlainText())
adc.startup_timeout_s = float(self._adc_startup_timeout_s_input.text().strip())
adc.sweep_timeout_s = float(self._adc_sweep_timeout_s_input.text().strip())
adc.stop_timeout_s = float(self._adc_stop_timeout_s_input.text().strip())
optical = config.radar.laser_control
optical.enabled = bool(self._optical_enabled_checkbox.isChecked())
optical.port = self._optical_port_input.text().strip()
optical.mode = self._optical_mode_combo.currentText()
optical.pi_coeff1_p = int(self._optical_pi_coeff1_p_input.text().strip())
optical.pi_coeff1_i = int(self._optical_pi_coeff1_i_input.text().strip())
optical.pi_coeff2_p = int(self._optical_pi_coeff2_p_input.text().strip())
optical.pi_coeff2_i = int(self._optical_pi_coeff2_i_input.text().strip())
optical.manual.temp1 = float(self._optical_manual_temp1_input.text().strip())
optical.manual.temp2 = float(self._optical_manual_temp2_input.text().strip())
optical.manual.current1 = float(self._optical_manual_current1_input.text().strip())
optical.manual.current2 = float(self._optical_manual_current2_input.text().strip())
optical.variation.variation_type = self._optical_variation_type_combo.currentText()
optical.variation.static_temp1 = float(self._optical_static_temp1_input.text().strip())
optical.variation.static_temp2 = float(self._optical_static_temp2_input.text().strip())
optical.variation.static_current1 = float(self._optical_static_current1_input.text().strip())
optical.variation.static_current2 = float(self._optical_static_current2_input.text().strip())
optical.variation.min_value = float(self._optical_min_value_input.text().strip())
optical.variation.max_value = float(self._optical_max_value_input.text().strip())
optical.variation.step = float(self._optical_step_input.text().strip())
optical.variation.time_step = int(self._optical_time_step_input.text().strip())
optical.variation.delay_time = int(self._optical_delay_time_input.text().strip())
@staticmethod
def _switches_are_effectively_static(config: RunConfigModel) -> bool:
"""Return `True` when non-GPR switch setup effectively yields one fixed combo."""
@@ -8,6 +8,7 @@ from PyQt6.QtCore import QSignalBlocker
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control
from python_app.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel
@@ -86,7 +87,7 @@ class AppWindowPipelineMixin:
self._config_writer.prepare_preprocess_bundles(self._store, radar_key, config)
config.runtime.continuous = not single_capture
if not single_capture:
if not single_capture and not config.is_kamil_adc:
self._prepare_radar_for_native_acquisition(config)
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json")
@@ -168,11 +169,16 @@ class AppWindowPipelineMixin:
"matches the current config"
)
self._prepare_radar_for_native_acquisition(config)
points_text = (
"points=from Kamil ADC stream"
if config.is_kamil_adc
else f"points={config.radar.sweep.points}"
)
self._log(
"Radar settings applied: "
f"start={config.radar.sweep.start_hz:g} Hz, "
f"stop={config.radar.sweep.stop_hz:g} Hz, "
f"points={config.radar.sweep.points}, "
f"{points_text}, "
f"ifbw={config.radar.sweep.if_bandwidth_hz:g} Hz, "
f"power={config.radar.sweep.power_dbm:g} dBm"
)
@@ -193,7 +199,10 @@ class AppWindowPipelineMixin:
self._log("Multi-device raw producer will configure all LibreVNA devices")
return
if config.is_kamil_adc:
self._log("Kamil ADC raw producer will apply laser_control and start the external collector")
if apply_kamil_adc_laser_control(config):
self._log("Kamil ADC laser_control applied via Apply Radar")
else:
self._log("Kamil ADC laser_control skipped because it is disabled")
return
radar_service = create_single_radar_service(config)
@@ -272,6 +281,7 @@ class AppWindowPipelineMixin:
try:
self._drain_locator_speed_updates()
self._drain_locator_log_updates()
if self._raw_reader is not None:
self._read_all_raw()
self._read_all_preprocessed()
@@ -483,6 +493,8 @@ class AppWindowPipelineMixin:
return
if self._processing_mode.currentText() != "legacy_gpr":
return
if self._legacy_gpr_ignore_socket_speed_enabled.isChecked():
return
previous_speed_m_s = float(self._legacy_gpr_speed_m_s.value())
with QSignalBlocker(self._legacy_gpr_speed_m_s):
@@ -491,7 +503,14 @@ class AppWindowPipelineMixin:
if current_speed_m_s == previous_speed_m_s:
return
self._write_live_processing_config()
self._write_live_processing_config(reprocess_current_result=False)
def _drain_locator_log_updates(self) -> None:
"""Append queued locator socket traffic messages to the runtime log."""
if self._locator_service is None:
return
for message in self._locator_service.drain_log_updates():
self._log(message)
def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None:
"""Publish one locator snapshot from a GPR result collection."""
@@ -501,6 +520,7 @@ class AppWindowPipelineMixin:
collection,
self._gpr_locator_threshold(),
visible_bounds=self._gpr_visible_object_bounds(),
object_draw_limits=self._gpr_draw_limits(),
)
def _publish_locator_snapshot_from_latest_result(self) -> None:
@@ -254,7 +254,15 @@ class AppWindowGprPlotMixin:
points_payload = self._collection_payload_by_name(collection, "gpr_points", kind=4)
if points_payload is not None and np.asarray(points_payload.table).size > 0:
points = np.asarray(points_payload.table, dtype=np.float32)
points = (
self._filtered_gpr_object_rows(collection)
if self._processing_mode.currentText() == "gpr"
else np.asarray(points_payload.table, dtype=np.float32)
)
else:
points = np.zeros((0, 3), dtype=np.float32)
if points.size > 0:
self._gpr_points_item.setData(
x=points[:, 0],
y=points[:, 1],
@@ -368,6 +376,26 @@ class AppWindowGprPlotMixin:
return float(self._legacy_gpr_min_visible_pair_count.value())
return float(self._gpr_min_visible_score.value())
def _gpr_draw_limits(self) -> tuple[int, int] | None:
"""Return GPR object draw limits, or None for legacy GPR."""
if self._processing_mode.currentText() == "legacy_gpr":
return None
return (
int(self._gpr_max_detected_objects_to_draw.value()),
int(self._gpr_draw_top_m_objects.value()),
)
@staticmethod
def _apply_object_draw_limits(rows: np.ndarray, limits: tuple[int, int] | None) -> np.ndarray:
"""Apply object count/top-M drawing rules to already-filtered rows."""
if limits is None or rows.size == 0:
return rows
max_detected_objects, draw_top_objects = limits
if rows.shape[0] > int(max_detected_objects):
return np.zeros((0, rows.shape[1]), dtype=rows.dtype)
return rows[: max(0, int(draw_top_objects))]
@staticmethod
def _gpr_display_y_min(z_min: float, z_max: float) -> float:
"""Return lower display bound, preserving surface markers only when surface is visible."""
@@ -483,7 +511,7 @@ class AppWindowGprPlotMixin:
return extract_gpr_object_rows(collection)
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray:
"""Return object rows filtered by minimum pair count and visible X/Z bounds."""
"""Return object rows filtered by threshold, visible X/Z bounds, and active GPR draw limits."""
rows = self._gpr_object_rows(collection)
if rows.size == 0:
return rows
@@ -499,7 +527,7 @@ class AppWindowGprPlotMixin:
& (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max)
)
return rows[visible_mask]
return self._apply_object_draw_limits(rows[visible_mask], self._gpr_draw_limits())
def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
"""Draw only detected GPR objects inside configured X/Z bounds."""
@@ -3,12 +3,14 @@
from __future__ import annotations
from python_app.gui.preprocess_dialog import PreprocessDialog
from python_app.hardware_full.kamil_adc_service import KamilAdcService
from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS,
VISIBLE_PREPROCESS_ASSET_KEYS,
preprocess_asset_channel,
preprocess_asset_display_name,
)
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
from python_app.workflows.multi_radar_capture_workflow import (
MultiRadarCaptureBatch,
MultiRadarSequentialCaptureSession,
@@ -185,6 +187,8 @@ class AppWindowPreprocessMixin:
dialog.undo_last_requested.connect(self._undo_last_capture)
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
dialog.abort_sequence_requested.connect(self._abort_capture_sequence)
dialog.create_kamil_adc_neutral_sets_requested.connect(self._create_kamil_adc_neutral_sets)
dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc)
dialog.set_radar_config_summary(
directory_path=self._preprocess_radar_scan_summary.directory_path,
json_file_count=self._preprocess_radar_scan_summary.json_file_count,
@@ -254,6 +258,7 @@ class AppWindowPreprocessMixin:
f"{preprocess_asset_display_name(key)}={len(names)}"
for key, names in available_sets.items()
)
dialog.set_kamil_adc_neutral_sets_visible(self._defaults_config.is_kamil_adc)
self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}")
if unavailable_selections:
self._log_warning(
@@ -338,6 +343,88 @@ class AppWindowPreprocessMixin:
self._show_exception(f"Failed to start {kind} sequence", exc)
self._resume_pipeline_if_needed()
def _create_kamil_adc_neutral_sets(self) -> None:
"""Save neutral S21 calibration/reference sets for the current Kamil ADC settings."""
if self._capture_session is not None:
self._show_error(
"Cannot create neutral sets during active capture sequence",
details=self._capture_state_details(),
)
return
dialog = self._ensure_preprocess_dialog()
set_name = dialog.set_name()
if not set_name:
self._show_error("Set name is required")
return
pipeline_was_paused = False
try:
config = self._build_config()
if not config.is_kamil_adc:
self._show_error("Neutral S21 sets are available only for kamil_adc")
return
radar_key = self._radar_key(config)
duplicate_assets = [
preprocess_asset_display_name(key)
for key in ("s21_calibration", "s21_reference")
if set_name in self._store.list_sets(PREPROCESS_ASSET_SPECS[key].set_kind, radar_key)
]
if duplicate_assets:
raise RuntimeError(
f"Set '{set_name}' already exists for: " + ", ".join(duplicate_assets)
)
if self._supervisor.is_running():
self._log("Pipeline paused for Kamil ADC neutral-set creation")
self._stop_run()
pipeline_was_paused = True
point_count = self._read_kamil_adc_point_count(config)
calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count)
self._store.save_set("s21_calibration", radar_key, set_name, calibration)
self._store.save_set("s21_reference", radar_key, set_name, reference)
self._selected_preprocess_sets["s21_calibration"] = set_name
self._selected_preprocess_sets["s21_reference"] = set_name
self._selected_preprocess_radar_key = radar_key
self._processor_run_signature = None
self._history_run_signature = None
self._reset_runtime_history()
self._refresh_sets()
dialog.set_status(
f"Neutral S21 sets saved: {set_name} ({len(calibration.traces)} combos, {point_count} points)"
)
self._log(
"Kamil ADC neutral S21 sets saved: "
f"set={set_name}, radar_key={radar_key}, combos={len(calibration.traces)}, points={point_count}"
)
except Exception as exc: # noqa: BLE001
self._show_exception("Failed to create Kamil ADC neutral sets", exc)
finally:
if pipeline_was_paused:
self._start_run()
def _read_kamil_adc_point_count(self, config) -> int:
"""Read one Kamil ADC sweep and return its actual point count."""
dialog = self._ensure_preprocess_dialog()
dialog.set_status("Reading one Kamil ADC sweep to detect point count...")
self._log("Reading one Kamil ADC sweep to detect neutral-set point count")
radar = KamilAdcService(config)
try:
radar.open()
radar.configure(config.radar.sweep)
sweep = radar.acquire()
finally:
radar.close()
point_count = int(sweep.x.size)
if point_count <= 0:
raise RuntimeError("Kamil ADC returned an empty sweep while detecting point count")
return point_count
def _build_single_radar_capture_session(
self,
*,
@@ -209,6 +209,18 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_angle_comp_power.setSingleStep(0.01)
owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power))
owner._gpr_score_mode = QComboBox()
owner._gpr_score_mode.addItems(["peak", "combined"])
owner._set_combo_current_text(owner._gpr_score_mode, gpr_live_defaults.score_mode)
owner._gpr_max_detected_objects_to_draw = QSpinBox()
owner._gpr_max_detected_objects_to_draw.setRange(0, 10_000)
owner._gpr_max_detected_objects_to_draw.setValue(int(gpr_live_defaults.max_detected_objects_to_draw))
owner._gpr_draw_top_m_objects = QSpinBox()
owner._gpr_draw_top_m_objects.setRange(0, 10_000)
owner._gpr_draw_top_m_objects.setValue(int(gpr_live_defaults.draw_top_m_objects))
owner._gpr_start_freq_mhz = QDoubleSpinBox()
owner._gpr_start_freq_mhz.setDecimals(1)
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0)
@@ -274,8 +286,11 @@ def build_processing_group(owner) -> QGroupBox:
("Max depth m", owner._gpr_max_depth_m),
("Range comp power", owner._gpr_range_comp_power),
("Angle comp power", owner._gpr_angle_comp_power),
("Score mode", owner._gpr_score_mode),
("Render mode", owner._gpr_render_mode),
("Min visible score", owner._gpr_min_visible_score),
("Max detected objects", owner._gpr_max_detected_objects_to_draw),
("Draw top M objects", owner._gpr_draw_top_m_objects),
("Start MHz", owner._gpr_start_freq_mhz),
("Stop MHz", owner._gpr_stop_freq_mhz),
("Visible X min m", owner._gpr_visible_x_min_m),
@@ -348,6 +363,11 @@ def build_processing_group(owner) -> QGroupBox:
owner._legacy_gpr_speed_m_s.setSingleStep(0.01)
owner._legacy_gpr_speed_m_s.setValue(float(legacy_gpr_defaults.speed_m_s))
owner._legacy_gpr_ignore_socket_speed_enabled = QCheckBox("Ignore socket speed")
owner._legacy_gpr_ignore_socket_speed_enabled.setChecked(
bool(legacy_gpr_defaults.ignore_socket_speed_enabled)
)
owner._legacy_gpr_look_angle_deg = QDoubleSpinBox()
owner._legacy_gpr_look_angle_deg.setDecimals(2)
owner._legacy_gpr_look_angle_deg.setRange(-90.0, 90.0)
@@ -405,6 +425,7 @@ def build_processing_group(owner) -> QGroupBox:
("SNR thresh", owner._legacy_gpr_snr_thresh),
("SNR comp max", owner._legacy_gpr_snr_comp_max),
("Speed m/s", owner._legacy_gpr_speed_m_s),
owner._legacy_gpr_ignore_socket_speed_enabled,
("Render mode", owner._legacy_gpr_render_mode),
("Min visible pairs", owner._legacy_gpr_min_visible_pair_count),
("Start MHz", owner._legacy_gpr_start_freq_mhz),
@@ -445,6 +466,7 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_max_depth_m.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_range_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_angle_comp_power.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_score_mode.currentTextChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_start_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_background_subtract_enabled.toggled.connect(owner._on_processing_live_settings_changed)
@@ -452,6 +474,8 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_remove_sidelobe_objects_enabled.toggled.connect(owner._on_processing_live_settings_changed)
owner._gpr_render_mode.currentTextChanged.connect(owner._on_gpr_visual_settings_changed)
owner._gpr_min_visible_score.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
owner._gpr_max_detected_objects_to_draw.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
owner._gpr_draw_top_m_objects.valueChanged.connect(owner._on_gpr_locator_threshold_changed)
owner._gpr_visible_x_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
owner._gpr_visible_x_max_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
@@ -2,7 +2,18 @@
from __future__ import annotations
from PyQt6.QtWidgets import QGroupBox, QLabel, QLineEdit, QVBoxLayout
import json
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QGroupBox,
QLabel,
QLineEdit,
QPlainTextEdit,
QVBoxLayout,
QWidget,
)
from python_app.gui.controllers.sections.layout_helpers import build_two_column_form_widget
@@ -15,6 +26,11 @@ def build_radar_group(owner) -> QGroupBox:
layout.setSpacing(8)
defaults = owner._defaults_config.radar
owner._vna_radar_settings_panel = QWidget(group)
vna_layout = QVBoxLayout(owner._vna_radar_settings_panel)
vna_layout.setContentsMargins(0, 0, 0, 0)
vna_layout.setSpacing(8)
owner._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}")
owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}")
owner._points_input = QLineEdit(str(defaults.sweep.points))
@@ -36,7 +52,7 @@ def build_radar_group(owner) -> QGroupBox:
owner._radar_limits_hint = QLabel("Device limits are not available.")
owner._radar_limits_hint.setObjectName("hintLabel")
layout.addWidget(
vna_layout.addWidget(
build_two_column_form_widget(
group,
[
@@ -48,5 +64,174 @@ def build_radar_group(owner) -> QGroupBox:
],
)
)
layout.addWidget(owner._radar_limits_hint)
vna_layout.addWidget(owner._radar_limits_hint)
owner._adc_radar_settings_panel = _build_adc_settings_panel(owner, group)
layout.addWidget(owner._vna_radar_settings_panel)
layout.addWidget(owner._adc_radar_settings_panel)
owner._set_radar_settings_mode(owner._defaults_config.is_kamil_adc)
return group
def _build_adc_settings_panel(owner, parent: QWidget) -> QWidget:
"""Create controls for the external ADC collector and optical board."""
panel = QWidget(parent)
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
adc = owner._defaults_config.radar.kamil_adc
optical = owner._defaults_config.radar.laser_control
manual = optical.manual
variation = optical.variation
owner._adc_project_dir_input = QLineEdit(adc.project_dir)
owner._adc_executable_path_input = QLineEdit(adc.executable_path)
owner._adc_tty_path_input = QLineEdit(adc.tty_path)
owner._adc_args_input = _plain_text("\n".join(adc.args))
owner._adc_env_input = _plain_text(json.dumps(adc.env, indent=2, sort_keys=True) if adc.env else "{}")
owner._adc_startup_timeout_s_input = QLineEdit(f"{adc.startup_timeout_s:g}")
owner._adc_sweep_timeout_s_input = QLineEdit(f"{adc.sweep_timeout_s:g}")
owner._adc_stop_timeout_s_input = QLineEdit(f"{adc.stop_timeout_s:g}")
owner._optical_enabled_checkbox = QCheckBox()
owner._optical_enabled_checkbox.setChecked(bool(optical.enabled))
owner._optical_port_input = QLineEdit(optical.port)
owner._optical_mode_combo = QComboBox()
owner._optical_mode_combo.addItems(["manual", "variation"])
owner._set_combo_current_text(owner._optical_mode_combo, optical.mode)
owner._optical_pi_coeff1_p_input = QLineEdit(str(optical.pi_coeff1_p))
owner._optical_pi_coeff1_i_input = QLineEdit(str(optical.pi_coeff1_i))
owner._optical_pi_coeff2_p_input = QLineEdit(str(optical.pi_coeff2_p))
owner._optical_pi_coeff2_i_input = QLineEdit(str(optical.pi_coeff2_i))
owner._optical_manual_temp1_input = QLineEdit(f"{manual.temp1:g}")
owner._optical_manual_temp2_input = QLineEdit(f"{manual.temp2:g}")
owner._optical_manual_current1_input = QLineEdit(f"{manual.current1:g}")
owner._optical_manual_current2_input = QLineEdit(f"{manual.current2:g}")
owner._optical_variation_type_combo = QComboBox()
owner._optical_variation_type_combo.addItems(
[
"CHANGE_CURRENT_LD1",
"CHANGE_CURRENT_LD2",
"CHANGE_TEMPERATURE_LD1",
"CHANGE_TEMPERATURE_LD2",
]
)
owner._set_combo_current_text(owner._optical_variation_type_combo, variation.variation_type)
owner._optical_static_temp1_input = QLineEdit(f"{variation.static_temp1:g}")
owner._optical_static_temp2_input = QLineEdit(f"{variation.static_temp2:g}")
owner._optical_static_current1_input = QLineEdit(f"{variation.static_current1:g}")
owner._optical_static_current2_input = QLineEdit(f"{variation.static_current2:g}")
owner._optical_min_value_input = QLineEdit(f"{variation.min_value:g}")
owner._optical_max_value_input = QLineEdit(f"{variation.max_value:g}")
owner._optical_step_input = QLineEdit(f"{variation.step:g}")
owner._optical_time_step_input = QLineEdit(str(variation.time_step))
owner._optical_delay_time_input = QLineEdit(str(variation.delay_time))
layout.addWidget(
build_two_column_form_widget(
panel,
[
("Project dir", owner._adc_project_dir_input),
("Executable path", owner._adc_executable_path_input),
("TTY path", owner._adc_tty_path_input),
("Arguments", owner._adc_args_input),
("Environment JSON", owner._adc_env_input),
("Startup timeout s", owner._adc_startup_timeout_s_input),
("Read timeout s", owner._adc_sweep_timeout_s_input),
("Stop timeout s", owner._adc_stop_timeout_s_input),
("Board enabled", owner._optical_enabled_checkbox),
("Board port", owner._optical_port_input),
("Board mode", owner._optical_mode_combo),
("PI 1 P", owner._optical_pi_coeff1_p_input),
("PI 1 I", owner._optical_pi_coeff1_i_input),
("PI 2 P", owner._optical_pi_coeff2_p_input),
("PI 2 I", owner._optical_pi_coeff2_i_input),
],
split_index=8,
)
)
owner._optical_manual_panel = build_two_column_form_widget(
panel,
[
("Temperature 1", owner._optical_manual_temp1_input),
("Temperature 2", owner._optical_manual_temp2_input),
("Current 1", owner._optical_manual_current1_input),
("Current 2", owner._optical_manual_current2_input),
],
)
owner._optical_variation_panel = build_two_column_form_widget(
panel,
[
("Variation type", owner._optical_variation_type_combo),
("Static temp 1", owner._optical_static_temp1_input),
("Static temp 2", owner._optical_static_temp2_input),
("Static current 1", owner._optical_static_current1_input),
("Static current 2", owner._optical_static_current2_input),
("Min value", owner._optical_min_value_input),
("Max value", owner._optical_max_value_input),
("Step", owner._optical_step_input),
("Time step", owner._optical_time_step_input),
("Delay time", owner._optical_delay_time_input),
],
split_index=5,
)
layout.addWidget(owner._optical_manual_panel)
layout.addWidget(owner._optical_variation_panel)
_connect_adc_settings(owner)
owner._sync_adc_settings_controls()
return panel
def _plain_text(text: str) -> QPlainTextEdit:
widget = QPlainTextEdit(text)
widget.setMaximumHeight(76)
return widget
def _connect_adc_settings(owner) -> None:
text_widgets = [
owner._adc_project_dir_input,
owner._adc_executable_path_input,
owner._adc_tty_path_input,
owner._adc_startup_timeout_s_input,
owner._adc_sweep_timeout_s_input,
owner._adc_stop_timeout_s_input,
owner._optical_port_input,
owner._optical_pi_coeff1_p_input,
owner._optical_pi_coeff1_i_input,
owner._optical_pi_coeff2_p_input,
owner._optical_pi_coeff2_i_input,
owner._optical_manual_temp1_input,
owner._optical_manual_temp2_input,
owner._optical_manual_current1_input,
owner._optical_manual_current2_input,
owner._optical_static_temp1_input,
owner._optical_static_temp2_input,
owner._optical_static_current1_input,
owner._optical_static_current2_input,
owner._optical_min_value_input,
owner._optical_max_value_input,
owner._optical_step_input,
owner._optical_time_step_input,
owner._optical_delay_time_input,
]
for widget in text_widgets:
widget.editingFinished.connect(owner._reset_preprocess_selection_after_radar_key_change)
owner._adc_args_input.textChanged.connect(owner._reset_preprocess_selection_after_radar_key_change)
owner._adc_env_input.textChanged.connect(owner._reset_preprocess_selection_after_radar_key_change)
owner._optical_variation_type_combo.currentTextChanged.connect(
owner._reset_preprocess_selection_after_radar_key_change
)
def sync_and_reset() -> None:
owner._sync_adc_settings_controls()
owner._reset_preprocess_selection_after_radar_key_change()
owner._optical_enabled_checkbox.toggled.connect(sync_and_reset)
owner._optical_mode_combo.currentTextChanged.connect(sync_and_reset)
+15
View File
@@ -44,6 +44,7 @@ class PreprocessDialog(QDialog):
undo_last_requested = pyqtSignal()
finalize_sequence_requested = pyqtSignal()
abort_sequence_requested = pyqtSignal()
create_kamil_adc_neutral_sets_requested = pyqtSignal()
def __init__(self, parent=None) -> None:
"""Initialize window metadata and compose dialog UI."""
@@ -88,8 +89,17 @@ class PreprocessDialog(QDialog):
self._set_name_input = QLineEdit("set_001", group)
refresh_button = QPushButton("Refresh Sets", group)
refresh_button.clicked.connect(self.refresh_requested.emit)
self._kamil_adc_neutral_sets_button = QPushButton("Create Neutral S21 Sets", group)
self._kamil_adc_neutral_sets_button.setToolTip(
"Save S21 calibration=1 and S21 reference=0 for the current Kamil ADC settings."
)
self._kamil_adc_neutral_sets_button.clicked.connect(
self.create_kamil_adc_neutral_sets_requested.emit
)
self._kamil_adc_neutral_sets_button.setVisible(False)
header_row.addWidget(QLabel("Set name"))
header_row.addWidget(self._set_name_input, stretch=1)
header_row.addWidget(self._kamil_adc_neutral_sets_button)
header_row.addWidget(refresh_button)
layout.addLayout(header_row)
layout.addWidget(self._build_radar_config_group(group))
@@ -369,6 +379,11 @@ class PreprocessDialog(QDialog):
"""Set short human-readable status line."""
self._status_label.setText(message)
def set_kamil_adc_neutral_sets_visible(self, visible: bool) -> None:
"""Show Kamil ADC neutral-set shortcut only in the matching radar mode."""
self._kamil_adc_neutral_sets_button.setVisible(bool(visible))
self._kamil_adc_neutral_sets_button.setEnabled(bool(visible))
def reset_preview(self) -> None:
"""Clear preview surface and restore default empty-state text when possible."""
if self._preview_plot is not None:
+2 -1
View File
@@ -65,6 +65,7 @@ def build_run_history_signature(
"""Build deterministic signature to detect run-settings changes (excluding live processing params)."""
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS)
sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points)
return (
str(config.radar.model),
str(config.radar.driver_mode),
@@ -73,7 +74,7 @@ def build_run_history_signature(
bool(config.radar.multi_device.force_external_reference),
float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_hz),
int(config.radar.sweep.points),
sweep_points_signature,
float(config.radar.sweep.if_bandwidth_hz),
float(config.radar.sweep.power_dbm),
str(config.input_switch.driver_mode),
+191 -122
View File
@@ -10,10 +10,10 @@ import os
from pathlib import Path
import select
import signal
import stat
import struct
import subprocess
import time
from typing import Any
import numpy as np
@@ -67,6 +67,7 @@ class KamilAdcTtyReader:
tty_path: str
_fd: int | None = field(init=False, default=None, repr=False)
_buffer: bytearray = field(init=False, default_factory=bytearray, repr=False)
_packet_start_pending: bool = field(init=False, default=False, repr=False)
def open(self) -> None:
"""Open the configured TTY path for binary reads."""
@@ -83,36 +84,67 @@ class KamilAdcTtyReader:
finally:
self._fd = None
self._buffer.clear()
self._packet_start_pending = False
def read_sweep(
self,
*,
points: int,
timeout_s: float,
process: subprocess.Popen[bytes] | None = None,
expected_points: int | None = None,
) -> np.ndarray:
"""Read one packet-start marker followed by exactly `points` IQ frames."""
if points <= 0:
raise ValueError("Kamil ADC sweep points must be > 0")
if points > KAMIL_ADC_MAX_STEP:
raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}")
"""Read one full packet, optionally discarding packets with an unexpected point count."""
if self._fd is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
if expected_points is not None:
if expected_points <= 0:
raise ValueError("Kamil ADC expected points must be > 0")
if expected_points > KAMIL_ADC_MAX_STEP:
raise ValueError(f"Kamil ADC expected points must be <= {KAMIL_ADC_MAX_STEP}")
deadline = time.monotonic() + float(timeout_s)
self._read_until_packet_start(deadline, process)
while True:
values = self._read_one_sweep(deadline, process)
if expected_points is None or int(values.size) == int(expected_points):
return values
logger.warning(
"Discarding Kamil ADC sweep with %d points; expected %d",
int(values.size),
int(expected_points),
)
values = np.empty(points, dtype=np.complex64)
for index in range(points):
frame = self._read_frame(deadline, process, received_points=index, expected_points=points)
values[index] = KamilAdcFrameParser.parse_point(frame, index + 1)
return values
def _read_one_sweep(
self,
deadline: float,
process: subprocess.Popen[bytes] | None,
) -> np.ndarray:
"""Read one packet from start marker to the next start marker."""
if self._packet_start_pending:
self._packet_start_pending = False
else:
self._read_until_packet_start(deadline, process)
values: list[complex] = []
expected_step = 1
while True:
frame = self._read_frame(deadline, process, received_points=len(values))
if KamilAdcFrameParser.is_packet_start(frame):
if not values:
continue
self._packet_start_pending = True
return np.asarray(values, dtype=np.complex64)
if expected_step > KAMIL_ADC_MAX_STEP:
raise RuntimeError(f"Kamil ADC sweep exceeded {KAMIL_ADC_MAX_STEP} points without packet end")
values.append(KamilAdcFrameParser.parse_point(frame, expected_step))
expected_step += 1
def discard_pending(self, process: subprocess.Popen[bytes] | None = None) -> None:
"""Discard bytes already buffered before starting a new logical sweep."""
"""Discard stale bytes while keeping the newest packet-start boundary."""
if self._fd is None:
raise RuntimeError("Kamil ADC TTY reader is not open")
self._buffer.clear()
self._packet_start_pending = False
fd = self._require_fd()
while True:
self._raise_if_process_exited(process)
@@ -132,6 +164,17 @@ class KamilAdcTtyReader:
raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
if not chunk:
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while draining")
self._buffer.extend(chunk)
self._keep_latest_packet_start_tail()
def _keep_latest_packet_start_tail(self) -> None:
"""Keep only bytes from the latest complete packet-start marker onward."""
start_index = self._buffer.rfind(_START_FRAME)
if start_index >= 0:
del self._buffer[:start_index]
return
if len(self._buffer) >= KAMIL_ADC_FRAME_BYTES:
del self._buffer[:-KAMIL_ADC_FRAME_BYTES + 1]
def _read_until_packet_start(
self,
@@ -153,7 +196,7 @@ class KamilAdcTtyReader:
process: subprocess.Popen[bytes] | None,
*,
received_points: int,
expected_points: int,
expected_points: int | None = None,
) -> bytes:
while len(self._buffer) < KAMIL_ADC_FRAME_BYTES:
self._read_available(deadline, process, received_points, expected_points)
@@ -172,6 +215,10 @@ class KamilAdcTtyReader:
remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0:
if received_points is None or expected_points is None:
if received_points is not None:
raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep end: received {received_points} points"
)
raise TimeoutError("Timed out waiting for Kamil ADC packet-start marker")
raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points"
@@ -214,15 +261,14 @@ class KamilAdcTtyReader:
@dataclass(slots=True)
class KamilAdcService:
"""Launch `kamil_adc`, configure laser board, and acquire TTY sweeps."""
"""Launch `kamil_adc` and acquire TTY sweeps."""
config: RunConfigModel
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: KamilAdcTtyReader | None = field(init=False, default=None, repr=False)
_settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | None = field(init=False, default=None, repr=False)
_laser_controller: Any | None = field(init=False, default=None, repr=False)
_laser_variation_active: bool = field(init=False, default=False, repr=False)
_expected_points: int | None = field(init=False, default=None, repr=False)
def __post_init__(self) -> None:
self._validate_config()
@@ -235,13 +281,12 @@ class KamilAdcService:
return [executable_path, *adc.args, f"tty:{adc.tty_path}"]
def open(self) -> None:
"""Apply laser configuration, launch the collector, and open its TTY stream."""
"""Launch the collector and open its TTY stream."""
if self._reader is not None:
return
previous_tty_identity = _tty_identity(self.config.radar.kamil_adc.tty_path)
previous_tty_identity = _prepare_tty_path_for_collector(self.config.radar.kamil_adc.tty_path)
try:
self._apply_laser_control()
self._start_process()
self._wait_for_tty(previous_tty_identity)
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
@@ -252,25 +297,20 @@ class KamilAdcService:
raise
def close(self) -> None:
"""Close TTY, stop the external collector, and disconnect laser control."""
"""Close TTY and stop the external collector."""
if self._reader is not None:
with suppress(Exception):
self._reader.close()
self._reader = None
self._stop_process()
self._close_laser_control()
def configure(self, sweep: RadarSweepModel) -> None:
"""Store sweep settings and construct the synthetic frequency axis."""
"""Store sweep settings used to construct the synthetic frequency axis."""
self._validate_sweep(sweep)
self._settings = sweep
self._frequency_hz = np.linspace(
float(sweep.start_hz),
float(sweep.stop_hz),
int(sweep.points),
dtype=np.float32,
)
self._frequency_hz = None
self._expected_points = None
def read_device_limits(self) -> dict[str, float | int]:
"""Kamil ADC has no runtime-readable sweep limit API."""
@@ -278,7 +318,7 @@ class KamilAdcService:
def acquire(self) -> SweepResult:
"""Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros."""
if self._settings is None or self._frequency_hz is None:
if self._settings is None:
raise RuntimeError("Kamil ADC service is not configured")
if self._reader is None:
raise RuntimeError("Kamil ADC service is not open")
@@ -287,13 +327,21 @@ class KamilAdcService:
code = None if process is None else process.poll()
raise RuntimeError(f"Kamil ADC process is not running (code={code})")
points = int(self._settings.points)
self._reader.discard_pending(process)
s21 = self._reader.read_sweep(
points=points,
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
process=process,
expected_points=self._expected_points,
)
points = int(s21.size)
if points <= 0:
raise RuntimeError("Kamil ADC sweep contained no points")
if self._expected_points is None:
self._expected_points = points
self._frequency_hz = self._build_frequency_axis(points)
logger.info("Kamil ADC sweep point count locked to %d", points)
if self._frequency_hz is None:
self._frequency_hz = self._build_frequency_axis(points)
return SweepResult(
x=self._frequency_hz.copy(),
traces={
@@ -353,79 +401,6 @@ class KamilAdcService:
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector"
)
def _apply_laser_control(self) -> None:
laser = self.config.radar.laser_control
if not laser.enabled:
return
from python_app.hardware_full.laser_control.controller import LaserController
from python_app.hardware_full.laser_control.models import VariationType
controller = LaserController(
port=laser.port,
pi_coeff1_p=laser.pi_coeff1_p,
pi_coeff1_i=laser.pi_coeff1_i,
pi_coeff2_p=laser.pi_coeff2_p,
pi_coeff2_i=laser.pi_coeff2_i,
)
try:
controller.connect()
mode = laser.mode.strip().lower()
if mode == "manual":
manual = laser.manual
controller.set_manual_mode(
temp1=manual.temp1,
temp2=manual.temp2,
current1=manual.current1,
current2=manual.current2,
)
elif mode == "variation":
variation = laser.variation
try:
variation_type = VariationType[variation.variation_type]
except KeyError as exc:
raise ValueError(
f"Unsupported radar.laser_control.variation.variation_type: "
f"{variation.variation_type}"
) from exc
controller.start_variation(
variation_type=variation_type,
params={
"static_temp1": variation.static_temp1,
"static_temp2": variation.static_temp2,
"static_current1": variation.static_current1,
"static_current2": variation.static_current2,
"min_value": variation.min_value,
"max_value": variation.max_value,
"step": variation.step,
"time_step": variation.time_step,
"delay_time": variation.delay_time,
},
)
self._laser_variation_active = True
else:
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
except Exception:
with suppress(Exception):
controller.disconnect()
raise
self._laser_controller = controller
def _close_laser_control(self) -> None:
controller = self._laser_controller
self._laser_controller = None
if controller is None:
self._laser_variation_active = False
return
try:
if self._laser_variation_active:
controller.stop_task()
finally:
self._laser_variation_active = False
controller.disconnect()
def _validate_config(self) -> None:
if not self.config.is_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='kamil_adc'")
@@ -457,31 +432,33 @@ class KamilAdcService:
if not os.access(executable_path, os.X_OK):
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}")
laser = self.config.radar.laser_control
if laser.enabled:
if not laser.port:
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
mode = laser.mode.strip().lower()
if mode not in {"manual", "variation"}:
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
if mode == "variation" and not laser.variation.variation_type:
raise ValueError("radar.laser_control.variation.variation_type is required")
@staticmethod
def _validate_sweep(sweep: RadarSweepModel) -> None:
points = int(sweep.points)
if points <= 0:
raise ValueError("Kamil ADC sweep points must be > 0")
if points > KAMIL_ADC_MAX_STEP:
raise ValueError(f"Kamil ADC sweep points must be <= {KAMIL_ADC_MAX_STEP}")
if float(sweep.stop_hz) < float(sweep.start_hz):
raise ValueError("Kamil ADC sweep stop_hz must be >= start_hz")
def _build_frequency_axis(self, points: int) -> np.ndarray:
if self._settings is None:
raise RuntimeError("Kamil ADC service is not configured")
return np.linspace(
float(self._settings.start_hz),
float(self._settings.stop_hz),
int(points),
dtype=np.float32,
)
def _tty_identity(path: str) -> tuple[object, ...] | None:
try:
if os.path.islink(path):
return ("link", os.readlink(path))
stat_result = os.lstat(path)
return (
"link",
os.readlink(path),
int(stat_result.st_dev),
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
stat_result = os.stat(path)
except FileNotFoundError:
return None
@@ -491,3 +468,95 @@ def _tty_identity(path: str) -> tuple[object, ...] | None:
int(stat_result.st_ino),
int(stat_result.st_mtime_ns),
)
def _prepare_tty_path_for_collector(path: str) -> tuple[object, ...] | None:
"""Remove stale generated TTY links before starting the external collector."""
try:
stat_result = os.lstat(path)
except FileNotFoundError:
return None
if stat.S_ISLNK(stat_result.st_mode) or stat.S_ISREG(stat_result.st_mode):
os.unlink(path)
return None
return None
def apply_kamil_adc_laser_control(config: RunConfigModel) -> bool:
"""Apply Kamil ADC laser settings exactly through the legacy device_main command sequence."""
laser = config.radar.laser_control
if not laser.enabled:
return False
_validate_laser_control_config(config)
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
from python_app.hardware_full.laser_control.models import VariationType
controller = LaserController(
port=laser.port,
pi_coeff1_p=laser.pi_coeff1_p,
pi_coeff1_i=laser.pi_coeff1_i,
pi_coeff2_p=laser.pi_coeff2_p,
pi_coeff2_i=laser.pi_coeff2_i,
)
try:
controller.connect()
controller.reset()
mode = laser.mode.strip().lower()
if mode == "manual":
manual = laser.manual
controller.set_manual_mode(
temp1=manual.temp1,
temp2=manual.temp2,
current1=manual.current1,
current2=manual.current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
return True
if mode == "variation":
variation = laser.variation
try:
variation_type = VariationType[variation.variation_type]
except KeyError as exc:
raise ValueError(
f"Unsupported radar.laser_control.variation.variation_type: {variation.variation_type}"
) from exc
controller.set_manual_mode(
temp1=variation.static_temp1,
temp2=variation.static_temp2,
current1=variation.static_current1,
current2=variation.static_current2,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=variation_type,
params={
"static_temp1": variation.static_temp1,
"static_temp2": variation.static_temp2,
"static_current1": variation.static_current1,
"static_current2": variation.static_current2,
"min_value": variation.min_value,
"max_value": variation.max_value,
"step": variation.step,
"time_step": variation.time_step,
"delay_time": variation.delay_time,
},
)
return True
raise RuntimeError(f"Unsupported laser_control mode: {laser.mode}")
finally:
controller.disconnect()
def _validate_laser_control_config(config: RunConfigModel) -> None:
laser = config.radar.laser_control
if not laser.port:
raise ValueError("radar.laser_control.port is required when laser_control is enabled")
mode = laser.mode.strip().lower()
if mode not in {"manual", "variation"}:
raise ValueError("radar.laser_control.mode must be 'manual' or 'variation'")
if mode == "variation" and not laser.variation.variation_type:
raise ValueError("radar.laser_control.variation.variation_type is required")
@@ -33,6 +33,7 @@ logger = logging.getLogger(__name__)
# Default PI regulator coefficients (match firmware defaults)
DEFAULT_PI_P = 2560 # 10 * 256
DEFAULT_PI_I = 128 # 0.5 * 256
DEVICE_MAIN_MESSAGE_ID = 0x00FF
class LaserController:
@@ -121,6 +122,7 @@ class LaserController:
temp2: float,
current1: float,
current2: float,
message_id: Optional[int] = None,
) -> None:
"""
Set manual control parameters for both lasers.
@@ -134,6 +136,9 @@ class LaserController:
Valid range: [15.0 60.0] mA.
current2: Drive current for laser 2, mA.
Valid range: [15.0 60.0] mA.
message_id: Optional fixed DECODE_ENABLE message id. When
omitted, the id is incremented for backward
compatibility with the refactored API.
Raises:
ValidationError: If any parameter is out of range.
@@ -142,7 +147,10 @@ class LaserController:
validated = ParameterValidator.validate_manual_mode_params(
temp1, temp2, current1, current2
)
self._message_id = (self._message_id + 1) & 0xFFFF
if message_id is None:
self._message_id = (self._message_id + 1) & 0xFFFF
else:
self._message_id = int(message_id) & 0xFFFF
cmd = Protocol.encode_decode_enable(
temp1=validated['temp1'],
@@ -244,7 +252,7 @@ class LaserController:
validated['max_value'],
validated['step'])
def stop_task(self) -> None:
def stop_task(self, restore_message_id: Optional[int] = None) -> None:
"""Stop the current task and restore manual mode.
Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last
@@ -257,20 +265,13 @@ class LaserController:
self._send_and_read_state(cmd_reset)
logger.info("Task stopped (DEFAULT_ENABLE sent)")
# Restore manual mode so the board is ready for TRANS_ENABLE requests
self._message_id = (self._message_id + 1) & 0xFFFF
cmd_restore = Protocol.encode_decode_enable(
self.set_manual_mode(
temp1=self._last_temp1,
temp2=self._last_temp2,
current1=self._last_current1,
current2=self._last_current2,
pi_coeff1_p=self._pi1_p,
pi_coeff1_i=self._pi1_i,
pi_coeff2_p=self._pi2_p,
pi_coeff2_i=self._pi2_i,
message_id=self._message_id,
message_id=restore_message_id,
)
self._send_and_read_state(cmd_restore)
logger.info("Manual mode restored after task stop")
def get_measurements(self) -> Optional[Measurements]:
+32
View File
@@ -265,6 +265,24 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
gui.processing.gpr.angle_comp_power,
"gui.processing.gpr",
),
score_mode=_optional_string(
gpr_object,
"score_mode",
gui.processing.gpr.score_mode,
"gui.processing.gpr",
),
max_detected_objects_to_draw=_optional_int(
gpr_object,
"max_detected_objects_to_draw",
gui.processing.gpr.max_detected_objects_to_draw,
"gui.processing.gpr",
),
draw_top_m_objects=_optional_int(
gpr_object,
"draw_top_m_objects",
gui.processing.gpr.draw_top_m_objects,
"gui.processing.gpr",
),
start_freq_mhz=_optional_float(
gpr_object,
"start_freq_mhz",
@@ -342,6 +360,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
start_freq_mhz=legacy_float("start_freq_mhz", gui.processing.legacy_gpr.start_freq_mhz),
stop_freq_mhz=legacy_float("stop_freq_mhz", gui.processing.legacy_gpr.stop_freq_mhz),
speed_m_s=legacy_float("speed_m_s", gui.processing.legacy_gpr.speed_m_s),
ignore_socket_speed_enabled=legacy_bool(
"ignore_socket_speed_enabled",
gui.processing.legacy_gpr.ignore_socket_speed_enabled,
),
look_angle_deg=legacy_float("look_angle_deg", gui.processing.legacy_gpr.look_angle_deg),
snr_thresh=legacy_float("snr_thresh", gui.processing.legacy_gpr.snr_thresh),
snr_comp_max=legacy_float("snr_comp_max", gui.processing.legacy_gpr.snr_comp_max),
@@ -370,6 +392,8 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase")
if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}:
raise ValueError("gui.processing.gpr.render_mode must be one of: heatmap, objects_only")
if gui.processing.gpr.score_mode not in {"peak", "combined"}:
raise ValueError("gui.processing.gpr.score_mode must be one of: peak, combined")
if gui.processing.legacy_gpr.mode not in {"point", "extended"}:
raise ValueError("gui.processing.legacy_gpr.mode must be one of: point, extended")
if gui.processing.legacy_gpr.render_mode not in {"heatmap", "objects_only"}:
@@ -380,6 +404,10 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0")
if gui.processing.gpr.min_visible_score < 0.0:
raise ValueError("gui.processing.gpr.min_visible_score must be >= 0")
if gui.processing.gpr.max_detected_objects_to_draw < 0:
raise ValueError("gui.processing.gpr.max_detected_objects_to_draw must be >= 0")
if gui.processing.gpr.draw_top_m_objects < 0:
raise ValueError("gui.processing.gpr.draw_top_m_objects must be >= 0")
if gui.processing.legacy_gpr.comp_power < 0.0:
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
if gui.processing.legacy_gpr.snr_thresh < 0.0:
@@ -474,6 +502,9 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"max_depth_m": gui.processing.gpr.max_depth_m,
"range_comp_power": gui.processing.gpr.range_comp_power,
"angle_comp_power": gui.processing.gpr.angle_comp_power,
"score_mode": gui.processing.gpr.score_mode,
"max_detected_objects_to_draw": gui.processing.gpr.max_detected_objects_to_draw,
"draw_top_m_objects": gui.processing.gpr.draw_top_m_objects,
"start_freq_mhz": gui.processing.gpr.start_freq_mhz,
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled,
@@ -496,6 +527,7 @@ def gui_profile_to_dict(model: GuiProfileModel) -> dict[str, Any]:
"start_freq_mhz": gui.processing.legacy_gpr.start_freq_mhz,
"stop_freq_mhz": gui.processing.legacy_gpr.stop_freq_mhz,
"speed_m_s": gui.processing.legacy_gpr.speed_m_s,
"ignore_socket_speed_enabled": gui.processing.legacy_gpr.ignore_socket_speed_enabled,
"look_angle_deg": gui.processing.legacy_gpr.look_angle_deg,
"snr_thresh": gui.processing.legacy_gpr.snr_thresh,
"snr_comp_max": gui.processing.legacy_gpr.snr_comp_max,
+4
View File
@@ -55,6 +55,9 @@ class GuiGprStateModel:
max_depth_m: float = 14.0
range_comp_power: float = 0.28
angle_comp_power: float = 0.10
score_mode: str = "combined"
max_detected_objects_to_draw: int = 5
draw_top_m_objects: int = 2
start_freq_mhz: float = 3000.0
stop_freq_mhz: float = 6000.0
background_subtract_enabled: bool = True
@@ -81,6 +84,7 @@ class GuiLegacyGprStateModel:
start_freq_mhz: float = 3000.0
stop_freq_mhz: float = 6000.0
speed_m_s: float = 0.0
ignore_socket_speed_enabled: bool = False
look_angle_deg: float = 0.0
snr_thresh: float = 4.5
snr_comp_max: float = 25.0
+10 -7
View File
@@ -372,6 +372,15 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"""Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure."""
model.ensure_combos()
sweep_payload = {
"start_hz": model.radar.sweep.start_hz,
"stop_hz": model.radar.sweep.stop_hz,
"if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz,
"stimulus_power_dbm": model.radar.sweep.power_dbm,
}
if not model.is_kamil_adc:
sweep_payload["points"] = model.radar.sweep.points
return {
"radar": {
"model": model.radar.model,
@@ -422,13 +431,7 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"delay_time": model.radar.laser_control.variation.delay_time,
},
},
"sweep": {
"start_hz": model.radar.sweep.start_hz,
"stop_hz": model.radar.sweep.stop_hz,
"points": model.radar.sweep.points,
"if_bandwidth_hz": model.radar.sweep.if_bandwidth_hz,
"stimulus_power_dbm": model.radar.sweep.power_dbm,
},
"sweep": sweep_payload,
},
"switches": {
"port1": {
+7
View File
@@ -71,6 +71,7 @@ def locator_observations_from_collection(
min_score: float,
*,
visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> list[dict[str, float]]:
"""Build locator observations from GPR rows using score threshold and optional X/Z bounds."""
rows = gpr_object_rows(collection)
@@ -88,6 +89,12 @@ def locator_observations_from_collection(
& (rows[:, 1] <= z_max)
)
filtered = rows[visible_mask]
if object_draw_limits is not None and filtered.size > 0:
max_detected_objects, draw_top_objects = object_draw_limits
if filtered.shape[0] > int(max_detected_objects):
filtered = np.zeros((0, filtered.shape[1]), dtype=filtered.dtype)
else:
filtered = filtered[: max(0, int(draw_top_objects))]
observations: list[dict[str, float]] = []
for x_m, z_m, _score in filtered:
@@ -31,6 +31,9 @@ class ProcessingLiveConfig:
gpr_range_comp_power: float = 0.28
gpr_angle_comp_power: float = 0.10
gpr_comp_power: float = 0.2
gpr_score_mode: str = "combined"
gpr_max_detected_objects_to_draw: int = 5
gpr_draw_top_m_objects: int = 2
gpr_speed_m_s: float = 0.0
gpr_look_angle_deg: float = 0.0
gpr_snr_thresh: float = 4.5
@@ -40,6 +43,7 @@ class ProcessingLiveConfig:
gpr_background_subtract_enabled: bool = True
gpr_background_mean_count: int = 10
gpr_remove_sidelobe_objects_enabled: bool = True
reprocess_current_result: bool = True
history_command_seq: int = 0
history_command: str = "none"
@@ -77,6 +81,9 @@ class ProcessingLiveConfig:
"gpr_range_comp_power": float(self.gpr_range_comp_power),
"gpr_angle_comp_power": float(self.gpr_angle_comp_power),
"gpr_comp_power": float(self.gpr_comp_power),
"gpr_score_mode": str(self.gpr_score_mode),
"gpr_max_detected_objects_to_draw": int(self.gpr_max_detected_objects_to_draw),
"gpr_draw_top_m_objects": int(self.gpr_draw_top_m_objects),
"gpr_speed_m_s": float(self.gpr_speed_m_s),
"gpr_look_angle_deg": float(self.gpr_look_angle_deg),
"gpr_snr_thresh": float(self.gpr_snr_thresh),
@@ -86,6 +93,7 @@ class ProcessingLiveConfig:
"gpr_background_subtract_enabled": bool(self.gpr_background_subtract_enabled),
"gpr_background_mean_count": int(self.gpr_background_mean_count),
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled),
"reprocess_current_result": bool(self.reprocess_current_result),
"history_command_seq": int(self.history_command_seq),
"history_command": str(self.history_command),
}
+56 -11
View File
@@ -63,6 +63,21 @@ def parse_vlc(payload: dict[str, Any]) -> float:
return vlc
def format_payload_for_log(payload: Any) -> str:
"""Return compact JSON-ish payload text for logs."""
return json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
def decode_packet_for_log(packet: bytes) -> tuple[int, str]:
"""Decode an outbound packet into `(device_id, payload_text)` for logging."""
if len(packet) < _PACKET_HEADER_STRUCT.size:
raise ValueError("Packet is shorter than the locator header")
header_bytes = packet[: _PACKET_HEADER_STRUCT.size]
payload_bytes = packet[_PACKET_HEADER_STRUCT.size :]
device_id, payload = decode_packet(header_bytes, payload_bytes)
return device_id, format_payload_for_log(payload)
def format_peer_name(writer: asyncio.StreamWriter) -> str:
"""Return a readable peer address for logs."""
peer_name = writer.get_extra_info("peername")
@@ -118,6 +133,7 @@ class LocatorTcpService:
self._logger = logger or logging.getLogger(str(logger_name))
self._client_queue_size = int(client_queue_size)
self._speed_updates: queue.Queue[float] = queue.Queue()
self._log_updates: queue.Queue[str] = queue.Queue()
self._loop: asyncio.AbstractEventLoop | None = None
self._server: asyncio.AbstractServer | None = None
self._thread: threading.Thread | None = None
@@ -186,12 +202,14 @@ class LocatorTcpService:
min_score: float,
*,
visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> None:
"""Publish one locator payload derived from a GPR result collection."""
observations = locator_observations_from_collection(
collection,
min_score,
visible_bounds=visible_bounds,
object_draw_limits=object_draw_limits,
)
payload = build_locator_payload(
observations,
@@ -218,6 +236,24 @@ class LocatorTcpService:
except queue.Empty:
return latest
def drain_log_updates(self) -> list[str]:
"""Drain queued socket traffic log lines."""
lines: list[str] = []
while True:
try:
lines.append(str(self._log_updates.get_nowait()))
except queue.Empty:
return lines
def _queue_log_update(self, message: str) -> None:
"""Queue one socket traffic line for the GUI runtime log."""
self._log_updates.put(str(message))
def _log_socket_traffic(self, message: str) -> None:
"""Log socket traffic to both Python logging and the GUI-visible queue."""
self._logger.info(message)
self._queue_log_update(message)
def _publish_packet(self, packet: bytes) -> None:
"""Store latest packet and broadcast it to all connected clients."""
with self._snapshot_lock:
@@ -368,6 +404,17 @@ class LocatorTcpService:
packet = await client.queue.get()
client.writer.write(packet)
await client.writer.drain()
try:
device_id, payload_text = decode_packet_for_log(packet)
self._log_socket_traffic(
"Locator socket sent to %s: device_id=%d payload=%s"
% (client.peer_name, device_id, payload_text)
)
except ValueError as error:
self._log_socket_traffic(
"Locator socket sent undecodable packet to %s: bytes=%d error=%s"
% (client.peer_name, len(packet), error)
)
async def _receive_packets(
self,
@@ -377,21 +424,19 @@ class LocatorTcpService:
"""Receive inbound client packets and queue valid speed updates."""
while True:
device_id, payload = await read_packet_with_limit(reader, self._max_payload_bytes)
payload_text = format_payload_for_log(payload)
if isinstance(payload, dict) and "vlc" in payload:
self._speed_updates.put(parse_vlc(payload))
self._logger.debug(
"Received locator speed from %s: device_id=%d payload=%s",
client.peer_name,
device_id,
payload,
speed_m_s = parse_vlc(payload)
self._speed_updates.put(speed_m_s)
self._log_socket_traffic(
"Locator socket received from %s: device_id=%d payload=%s speed_m_s=%g"
% (client.peer_name, device_id, payload_text, speed_m_s)
)
continue
self._logger.info(
"Received locator payload from %s: device_id=%d payload=%s",
client.peer_name,
device_id,
json.dumps(payload, ensure_ascii=True, separators=(",", ":")),
self._log_socket_traffic(
"Locator socket received from %s: device_id=%d payload=%s"
% (client.peer_name, device_id, payload_text)
)
def _broadcast_packet(self, packet: bytes) -> None:
+2 -1
View File
@@ -23,12 +23,13 @@ def radar_key_from_config(
serial_part = "_".join(sanitize_path_component(value) for value in serial_parts)
start_token = _format_float_for_key(sweep_start_hz)
stop_token = _format_float_for_key(sweep_stop_hz)
points_token = "adc" if model_name.strip().lower() == "kamil_adc" else str(int(sweep_points))
ifbw_token = _format_float_for_key(ifbw_hz)
power_token = _format_float_for_key(power_dbm)
return (
f"{model_name}_{serial_part}"
f"_st{start_token}_sp{stop_token}"
f"_p{sweep_points}_if{ifbw_token}_pw{power_token}"
f"_p{points_token}_if{ifbw_token}_pw{power_token}"
)
@@ -0,0 +1,65 @@
"""Tests for Kamil ADC neutral preprocessing-set generation."""
from __future__ import annotations
import unittest
import numpy as np
from python_app.models.run_config_model import RunConfigModel
from python_app.workflows.kamil_adc_neutral_preprocess import build_kamil_adc_neutral_s21_sets
class KamilAdcNeutralPreprocessTest(unittest.TestCase):
def test_builds_passthrough_s21_sets_for_current_sweep(self) -> None:
config = RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"sweep": {
"start_hz": 1_000_000.0,
"stop_hz": 4_000_000.0,
"if_bandwidth_hz": 1.0,
"stimulus_power_dbm": -10.0,
},
},
"switches": {
"port1": {"positions": 1},
"port2": {"positions": 2},
},
"run": {
"combos": [
{"input": 0, "output": 0},
{"input": 1, "output": 0},
],
},
}
)
calibration, reference = build_kamil_adc_neutral_s21_sets(config, point_count=4)
expected_frequency = np.linspace(1_000_000.0, 4_000_000.0, 4, dtype=np.float32)
self.assertEqual(len(calibration.traces), 2)
self.assertEqual(len(reference.traces), 2)
self.assertEqual(
[(trace.combo.input_pos, trace.combo.output_pos) for trace in calibration.traces],
[(0, 0), (1, 0)],
)
for trace in calibration.traces:
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.ones(4, dtype=np.complex64))
for trace in reference.traces:
np.testing.assert_array_equal(trace.frequency_hz, expected_frequency)
np.testing.assert_array_equal(trace.s11, np.zeros(4, dtype=np.complex64))
np.testing.assert_array_equal(trace.s21, np.zeros(4, dtype=np.complex64))
def test_rejects_non_kamil_config(self) -> None:
config = RunConfigModel.from_dict({"radar": {"model": "librevna"}})
with self.assertRaisesRegex(ValueError, "kamil_adc"):
build_kamil_adc_neutral_s21_sets(config, point_count=4)
if __name__ == "__main__":
unittest.main()
+66 -5
View File
@@ -47,9 +47,15 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1) + _point_frame(2, -20, 2))
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame(),
)
values = reader.read_sweep(points=2, timeout_s=1.0)
values = reader.read_sweep(timeout_s=1.0)
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
finally:
@@ -58,7 +64,61 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
os.close(master_fd)
os.close(slave_fd)
def test_short_stream_times_out_with_received_count(self) -> None:
def test_stream_reads_consecutive_variable_length_sweeps(self) -> None:
master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None
try:
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame()
+ _point_frame(1, 30, -3)
+ _start_frame(),
)
first = reader.read_sweep(timeout_s=1.0)
second = reader.read_sweep(timeout_s=1.0)
self.assertEqual(first.tolist(), [complex(10, -1), complex(-20, 2)])
self.assertEqual(second.tolist(), [complex(30, -3)])
finally:
if reader is not None:
reader.close()
os.close(master_fd)
os.close(slave_fd)
def test_expected_point_count_discards_mismatched_sweep(self) -> None:
master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None
try:
tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open()
os.write(
master_fd,
_start_frame()
+ _point_frame(1, 5, -5)
+ _start_frame()
+ _point_frame(1, 10, -1)
+ _point_frame(2, -20, 2)
+ _start_frame(),
)
values = reader.read_sweep(timeout_s=1.0, expected_points=2)
self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
finally:
if reader is not None:
reader.close()
os.close(master_fd)
os.close(slave_fd)
def test_stream_without_next_start_times_out_with_received_count(self) -> None:
master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None
try:
@@ -67,8 +127,8 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
reader.open()
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1))
with self.assertRaisesRegex(TimeoutError, "received 1/2"):
reader.read_sweep(points=2, timeout_s=0.05)
with self.assertRaisesRegex(TimeoutError, "sweep end: received 1 points"):
reader.read_sweep(timeout_s=0.05)
finally:
if reader is not None:
reader.close()
@@ -137,6 +197,7 @@ class KamilAdcConfigTest(unittest.TestCase):
encoded = RunConfigModel.from_dict(payload).to_dict()
self.assertEqual(encoded["radar"]["model"], "kamil_adc")
self.assertNotIn("points", encoded["radar"]["sweep"])
self.assertEqual(encoded["radar"]["kamil_adc"]["tty_path"], "/tmp/ttyADC_data")
self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"])
self.assertEqual(encoded["radar"]["kamil_adc"]["env"], {"ADC_ENV": "1"})
@@ -0,0 +1,250 @@
"""Laser-control protocol compatibility tests."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from python_app.hardware_full.laser_control.controller import DEVICE_MAIN_MESSAGE_ID, LaserController
from python_app.hardware_full.laser_control.models import VariationType
from python_app.hardware_full.laser_control.protocol import Protocol, TaskType
from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control
from python_app.models.run_config_model import RunConfigModel
DEVICE_MAIN_MANUAL_HEX = "1111ff37ffa518ab000000000000000a8000000a8000ff003d2acc2c163f"
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX = (
"7777ff3701003d2acc2c10008813ffa5cc2c18ab0a00000a8000000a8000b600"
)
DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX = (
"7777ff3702003d2acc2c0500881318ab3d2affa50a00000a8000000a80005106"
)
class _FakeProtocol:
def __init__(self) -> None:
self.is_connected = True
self.sent: list[bytes] = []
def send_raw(self, data: bytes) -> None:
self.sent.append(bytes(data))
def receive_raw(self, length: int) -> bytes:
return b"\x00\x00" if length == 2 else b""
class _FakeLaserController:
instances: list["_FakeLaserController"] = []
def __init__(self, **kwargs: object) -> None:
self.kwargs = kwargs
self.calls: list[tuple[str, object]] = []
_FakeLaserController.instances.append(self)
def connect(self) -> bool:
self.calls.append(("connect", None))
return True
def reset(self) -> None:
self.calls.append(("reset", None))
def set_manual_mode(self, **kwargs: object) -> None:
self.calls.append(("set_manual_mode", kwargs))
def start_variation(self, **kwargs: object) -> None:
self.calls.append(("start_variation", kwargs))
def disconnect(self) -> None:
self.calls.append(("disconnect", None))
class LaserControlProtocolCompatibilityTest(unittest.TestCase):
def setUp(self) -> None:
_FakeLaserController.instances.clear()
@staticmethod
def _kamil_config(laser_payload: dict[str, object]) -> RunConfigModel:
return RunConfigModel.from_dict(
{
"radar": {
"model": "kamil_adc",
"driver_mode": "native",
"laser_control": laser_payload,
}
}
)
def test_manual_command_matches_device_main_bytes(self) -> None:
command = Protocol.encode_decode_enable(
temp1=28.0,
temp2=28.9,
current1=33.0,
current2=35.0,
pi_coeff1_p=2560,
pi_coeff1_i=128,
pi_coeff2_p=2560,
pi_coeff2_i=128,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
self.assertEqual(command.hex(), DEVICE_MAIN_MANUAL_HEX)
def test_variation_commands_match_device_main_bytes(self) -> None:
ld1_command = Protocol.encode_task_enable(
task_type=TaskType.CHANGE_CURRENT_LD1,
static_temp1=28.0,
static_temp2=28.9,
static_current1=33.0,
static_current2=35.0,
min_value=33.0,
max_value=35.0,
step=0.05,
time_step=50,
delay_time=10,
message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560,
pi_coeff1_i=128,
pi_coeff2_p=2560,
pi_coeff2_i=128,
)
ld2_command = Protocol.encode_task_enable(
task_type=TaskType.CHANGE_CURRENT_LD2,
static_temp1=28.0,
static_temp2=28.9,
static_current1=33.0,
static_current2=35.0,
min_value=33.0,
max_value=35.0,
step=0.05,
time_step=50,
delay_time=10,
message_id=DEVICE_MAIN_MESSAGE_ID,
pi_coeff1_p=2560,
pi_coeff1_i=128,
pi_coeff2_p=2560,
pi_coeff2_i=128,
)
self.assertEqual(ld1_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX)
self.assertEqual(ld2_command.hex(), DEVICE_MAIN_CHANGE_CURRENT_LD2_HEX)
def test_start_sequence_matches_device_main_order(self) -> None:
fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
controller._protocol = fake_protocol
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
controller.reset()
controller.set_manual_mode(
temp1=28.0,
temp2=28.9,
current1=33.0,
current2=35.0,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
controller.start_variation(
variation_type=VariationType.CHANGE_CURRENT_LD1,
params={
"static_temp1": 28.0,
"static_temp2": 28.9,
"static_current1": 33.0,
"static_current2": 35.0,
"min_value": 33.0,
"max_value": 35.0,
"step": 0.05,
"time_step": 50,
"delay_time": 10,
},
)
self.assertEqual(
[command.hex() for command in fake_protocol.sent],
[
"2222",
DEVICE_MAIN_MANUAL_HEX,
DEVICE_MAIN_CHANGE_CURRENT_LD1_HEX,
],
)
def test_stop_sequence_restores_device_main_manual_bytes(self) -> None:
fake_protocol = _FakeProtocol()
controller = LaserController(pi_coeff1_p=2560, pi_coeff1_i=128, pi_coeff2_p=2560, pi_coeff2_i=128)
controller._protocol = fake_protocol
with patch("python_app.hardware_full.laser_control.controller.time.sleep", return_value=None):
controller.set_manual_mode(
temp1=28.0,
temp2=28.9,
current1=33.0,
current2=35.0,
message_id=DEVICE_MAIN_MESSAGE_ID,
)
fake_protocol.sent.clear()
controller.stop_task(restore_message_id=DEVICE_MAIN_MESSAGE_ID)
self.assertEqual(
[command.hex() for command in fake_protocol.sent],
[
"2222",
DEVICE_MAIN_MANUAL_HEX,
],
)
def test_apply_radar_variation_sequence_matches_device_main_order(self) -> None:
config = self._kamil_config(
{
"enabled": True,
"port": "/dev/ttyUSB0",
"mode": "variation",
"pi_coeff1_p": 2560,
"pi_coeff1_i": 128,
"pi_coeff2_p": 2560,
"pi_coeff2_i": 128,
"variation": {
"variation_type": "CHANGE_CURRENT_LD1",
"static_temp1": 28.0,
"static_temp2": 28.9,
"static_current1": 33.0,
"static_current2": 35.0,
"min_value": 33.0,
"max_value": 35.0,
"step": 0.05,
"time_step": 50,
"delay_time": 10,
},
}
)
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
applied = apply_kamil_adc_laser_control(config)
self.assertTrue(applied)
controller = _FakeLaserController.instances[0]
self.assertEqual(
[name for name, _payload in controller.calls],
["connect", "reset", "set_manual_mode", "start_variation", "disconnect"],
)
manual_payload = controller.calls[2][1]
self.assertEqual(
manual_payload,
{
"temp1": 28.0,
"temp2": 28.9,
"current1": 33.0,
"current2": 35.0,
"message_id": DEVICE_MAIN_MESSAGE_ID,
},
)
def test_apply_radar_skips_disabled_laser_control(self) -> None:
config = self._kamil_config({"enabled": False})
with patch("python_app.hardware_full.laser_control.controller.LaserController", _FakeLaserController):
applied = apply_kamil_adc_laser_control(config)
self.assertFalse(applied)
self.assertEqual(_FakeLaserController.instances, [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,77 @@
"""Neutral preprocessing-set helpers for Kamil ADC acquisition."""
from __future__ import annotations
import time
import numpy as np
from python_app.models.dataset_model import ComboKey, SweepCollection, TraceData
from python_app.models.run_config_model import ComboModel, RunConfigModel
def build_kamil_adc_neutral_s21_sets(
config: RunConfigModel,
point_count: int,
) -> tuple[SweepCollection, SweepCollection]:
"""Build S21 calibration/reference collections that leave input S21 unchanged."""
if not config.is_kamil_adc:
raise ValueError("Neutral Kamil ADC sets require radar.model='kamil_adc'")
points = int(point_count)
if points <= 0:
raise ValueError("Kamil ADC neutral set point count must be > 0")
combos = list(config.combos)
if not combos:
combos = RunConfigModel.build_full_combos(config.input_switch.positions, config.output_switch.positions)
if not combos:
raise ValueError("Kamil ADC neutral sets require at least one switch combo")
frequency_hz = np.linspace(
float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_hz),
points,
dtype=np.float32,
)
now_ns = time.monotonic_ns()
calibration = _neutral_collection(
combos=combos,
frequency_hz=frequency_hz,
s21_value=np.complex64(1.0 + 0.0j),
monotonic_ns=now_ns,
)
reference = _neutral_collection(
combos=combos,
frequency_hz=frequency_hz,
s21_value=np.complex64(0.0 + 0.0j),
monotonic_ns=now_ns,
)
return calibration, reference
def _neutral_collection(
*,
combos: list[ComboModel],
frequency_hz: np.ndarray,
s21_value: np.complex64,
monotonic_ns: int,
) -> SweepCollection:
traces = []
for combo in combos:
point_count = int(frequency_hz.size)
traces.append(
TraceData(
combo=ComboKey(input_pos=int(combo.input), output_pos=int(combo.output)),
frequency_hz=frequency_hz.copy(),
s11=np.zeros(point_count, dtype=np.complex64),
s21=np.full(point_count, s21_value, dtype=np.complex64),
)
)
return SweepCollection(
collection_id=1,
monotonic_ns=monotonic_ns,
traces=traces,
capture_start_ns=monotonic_ns,
capture_end_ns=monotonic_ns,
)
+3 -14
View File
@@ -11,22 +11,12 @@
},
"kamil_adc": {
"project_dir": "/home/europa/Documents/kamil_adc",
"executable_path": "/home/europa/Documents/kamil_adc/kamil_adc_capture",
"executable_path": "/home/europa/Documents/kamil_adc/run_do1_pair_subtract_avg.sh",
"tty_path": "/tmp/ttyADC_data",
"args": [
"profile:phase",
"clock:internal",
"internal_ref_hz:2000000",
"mode:diff",
"channels:2",
"ch1:2",
"ch2:3",
"do1_toggle_per_frame",
"do1_pair_subtract_avg"
],
"args": [],
"env": {},
"startup_timeout_s": 5.0,
"sweep_timeout_s": 5.0,
"sweep_timeout_s": 15.0,
"stop_timeout_s": 2.0
},
"laser_control": {
@@ -59,7 +49,6 @@
"sweep": {
"start_hz": 1000000.0,
"stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0
}
+140
View File
@@ -0,0 +1,140 @@
{
"radar": {
"model": "kamil_adc",
"serial": "kamil_adc",
"driver_mode": "native",
"kamil_adc": {
"project_dir": "/home/callisto/Documents/kamil_adc",
"executable_path": "/home/callisto/Documents/kamil_adc/run_do1_pair_subtract_avg.sh",
"tty_path": "/tmp/ttyADC_data",
"args": [],
"env": {
"TTY_PATH": "/tmp/ttyADC_data"
},
"startup_timeout_s": 10.0,
"sweep_timeout_s": 15.0,
"stop_timeout_s": 2.0
},
"laser_control": {
"enabled": false,
"port": "/dev/ttyUSB0",
"mode": "variation"
},
"sweep": {
"start_hz": 1000000.0,
"stop_hz": 6000000000.0,
"if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.0
}
},
"switches": {
"port1": {
"name": "port1",
"driver_mode": "mock",
"driver": "h7992",
"radar_port": 1,
"positions": 1,
"default_position": 0
},
"port2": {
"name": "port2",
"driver_mode": "mock",
"driver": "h7992",
"radar_port": 2,
"positions": 1,
"default_position": 0
}
},
"run": {
"settling_ms": 0,
"idle_sleep_ms": 2,
"continuous": true,
"processing_live_config_path": "python_app/runtime/processing_live.json",
"combos": [
{
"input": 0,
"output": 0
}
]
},
"preprocess": {
"s21": {
"calibration": {
"set_name": "",
"bundle_path": ""
},
"reference": {
"set_name": "",
"bundle_path": ""
}
},
"s11": {
"calibration": {
"open": {
"set_name": "",
"bundle_path": ""
},
"short": {
"set_name": "",
"bundle_path": ""
},
"load": {
"set_name": "",
"bundle_path": ""
}
},
"reference": {
"set_name": "",
"bundle_path": ""
}
},
"notch": {
"enabled": false,
"bands_hz": [],
"taper_width_hz": 40000000.0,
"taper_type": "cosine"
}
},
"gpr": {
"relative_permittivity": 1.0,
"tx_geometry": [
{
"output_pos": 0,
"x_m": 0.0
}
],
"rx_geometry": [
{
"input_pos": 0,
"x_m": 0.0
}
]
},
"rings": {
"raw": {
"name": "/radar_raw_kamil_adc",
"capacity": 50,
"slot_size_bytes": 2097152
},
"raw_tap": {
"name": "/radar_raw_tap_kamil_adc",
"capacity": 50,
"slot_size_bytes": 2097152
},
"preprocessed": {
"name": "/radar_preprocessed_kamil_adc",
"capacity": 50,
"slot_size_bytes": 2097152
},
"preprocessed_tap": {
"name": "/radar_preprocessed_tap_kamil_adc",
"capacity": 50,
"slot_size_bytes": 2097152
},
"results": {
"name": "/radar_results_kamil_adc",
"capacity": 50,
"slot_size_bytes": 2097152
}
}
}
+125 -11
View File
@@ -7,20 +7,29 @@ VENV_PYTHON="${PROJECT_ROOT}/.venv/bin/python"
VENV_PIP="${PROJECT_ROOT}/.venv/bin/pip"
GUI_ENTRY="${PROJECT_ROOT}/python_app/gui/main.py"
REQUIREMENTS_FILE="${PROJECT_ROOT}/requirements.txt"
PYTHON_CMD=""
PROFILE_PATH=""
SKIP_BUILD=0
BUILD_ONLY=0
CLEAN_SHM=0
KAMIL_ADC_MODE=0
AUTO_START=0
PRODUCER_ONLY=0
print_usage() {
cat <<'EOF'
Usage: ./start.sh [options]
Options:
--skip-build Skip C++ build step and only run GUI
--build-only Build C++ binaries and exit
--clean-shm Remove known radar shared-memory segments before start
-h, --help Show this help
--kamil-adc Use the Raspberry Pi Kamil ADC profile
--profile PATH Use a specific GUI/run config profile
--auto-start Start the GUI pipeline automatically after launch
--producer-only Run only the raw producer selected by the profile
--skip-build Skip C++ build step
--build-only Build C++ binaries and exit
--clean-shm Remove known radar shared-memory segments before start
-h, --help Show this help
EOF
}
@@ -36,6 +45,23 @@ parse_args() {
--clean-shm)
CLEAN_SHM=1
;;
--kamil-adc)
KAMIL_ADC_MODE=1
;;
--profile)
if (($# < 2)); then
echo "--profile requires a path argument." >&2
exit 1
fi
PROFILE_PATH="$2"
shift
;;
--auto-start)
AUTO_START=1
;;
--producer-only)
PRODUCER_ONLY=1
;;
-h|--help)
print_usage
exit 0
@@ -50,6 +76,35 @@ parse_args() {
done
}
absolute_path() {
local path="$1"
if [[ "${path}" = /* ]]; then
printf '%s\n' "${path}"
return
fi
printf '%s\n' "${PROJECT_ROOT}/${path}"
}
resolve_profile_path() {
if ((KAMIL_ADC_MODE == 1)); then
if [[ -z "${PROFILE_PATH}" ]]; then
if [[ -f "${PROJECT_ROOT}/run_config_kamil_adc.pi.json" ]]; then
PROFILE_PATH="${PROJECT_ROOT}/run_config_kamil_adc.pi.json"
else
PROFILE_PATH="${PROJECT_ROOT}/run_config_kamil_adc.example.json"
fi
fi
fi
if [[ -n "${PROFILE_PATH}" ]]; then
PROFILE_PATH="$(absolute_path "${PROFILE_PATH}")"
if [[ ! -f "${PROFILE_PATH}" ]]; then
echo "Config profile not found: ${PROFILE_PATH}" >&2
exit 1
fi
fi
}
check_environment() {
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 is not installed or not found in PATH." >&2
@@ -68,6 +123,13 @@ check_environment() {
}
ensure_python_dependencies() {
local dependency_check
if ((KAMIL_ADC_MODE == 1)); then
dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1'
else
dependency_check='import numpy, serial, PyQt6, pyqtgraph, usb1, pyvisa'
fi
if [[ ! -x "${VENV_PYTHON}" ]]; then
echo "[start.sh] Creating virtual environment..."
python3 -m venv "${PROJECT_ROOT}/.venv"
@@ -78,9 +140,19 @@ ensure_python_dependencies() {
exit 1
fi
echo "[start.sh] Installing Python dependencies..."
"${VENV_PIP}" install --upgrade pip
"${VENV_PIP}" install -r "${REQUIREMENTS_FILE}"
if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then
echo "[start.sh] Installing Python dependencies into virtual environment..."
"${VENV_PIP}" install --upgrade pip
"${VENV_PIP}" install -r "${REQUIREMENTS_FILE}"
fi
if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then
echo "Required Python dependencies are still unavailable in virtual environment: ${PROJECT_ROOT}/.venv" >&2
exit 1
fi
PYTHON_CMD="${VENV_PYTHON}"
echo "[start.sh] Using virtual environment: ${PYTHON_CMD}"
}
run_privileged() {
@@ -170,19 +242,57 @@ build_cpp_binaries() {
cleanup_known_shm() {
echo "[start.sh] Cleaning known shared-memory segments..."
rm -f /dev/shm/radar_raw /dev/shm/radar_preprocessed /dev/shm/radar_results || true
rm -f \
/dev/shm/radar_raw \
/dev/shm/radar_raw_tap \
/dev/shm/radar_preprocessed \
/dev/shm/radar_preprocessed_tap \
/dev/shm/radar_results \
/dev/shm/radar_raw_kamil_adc \
/dev/shm/radar_raw_tap_kamil_adc \
/dev/shm/radar_preprocessed_kamil_adc \
/dev/shm/radar_preprocessed_tap_kamil_adc \
/dev/shm/radar_results_kamil_adc \
|| true
}
run_gui() {
if [[ -n "${PROFILE_PATH}" ]]; then
export RADAR_SYSTEM_PROFILE="${PROFILE_PATH}"
echo "[start.sh] Using config profile: ${PROFILE_PATH}"
fi
if ((AUTO_START == 1)); then
export RADAR_SYSTEM_AUTO_START=1
echo "[start.sh] GUI auto-start is enabled."
fi
echo "[start.sh] Launching GUI..."
exec "${VENV_PYTHON}" "${GUI_ENTRY}"
exec "${PYTHON_CMD}" "${GUI_ENTRY}"
}
run_producer_only() {
if ((KAMIL_ADC_MODE != 1)); then
echo "--producer-only currently requires --kamil-adc." >&2
exit 1
fi
if [[ -z "${PROFILE_PATH}" ]]; then
echo "--producer-only requires a resolved config profile." >&2
exit 1
fi
export PYTHONPATH="${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}"
echo "[start.sh] Starting Kamil ADC raw producer with profile: ${PROFILE_PATH}"
exec "${PYTHON_CMD}" -m python_app.scripts.kamil_adc_raw_producer --config "${PROFILE_PATH}"
}
main() {
parse_args "$@"
resolve_profile_path
check_environment
ensure_system_dependencies
ensure_usb_access_rules
if ((KAMIL_ADC_MODE == 0)); then
ensure_system_dependencies
ensure_usb_access_rules
fi
ensure_python_dependencies
if ((CLEAN_SHM == 1)); then
@@ -198,6 +308,10 @@ main() {
exit 0
fi
if ((PRODUCER_ONLY == 1)); then
run_producer_only
fi
run_gui
}
-3
View File
@@ -1,3 +0,0 @@
смотри сейчас будем добавлять в проект поддержку еще одного девайса в качестве радара. Когда будешь читать код смотри если есть файлы длинее чем 1300 строк то надо бы будет их грамотно разбить. В целом пиши код как мастер профессионал лучший в мире и самый опытный разработчик, пиши красивейший код, максимально читаемый, грамотный и понятный. Очень внимательнно смотри чтобы не было фолбеков, если в коде уже сейчас видишь какие то фолбеки то скажи где они и что делают, скорее всего будем удалять их в дальнейшем. И когда писать сейчас будешь то не создавай лишнего кода типа фолбеков изза отсутвтивия зависимостей и так далее, лишний код это плохо. объем в идеале уменьшать надо проекта.
Давай постепенно будем добавлять поддержку нового типа радара в код, для начала - сбор данных свипов. По пути /home/europa/Documents/kamil_adc лежит проект, который собирает свипы с нового девайса (там формат получается вроде бы как 0x0a step data1 data2 где data 1 это действтиельная часть а 2 это мнимая). Вот надо будет драйвер написать для интеграции в проект. как в случае с мультидевайсом можно только питоновский драйвер оставить, то есть будет запускаться код например из проекта kamil_adc а мы поверх него уже пишем нашу прослойку для интеграции в проект radar_system. Вот желательно немного кода добавтиь. И еще у этого девайса своя конфигурация, увидеть как настраивается устройство и какие параметры можно в проекте