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")); static_cast<float>(as_number(required_field(*sweep_obj, "start_hz"), "radar.sweep.start_hz"));
config.radar.sweep.stop_hz = config.radar.sweep.stop_hz =
static_cast<float>(as_number(required_field(*sweep_obj, "stop_hz"), "radar.sweep.stop_hz")); static_cast<float>(as_number(required_field(*sweep_obj, "stop_hz"), "radar.sweep.stop_hz"));
config.radar.sweep.points = if (const auto* points_value = optional_field(*sweep_obj, "points"); points_value != nullptr) {
number_to_u32(as_number(required_field(*sweep_obj, "points"), "radar.sweep.points"), "radar.sweep.points"); 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>( config.radar.sweep.if_bandwidth_hz = static_cast<float>(
as_number(required_field(*sweep_obj, "if_bandwidth_hz"), "radar.sweep.if_bandwidth_hz") 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_range_comp_power = 0.28F;
float gpr_angle_comp_power = 0.10F; float gpr_angle_comp_power = 0.10F;
float gpr_comp_power = 0.2F; float gpr_comp_power = 0.2F;
std::string gpr_score_mode = "combined";
float gpr_speed_m_s = 0.0F; float gpr_speed_m_s = 0.0F;
float gpr_look_angle_deg = 0.0F; float gpr_look_angle_deg = 0.0F;
float gpr_snr_thresh = 4.5F; float gpr_snr_thresh = 4.5F;
@@ -48,6 +49,7 @@ struct ProcessingLiveConfig {
bool gpr_background_subtract_enabled = true; bool gpr_background_subtract_enabled = true;
std::uint32_t gpr_background_mean_count = 10U; std::uint32_t gpr_background_mean_count = 10U;
bool gpr_remove_sidelobe_objects_enabled = true; bool gpr_remove_sidelobe_objects_enabled = true;
bool reprocess_current_result = true;
std::uint64_t history_command_seq = 0; std::uint64_t history_command_seq = 0;
HistoryCommand history_command = HistoryCommand::None; 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; 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) { for (std::size_t index = 0; index < preprocessed_history.size(); ++index) {
const auto replay_result = process_collection( const auto replay_result = process_collection(
preprocessed_history[index], preprocessed_history[index],
@@ -41,6 +41,13 @@ using Json = nlohmann::json;
throw std::runtime_error(field_name + " must be one of: point, extended"); 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) { void apply_legacy_gpr_algorithm_alias(ProcessingLiveConfig& config, const std::string& value) {
if (value == "backprojection") { if (value == "backprojection") {
return; 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>()); 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 (const auto found = root.find("gpr_speed_m_s"); found != root.end()) {
if (!found->is_number()) { if (!found->is_number()) {
throw std::runtime_error("processing.gpr_speed_m_s must be 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>(); 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()) { if (const auto found = root.find("history_command_seq"); found != root.end()) {
config.history_command_seq = parse_u64_number(*found, "processing.history_command_seq"); 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 kRangeWeightMax = 5.0;
constexpr double kAngleWeightMax = 2.0; constexpr double kAngleWeightMax = 2.0;
constexpr double kTotalWeightMax = 8.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 double kSmoothSigma = 1.5;
constexpr std::size_t kMaxObjects = 10U; constexpr std::size_t kMaxObjects = 10U;
@@ -30,6 +34,18 @@ constexpr double kSidelobeMinDxM = 0.35;
constexpr double kSidelobeMaxDzM = 0.70; constexpr double kSidelobeMaxDzM = 0.70;
constexpr double kSidelobeMaxRelativePeak = 0.85; 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; using PairKey = std::uint64_t;
struct GeometrySelection { struct GeometrySelection {
@@ -72,10 +88,13 @@ struct GridDefinition {
struct BpMap { struct BpMap {
std::vector<double> image{}; std::vector<double> image{};
std::vector<std::complex<double>> coherent{}; std::vector<std::complex<double>> coherent{};
std::vector<double> incoherent{};
std::vector<double> coherence_factor{};
}; };
struct ObjectRecord { struct ObjectRecord {
std::size_t index = 0U; std::size_t index = 0U;
std::size_t peak_index = 0U;
double x_peak_m = 0.0; double x_peak_m = 0.0;
double z_peak_m = 0.0; double z_peak_m = 0.0;
double x_m = 0.0; double x_m = 0.0;
@@ -87,6 +106,18 @@ struct ObjectRecord {
double center_area_cm2 = 0.0; double center_area_cm2 = 0.0;
double mean_value = 0.0; double mean_value = 0.0;
double sum_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> region_mask{};
std::vector<std::uint8_t> center_mask{}; std::vector<std::uint8_t> center_mask{};
bool sidelobe_candidate = false; bool sidelobe_candidate = false;
@@ -178,6 +209,28 @@ void fft_inplace(std::vector<std::complex<double>>& values, bool inverse) {
return median; 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> { [[nodiscard]] auto build_axis(double min_value, double max_value, std::size_t count) -> std::vector<double> {
std::vector<double> axis{}; std::vector<double> axis{};
if (count == 0U) { if (count == 0U) {
@@ -576,6 +629,50 @@ void validate_collection_trace_order(
return result; 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( [[nodiscard]] auto build_grid(
const std::vector<double>& x_tx, const std::vector<double>& x_tx,
const std::vector<double>& x_rx, const std::vector<double>& x_rx,
@@ -706,6 +803,8 @@ void validate_collection_trace_order(
BpMap result{}; BpMap result{};
result.image.assign(cell_count, 0.0); result.image.assign(cell_count, 0.0);
result.coherent.assign(cell_count, std::complex<double>(0.0, 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); std::vector<double> contribution_count(cell_count, 0.0);
for (const auto& trace : selected_traces) { for (const auto& trace : selected_traces) {
@@ -714,6 +813,10 @@ void validate_collection_trace_order(
if (ascan_it == ascans_by_pair.end()) { if (ascan_it == ascans_by_pair.end()) {
continue; continue;
} }
const auto& ascan = ascan_it->second;
if (ascan.time_s.empty()) {
continue;
}
const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth( const auto [geo_ref, angle_ref] = attenuation_components_at_ref_depth(
trace.tx_local_index, trace.tx_local_index,
@@ -736,10 +839,10 @@ void validate_collection_trace_order(
const double r_tx = tx_distances[cell_index]; const double r_tx = tx_distances[cell_index];
const double r_rx = rx_distances[cell_index]; const double r_rx = rx_distances[cell_index];
const double tau_s = (r_tx + r_rx) / velocity_mps; const double tau_s = (r_tx + r_rx) / velocity_mps;
const auto sample = interpolate_complex(ascan_it->second, tau_s); if (tau_s < ascan.time_s.front() || tau_s > ascan.time_s.back()) {
if (sample == std::complex<double>(0.0, 0.0)) {
continue; continue;
} }
const auto sample = interpolate_complex(ascan, tau_s);
const double weight = compensation_weight( const double weight = compensation_weight(
r_tx, r_tx,
@@ -751,6 +854,7 @@ void validate_collection_trace_order(
angle_power angle_power
); );
result.coherent[cell_index] += sample * weight; result.coherent[cell_index] += sample * weight;
result.incoherent[cell_index] += std::abs(sample) * weight;
contribution_count[cell_index] += 1.0; contribution_count[cell_index] += 1.0;
} }
} }
@@ -761,7 +865,10 @@ void validate_collection_trace_order(
continue; continue;
} }
result.coherent[index] /= contribution_count[index]; result.coherent[index] /= contribution_count[index];
result.incoherent[index] /= contribution_count[index];
result.image[index] = std::abs(result.coherent[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; 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( [[nodiscard]] auto neighbor_indices(
std::size_t index, std::size_t index,
std::size_t width, std::size_t width,
@@ -1045,6 +1167,7 @@ void apply_depth_gate(
ObjectRecord object{}; ObjectRecord object{};
object.index = objects.size() + 1U; object.index = objects.size() + 1U;
object.peak_index = peak_index;
object.x_peak_m = x_peak; object.x_peak_m = x_peak;
object.z_peak_m = z_peak; object.z_peak_m = z_peak;
object.x_m = x_center; 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( [[nodiscard]] auto flatten_table(
const std::vector<std::vector<float>>& rows, const std::vector<std::vector<float>>& rows,
std::uint32_t column_count std::uint32_t column_count
@@ -1248,6 +1552,7 @@ void mark_sidelobe_candidates(
if (ascans_by_pair.empty()) { if (ascans_by_pair.empty()) {
return results; 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); const auto grid = build_grid(selection.x_tx, selection.x_rx, max_depth_m, kGridZMinM);
if (grid.x_grid.empty() || grid.z_grid.empty()) { if (grid.x_grid.empty() || grid.z_grid.empty()) {
@@ -1270,13 +1575,15 @@ void mark_sidelobe_candidates(
return results; return results;
} }
normalize_in_place(bp.image); auto display_map = normalize_bp_map(bp.image, grid, min_depth_m, max_depth_m, kSmoothSigma);
auto display_map = gaussian_filter_2d(bp.image, grid.x_grid.size(), grid.z_grid.size(), kSmoothSigma); const auto incoherent_display_map =
apply_depth_gate(display_map, grid.z_grid, grid.x_grid.size(), min_depth_m, max_depth_m); normalize_bp_map(bp.incoherent, grid, min_depth_m, max_depth_m, kSmoothSigma);
normalize_in_place(display_map);
auto objects = find_bp_objects(display_map, grid); 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); 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) { if (live_config.gpr_remove_sidelobe_objects_enabled) {
for (const auto& object : objects) { for (const auto& object : objects) {
@@ -1295,15 +1602,12 @@ void mark_sidelobe_candidates(
std::vector<std::vector<float>> point_rows{}; std::vector<std::vector<float>> point_rows{};
point_rows.reserve(objects.size()); point_rows.reserve(objects.size());
for (const auto& object : objects) { for (const auto* object : output_objects_sorted(objects, live_config)) {
if (live_config.gpr_remove_sidelobe_objects_enabled && object.sidelobe_candidate) {
continue;
}
point_rows.push_back( point_rows.push_back(
{ {
static_cast<float>(object.x_m), static_cast<float>(object->x_m),
static_cast<float>(object.z_m), static_cast<float>(object->z_m),
static_cast<float>(object.peak), 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. - `executable_path` is mandatory and must name the real Raspberry Pi binary.
- The producer appends `tty:<tty_path>` automatically; do not put `tty:` in - The producer appends `tty:<tty_path>` automatically; do not put `tty:` in
`radar.kamil_adc.args`. `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`. - The laser-control driver is vendored under `python_app.hardware_full.laser_control`.
- `laser_control` and `kamil_adc` are treated as one hardware configuration. - `laser_control` and `kamil_adc` are treated as one hardware configuration.
Changing either section requires restarting acquisition so the lasers are 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`, 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 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` ### `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 from datetime import datetime
import html import html
import json import json
import os
from pathlib import Path from pathlib import Path
import traceback import traceback
@@ -217,6 +218,7 @@ class AppWindow(
self._on_processing_mode_changed(self._processing_mode.currentText()) self._on_processing_mode_changed(self._processing_mode.currentText())
self._write_live_processing_config() self._write_live_processing_config()
self._timer.start() self._timer.start()
self._maybe_auto_start_pipeline()
def _start_locator_service(self) -> None: def _start_locator_service(self) -> None:
"""Start embedded locator TCP service without failing the GUI.""" """Start embedded locator TCP service without failing the GUI."""
@@ -268,6 +270,13 @@ class AppWindow(
def _resolve_startup_profile_path(self) -> Path: def _resolve_startup_profile_path(self) -> Path:
"""Resolve active profile path from session-state or root fallback 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: try:
session_state = self._gui_session_state_store.load() session_state = self._gui_session_state_store.load()
except Exception as exc: except Exception as exc:
@@ -287,6 +296,14 @@ class AppWindow(
profile_path = (self._project_root / profile_path).resolve(strict=False) profile_path = (self._project_root / profile_path).resolve(strict=False)
return profile_path 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: def _normalize_profile_path(self, path: Path) -> Path:
"""Return normalized absolute profile path.""" """Return normalized absolute profile path."""
return path.expanduser().resolve(strict=False) return path.expanduser().resolve(strict=False)
@@ -24,7 +24,12 @@ class AppWindowLiveProcessingMixin:
self._live_processing_config(), 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.""" """Build live processing config from current processing widgets."""
self._sync_bscan_frequency_limits_with_radar() self._sync_bscan_frequency_limits_with_radar()
self._sync_gpr_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_range_comp_power=float(self._gpr_range_comp_power.value()),
gpr_angle_comp_power=float(self._gpr_angle_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_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_speed_m_s=float(self._legacy_gpr_speed_m_s.value()),
gpr_look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()), gpr_look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
gpr_snr_thresh=float(self._legacy_gpr_snr_thresh.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_subtract_enabled=gpr_background_enabled,
gpr_background_mean_count=gpr_background_mean_count, gpr_background_mean_count=gpr_background_mean_count,
gpr_remove_sidelobe_objects_enabled=bool(self._gpr_remove_sidelobe_objects_enabled.isChecked()), 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_seq=int(self._history_command_seq),
history_command=str(history_command), 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.""" """Persist current live processing config to runtime JSON file."""
if bump_history_seq: if bump_history_seq:
self._history_command_seq += 1 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: def _on_processing_live_settings_changed(self, *_args) -> None:
"""Handle live-processing setting changes and trigger redraw when needed.""" """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"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"range_comp={self._gpr_range_comp_power.value():g}, "
f"angle_comp={self._gpr_angle_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"background_subtract={self._gpr_background_subtract_enabled.isChecked()}, "
f"mean_count={self._gpr_background_mean_count.value()}, " f"mean_count={self._gpr_background_mean_count.value()}, "
f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, " f"remove_sidelobes={self._gpr_remove_sidelobe_objects_enabled.isChecked()}, "
f"render_mode={self._gpr_render_mode.currentText()}, " 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": elif mode == "legacy_gpr":
self._log( self._log(
@@ -70,6 +70,52 @@ class AppWindowConfigProfileIOMixin:
self._pass_through_y_min_db.setEnabled(enabled) self._pass_through_y_min_db.setEnabled(enabled)
self._pass_through_y_max_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: def _apply_history_limit_from_config(self, config) -> None:
"""Resize in-memory history buffers to match the loaded config.""" """Resize in-memory history buffers to match the loaded config."""
history_limit = self._history_limit_for_config(config) history_limit = self._history_limit_for_config(config)
@@ -101,12 +147,17 @@ class AppWindowConfigProfileIOMixin:
self._defaults_config = profile.run_config.clone() self._defaults_config = profile.run_config.clone()
self._gui_defaults = profile.gui self._gui_defaults = profile.gui
self._remember_active_profile_path(output_path) 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( self._log(
f"Config profile saved: path={output_path}, " f"Config profile saved: path={output_path}, "
f"combos={len(profile.run_config.combos)}, " f"combos={len(profile.run_config.combos)}, "
f"sweep={profile.run_config.radar.sweep.start_hz:g}.." f"sweep={profile.run_config.radar.sweep.start_hz:g}.."
f"{profile.run_config.radar.sweep.stop_hz:g} Hz, " 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"ifbw={profile.run_config.radar.sweep.if_bandwidth_hz:g} Hz, "
f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, " f"power={profile.run_config.radar.sweep.power_dbm:g} dBm, "
f"processing_mode={profile.gui.processing.selected_mode}" f"processing_mode={profile.gui.processing.selected_mode}"
@@ -203,6 +254,9 @@ class AppWindowConfigProfileIOMixin:
self._gpr_max_depth_m, self._gpr_max_depth_m,
self._gpr_range_comp_power, self._gpr_range_comp_power,
self._gpr_angle_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_start_freq_mhz,
self._gpr_stop_freq_mhz, self._gpr_stop_freq_mhz,
self._gpr_background_subtract_enabled, self._gpr_background_subtract_enabled,
@@ -225,6 +279,7 @@ class AppWindowConfigProfileIOMixin:
self._legacy_gpr_start_freq_mhz, self._legacy_gpr_start_freq_mhz,
self._legacy_gpr_stop_freq_mhz, self._legacy_gpr_stop_freq_mhz,
self._legacy_gpr_speed_m_s, self._legacy_gpr_speed_m_s,
self._legacy_gpr_ignore_socket_speed_enabled,
self._legacy_gpr_look_angle_deg, self._legacy_gpr_look_angle_deg,
self._legacy_gpr_background_subtract_enabled, self._legacy_gpr_background_subtract_enabled,
self._legacy_gpr_background_mean_count, self._legacy_gpr_background_mean_count,
@@ -237,6 +292,35 @@ class AppWindowConfigProfileIOMixin:
self._save_count, self._save_count,
self._save_path_input, self._save_path_input,
self._save_name_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: with ExitStack() as blockers:
@@ -249,6 +333,42 @@ class AppWindowConfigProfileIOMixin:
self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}") self._ifbw_input.setText(f"{config.radar.sweep.if_bandwidth_hz:g}")
self._power_input.setText(f"{config.radar.sweep.power_dbm:g}") self._power_input.setText(f"{config.radar.sweep.power_dbm:g}")
self._settling_ms.setText(str(int(config.runtime.settling_ms))) 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._combos_text.setText(str(gui_state.switches.combos_text))
self._single_combo_output.setText(str(gui_state.switches.single_output)) 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_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_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._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_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_stop_freq_mhz.setValue(float(gui_state.processing.gpr.stop_freq_mhz))
self._gpr_background_subtract_enabled.setChecked( 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_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_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_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_look_angle_deg.setValue(float(gui_state.processing.legacy_gpr.look_angle_deg))
self._legacy_gpr_background_subtract_enabled.setChecked( self._legacy_gpr_background_subtract_enabled.setChecked(
bool(gui_state.processing.legacy_gpr.background_subtract_enabled) bool(gui_state.processing.legacy_gpr.background_subtract_enabled)
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import json
from python_app.models.gui_profile_model import ( from python_app.models.gui_profile_model import (
GuiBscanStateModel, GuiBscanStateModel,
GuiDataActionsStateModel, GuiDataActionsStateModel,
@@ -42,6 +44,27 @@ class AppWindowConfigStateBuildersMixin:
values.append(int(token)) values.append(int(token))
return values 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 @staticmethod
def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]: def _parse_gpr_tx_geometry_text(text: str) -> list[GprTxGeometryModel]:
"""Parse line-based Tx geometry editor text.""" """Parse line-based Tx geometry editor text."""
@@ -172,6 +195,9 @@ class AppWindowConfigStateBuildersMixin:
max_depth_m=14.0, max_depth_m=14.0,
range_comp_power=0.28, range_comp_power=0.28,
angle_comp_power=0.10, angle_comp_power=0.10,
score_mode="combined",
max_detected_objects_to_draw=5,
draw_top_m_objects=2,
start_freq_mhz=3000.0, start_freq_mhz=3000.0,
stop_freq_mhz=6000.0, stop_freq_mhz=6000.0,
background_subtract_enabled=True, background_subtract_enabled=True,
@@ -193,6 +219,7 @@ class AppWindowConfigStateBuildersMixin:
start_freq_mhz=3000.0, start_freq_mhz=3000.0,
stop_freq_mhz=6000.0, stop_freq_mhz=6000.0,
speed_m_s=0.0, speed_m_s=0.0,
ignore_socket_speed_enabled=False,
look_angle_deg=0.0, look_angle_deg=0.0,
snr_thresh=4.5, snr_thresh=4.5,
snr_comp_max=25.0, snr_comp_max=25.0,
@@ -280,6 +307,9 @@ class AppWindowConfigStateBuildersMixin:
max_depth_m=float(self._gpr_max_depth_m.value()), max_depth_m=float(self._gpr_max_depth_m.value()),
range_comp_power=float(self._gpr_range_comp_power.value()), range_comp_power=float(self._gpr_range_comp_power.value()),
angle_comp_power=float(self._gpr_angle_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()), start_freq_mhz=float(self._gpr_start_freq_mhz.value()),
stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()), stop_freq_mhz=float(self._gpr_stop_freq_mhz.value()),
background_subtract_enabled=bool(self._gpr_background_subtract_enabled.isChecked()), 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()), start_freq_mhz=float(self._legacy_gpr_start_freq_mhz.value()),
stop_freq_mhz=float(self._legacy_gpr_stop_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()), 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()), look_angle_deg=float(self._legacy_gpr_look_angle_deg.value()),
snr_thresh=float(self._legacy_gpr_snr_thresh.value()), snr_thresh=float(self._legacy_gpr_snr_thresh.value()),
snr_comp_max=float(self._legacy_gpr_snr_comp_max.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.points = int(self._points_input.text().strip())
config.radar.sweep.if_bandwidth_hz = float(self._ifbw_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()) 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.settling_ms = int(self._settling_ms.text().strip())
config.runtime.processing_live_config_path = str(self._live_config_writer.path) 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: def _radar_key_from_ui(self) -> str:
"""Build current radar key directly from radar widgets only.""" """Build current radar key directly from radar widgets only."""
model_name = self._defaults_config.radar.model or RunConfigModel.LIBREVNA_MODEL 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( return radar_key_from_config(
model_name=model_name, model_name=model_name,
serial=self._defaults_config.radar.serial, serial=self._defaults_config.radar.serial,
@@ -404,9 +447,46 @@ class AppWindowConfigStateBuildersMixin:
sweep_points=int(self._points_input.text().strip()), sweep_points=int(self._points_input.text().strip()),
ifbw_hz=float(self._ifbw_input.text().strip()), ifbw_hz=float(self._ifbw_input.text().strip()),
power_dbm=float(self._power_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 @staticmethod
def _switches_are_effectively_static(config: RunConfigModel) -> bool: def _switches_are_effectively_static(config: RunConfigModel) -> bool:
"""Return `True` when non-GPR switch setup effectively yields one fixed combo.""" """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.constraints import validate_processing_mode_constraints
from python_app.gui.runtime.history import build_run_history_signature, record_result_history 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.hardware_full.single_radar_service import create_single_radar_service
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
from python_app.models.run_config_model import RunConfigModel 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) self._config_writer.prepare_preprocess_bundles(self._store, radar_key, config)
config.runtime.continuous = not single_capture 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) self._prepare_radar_for_native_acquisition(config)
config_path = self._config_writer.write(config, self._project_root / "python_app/runtime/run_config.json") 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" "matches the current config"
) )
self._prepare_radar_for_native_acquisition(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( self._log(
"Radar settings applied: " "Radar settings applied: "
f"start={config.radar.sweep.start_hz:g} Hz, " f"start={config.radar.sweep.start_hz:g} Hz, "
f"stop={config.radar.sweep.stop_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"ifbw={config.radar.sweep.if_bandwidth_hz:g} Hz, "
f"power={config.radar.sweep.power_dbm:g} dBm" 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") self._log("Multi-device raw producer will configure all LibreVNA devices")
return return
if config.is_kamil_adc: 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 return
radar_service = create_single_radar_service(config) radar_service = create_single_radar_service(config)
@@ -272,6 +281,7 @@ class AppWindowPipelineMixin:
try: try:
self._drain_locator_speed_updates() self._drain_locator_speed_updates()
self._drain_locator_log_updates()
if self._raw_reader is not None: if self._raw_reader is not None:
self._read_all_raw() self._read_all_raw()
self._read_all_preprocessed() self._read_all_preprocessed()
@@ -483,6 +493,8 @@ class AppWindowPipelineMixin:
return return
if self._processing_mode.currentText() != "legacy_gpr": if self._processing_mode.currentText() != "legacy_gpr":
return return
if self._legacy_gpr_ignore_socket_speed_enabled.isChecked():
return
previous_speed_m_s = float(self._legacy_gpr_speed_m_s.value()) previous_speed_m_s = float(self._legacy_gpr_speed_m_s.value())
with QSignalBlocker(self._legacy_gpr_speed_m_s): with QSignalBlocker(self._legacy_gpr_speed_m_s):
@@ -491,7 +503,14 @@ class AppWindowPipelineMixin:
if current_speed_m_s == previous_speed_m_s: if current_speed_m_s == previous_speed_m_s:
return 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: def _publish_locator_snapshot_from_collection(self, collection: ResultCollection) -> None:
"""Publish one locator snapshot from a GPR result collection.""" """Publish one locator snapshot from a GPR result collection."""
@@ -501,6 +520,7 @@ class AppWindowPipelineMixin:
collection, collection,
self._gpr_locator_threshold(), self._gpr_locator_threshold(),
visible_bounds=self._gpr_visible_object_bounds(), visible_bounds=self._gpr_visible_object_bounds(),
object_draw_limits=self._gpr_draw_limits(),
) )
def _publish_locator_snapshot_from_latest_result(self) -> None: 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) 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: 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( self._gpr_points_item.setData(
x=points[:, 0], x=points[:, 0],
y=points[:, 1], y=points[:, 1],
@@ -368,6 +376,26 @@ class AppWindowGprPlotMixin:
return float(self._legacy_gpr_min_visible_pair_count.value()) return float(self._legacy_gpr_min_visible_pair_count.value())
return float(self._gpr_min_visible_score.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 @staticmethod
def _gpr_display_y_min(z_min: float, z_max: float) -> float: def _gpr_display_y_min(z_min: float, z_max: float) -> float:
"""Return lower display bound, preserving surface markers only when surface is visible.""" """Return lower display bound, preserving surface markers only when surface is visible."""
@@ -483,7 +511,7 @@ class AppWindowGprPlotMixin:
return extract_gpr_object_rows(collection) return extract_gpr_object_rows(collection)
def _filtered_gpr_object_rows(self, collection: ResultCollection) -> np.ndarray: 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) rows = self._gpr_object_rows(collection)
if rows.size == 0: if rows.size == 0:
return rows return rows
@@ -499,7 +527,7 @@ class AppWindowGprPlotMixin:
& (rows[:, 1] >= z_min) & (rows[:, 1] >= z_min)
& (rows[:, 1] <= z_max) & (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: def _draw_gpr_objects_only(self, collection: ResultCollection) -> bool:
"""Draw only detected GPR objects inside configured X/Z bounds.""" """Draw only detected GPR objects inside configured X/Z bounds."""
@@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
from python_app.gui.preprocess_dialog import PreprocessDialog 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 ( from python_app.orchestration.preprocess_assets import (
PREPROCESS_ASSET_SPECS, PREPROCESS_ASSET_SPECS,
VISIBLE_PREPROCESS_ASSET_KEYS, VISIBLE_PREPROCESS_ASSET_KEYS,
preprocess_asset_channel, preprocess_asset_channel,
preprocess_asset_display_name, 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 ( from python_app.workflows.multi_radar_capture_workflow import (
MultiRadarCaptureBatch, MultiRadarCaptureBatch,
MultiRadarSequentialCaptureSession, MultiRadarSequentialCaptureSession,
@@ -185,6 +187,8 @@ class AppWindowPreprocessMixin:
dialog.undo_last_requested.connect(self._undo_last_capture) dialog.undo_last_requested.connect(self._undo_last_capture)
dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence) dialog.finalize_sequence_requested.connect(self._finalize_capture_sequence)
dialog.abort_sequence_requested.connect(self._abort_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( dialog.set_radar_config_summary(
directory_path=self._preprocess_radar_scan_summary.directory_path, directory_path=self._preprocess_radar_scan_summary.directory_path,
json_file_count=self._preprocess_radar_scan_summary.json_file_count, 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)}" f"{preprocess_asset_display_name(key)}={len(names)}"
for key, names in available_sets.items() 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}") self._log(f"Preprocess set lists refreshed: radar_key={radar_key}, {available_counts}")
if unavailable_selections: if unavailable_selections:
self._log_warning( self._log_warning(
@@ -338,6 +343,88 @@ class AppWindowPreprocessMixin:
self._show_exception(f"Failed to start {kind} sequence", exc) self._show_exception(f"Failed to start {kind} sequence", exc)
self._resume_pipeline_if_needed() 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( def _build_single_radar_capture_session(
self, self,
*, *,
@@ -209,6 +209,18 @@ def build_processing_group(owner) -> QGroupBox:
owner._gpr_angle_comp_power.setSingleStep(0.01) owner._gpr_angle_comp_power.setSingleStep(0.01)
owner._gpr_angle_comp_power.setValue(float(gpr_live_defaults.angle_comp_power)) 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 = QDoubleSpinBox()
owner._gpr_start_freq_mhz.setDecimals(1) owner._gpr_start_freq_mhz.setDecimals(1)
owner._gpr_start_freq_mhz.setRange(100.0, 8800.0) 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), ("Max depth m", owner._gpr_max_depth_m),
("Range comp power", owner._gpr_range_comp_power), ("Range comp power", owner._gpr_range_comp_power),
("Angle comp power", owner._gpr_angle_comp_power), ("Angle comp power", owner._gpr_angle_comp_power),
("Score mode", owner._gpr_score_mode),
("Render mode", owner._gpr_render_mode), ("Render mode", owner._gpr_render_mode),
("Min visible score", owner._gpr_min_visible_score), ("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), ("Start MHz", owner._gpr_start_freq_mhz),
("Stop MHz", owner._gpr_stop_freq_mhz), ("Stop MHz", owner._gpr_stop_freq_mhz),
("Visible X min m", owner._gpr_visible_x_min_m), ("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.setSingleStep(0.01)
owner._legacy_gpr_speed_m_s.setValue(float(legacy_gpr_defaults.speed_m_s)) 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 = QDoubleSpinBox()
owner._legacy_gpr_look_angle_deg.setDecimals(2) owner._legacy_gpr_look_angle_deg.setDecimals(2)
owner._legacy_gpr_look_angle_deg.setRange(-90.0, 90.0) 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 thresh", owner._legacy_gpr_snr_thresh),
("SNR comp max", owner._legacy_gpr_snr_comp_max), ("SNR comp max", owner._legacy_gpr_snr_comp_max),
("Speed m/s", owner._legacy_gpr_speed_m_s), ("Speed m/s", owner._legacy_gpr_speed_m_s),
owner._legacy_gpr_ignore_socket_speed_enabled,
("Render mode", owner._legacy_gpr_render_mode), ("Render mode", owner._legacy_gpr_render_mode),
("Min visible pairs", owner._legacy_gpr_min_visible_pair_count), ("Min visible pairs", owner._legacy_gpr_min_visible_pair_count),
("Start MHz", owner._legacy_gpr_start_freq_mhz), ("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_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_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_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_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_stop_freq_mhz.valueChanged.connect(owner._on_processing_live_settings_changed)
owner._gpr_background_subtract_enabled.toggled.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_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_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_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_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_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) owner._gpr_visible_z_min_m.valueChanged.connect(owner._on_gpr_locator_window_changed)
@@ -2,7 +2,18 @@
from __future__ import annotations 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 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) layout.setSpacing(8)
defaults = owner._defaults_config.radar 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._start_hz_input = QLineEdit(f"{defaults.sweep.start_hz:g}")
owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}") owner._stop_hz_input = QLineEdit(f"{defaults.sweep.stop_hz:g}")
owner._points_input = QLineEdit(str(defaults.sweep.points)) 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 = QLabel("Device limits are not available.")
owner._radar_limits_hint.setObjectName("hintLabel") owner._radar_limits_hint.setObjectName("hintLabel")
layout.addWidget( vna_layout.addWidget(
build_two_column_form_widget( build_two_column_form_widget(
group, 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 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() undo_last_requested = pyqtSignal()
finalize_sequence_requested = pyqtSignal() finalize_sequence_requested = pyqtSignal()
abort_sequence_requested = pyqtSignal() abort_sequence_requested = pyqtSignal()
create_kamil_adc_neutral_sets_requested = pyqtSignal()
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
"""Initialize window metadata and compose dialog UI.""" """Initialize window metadata and compose dialog UI."""
@@ -88,8 +89,17 @@ class PreprocessDialog(QDialog):
self._set_name_input = QLineEdit("set_001", group) self._set_name_input = QLineEdit("set_001", group)
refresh_button = QPushButton("Refresh Sets", group) refresh_button = QPushButton("Refresh Sets", group)
refresh_button.clicked.connect(self.refresh_requested.emit) 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(QLabel("Set name"))
header_row.addWidget(self._set_name_input, stretch=1) header_row.addWidget(self._set_name_input, stretch=1)
header_row.addWidget(self._kamil_adc_neutral_sets_button)
header_row.addWidget(refresh_button) header_row.addWidget(refresh_button)
layout.addLayout(header_row) layout.addLayout(header_row)
layout.addWidget(self._build_radar_config_group(group)) layout.addWidget(self._build_radar_config_group(group))
@@ -369,6 +379,11 @@ class PreprocessDialog(QDialog):
"""Set short human-readable status line.""" """Set short human-readable status line."""
self._status_label.setText(message) 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: def reset_preview(self) -> None:
"""Clear preview surface and restore default empty-state text when possible.""" """Clear preview surface and restore default empty-state text when possible."""
if self._preview_plot is not None: 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).""" """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) 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) 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 ( return (
str(config.radar.model), str(config.radar.model),
str(config.radar.driver_mode), str(config.radar.driver_mode),
@@ -73,7 +74,7 @@ def build_run_history_signature(
bool(config.radar.multi_device.force_external_reference), bool(config.radar.multi_device.force_external_reference),
float(config.radar.sweep.start_hz), float(config.radar.sweep.start_hz),
float(config.radar.sweep.stop_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.if_bandwidth_hz),
float(config.radar.sweep.power_dbm), float(config.radar.sweep.power_dbm),
str(config.input_switch.driver_mode), str(config.input_switch.driver_mode),
+191 -122
View File
@@ -10,10 +10,10 @@ import os
from pathlib import Path from pathlib import Path
import select import select
import signal import signal
import stat
import struct import struct
import subprocess import subprocess
import time import time
from typing import Any
import numpy as np import numpy as np
@@ -67,6 +67,7 @@ class KamilAdcTtyReader:
tty_path: str tty_path: str
_fd: int | None = field(init=False, default=None, repr=False) _fd: int | None = field(init=False, default=None, repr=False)
_buffer: bytearray = field(init=False, default_factory=bytearray, 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: def open(self) -> None:
"""Open the configured TTY path for binary reads.""" """Open the configured TTY path for binary reads."""
@@ -83,36 +84,67 @@ class KamilAdcTtyReader:
finally: finally:
self._fd = None self._fd = None
self._buffer.clear() self._buffer.clear()
self._packet_start_pending = False
def read_sweep( def read_sweep(
self, self,
*, *,
points: int,
timeout_s: float, timeout_s: float,
process: subprocess.Popen[bytes] | None = None, process: subprocess.Popen[bytes] | None = None,
expected_points: int | None = None,
) -> np.ndarray: ) -> np.ndarray:
"""Read one packet-start marker followed by exactly `points` IQ frames.""" """Read one full packet, optionally discarding packets with an unexpected point count."""
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 self._fd is None: if self._fd is None:
raise RuntimeError("Kamil ADC TTY reader is not open") 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) 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) def _read_one_sweep(
for index in range(points): self,
frame = self._read_frame(deadline, process, received_points=index, expected_points=points) deadline: float,
values[index] = KamilAdcFrameParser.parse_point(frame, index + 1) process: subprocess.Popen[bytes] | None,
return values ) -> 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: 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: if self._fd is None:
raise RuntimeError("Kamil ADC TTY reader is not open") raise RuntimeError("Kamil ADC TTY reader is not open")
self._buffer.clear() self._buffer.clear()
self._packet_start_pending = False
fd = self._require_fd() fd = self._require_fd()
while True: while True:
self._raise_if_process_exited(process) 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 raise RuntimeError(f"Failed to drain Kamil ADC TTY `{self.tty_path}`: {exc}") from exc
if not chunk: if not chunk:
raise RuntimeError(f"Kamil ADC TTY `{self.tty_path}` closed while draining") 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( def _read_until_packet_start(
self, self,
@@ -153,7 +196,7 @@ class KamilAdcTtyReader:
process: subprocess.Popen[bytes] | None, process: subprocess.Popen[bytes] | None,
*, *,
received_points: int, received_points: int,
expected_points: int, expected_points: int | None = None,
) -> bytes: ) -> bytes:
while len(self._buffer) < KAMIL_ADC_FRAME_BYTES: while len(self._buffer) < KAMIL_ADC_FRAME_BYTES:
self._read_available(deadline, process, received_points, expected_points) self._read_available(deadline, process, received_points, expected_points)
@@ -172,6 +215,10 @@ class KamilAdcTtyReader:
remaining_s = deadline - time.monotonic() remaining_s = deadline - time.monotonic()
if remaining_s <= 0.0: if remaining_s <= 0.0:
if received_points is None or expected_points is None: 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("Timed out waiting for Kamil ADC packet-start marker")
raise TimeoutError( raise TimeoutError(
f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points" f"Timed out waiting for Kamil ADC sweep: received {received_points}/{expected_points} points"
@@ -214,15 +261,14 @@ class KamilAdcTtyReader:
@dataclass(slots=True) @dataclass(slots=True)
class KamilAdcService: class KamilAdcService:
"""Launch `kamil_adc`, configure laser board, and acquire TTY sweeps.""" """Launch `kamil_adc` and acquire TTY sweeps."""
config: RunConfigModel config: RunConfigModel
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False) _process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
_reader: KamilAdcTtyReader | 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) _settings: RadarSweepModel | None = field(init=False, default=None, repr=False)
_frequency_hz: np.ndarray | 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) _expected_points: int | None = field(init=False, default=None, repr=False)
_laser_variation_active: bool = field(init=False, default=False, repr=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self._validate_config() self._validate_config()
@@ -235,13 +281,12 @@ class KamilAdcService:
return [executable_path, *adc.args, f"tty:{adc.tty_path}"] return [executable_path, *adc.args, f"tty:{adc.tty_path}"]
def open(self) -> None: 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: if self._reader is not None:
return 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: try:
self._apply_laser_control()
self._start_process() self._start_process()
self._wait_for_tty(previous_tty_identity) self._wait_for_tty(previous_tty_identity)
reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path) reader = KamilAdcTtyReader(self.config.radar.kamil_adc.tty_path)
@@ -252,25 +297,20 @@ class KamilAdcService:
raise raise
def close(self) -> None: 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: if self._reader is not None:
with suppress(Exception): with suppress(Exception):
self._reader.close() self._reader.close()
self._reader = None self._reader = None
self._stop_process() self._stop_process()
self._close_laser_control()
def configure(self, sweep: RadarSweepModel) -> None: 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._validate_sweep(sweep)
self._settings = sweep self._settings = sweep
self._frequency_hz = np.linspace( self._frequency_hz = None
float(sweep.start_hz), self._expected_points = None
float(sweep.stop_hz),
int(sweep.points),
dtype=np.float32,
)
def read_device_limits(self) -> dict[str, float | int]: def read_device_limits(self) -> dict[str, float | int]:
"""Kamil ADC has no runtime-readable sweep limit API.""" """Kamil ADC has no runtime-readable sweep limit API."""
@@ -278,7 +318,7 @@ class KamilAdcService:
def acquire(self) -> SweepResult: def acquire(self) -> SweepResult:
"""Acquire one Kamil ADC sweep as S21; fill S11 with explicit zeros.""" """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") raise RuntimeError("Kamil ADC service is not configured")
if self._reader is None: if self._reader is None:
raise RuntimeError("Kamil ADC service is not open") raise RuntimeError("Kamil ADC service is not open")
@@ -287,13 +327,21 @@ class KamilAdcService:
code = None if process is None else process.poll() code = None if process is None else process.poll()
raise RuntimeError(f"Kamil ADC process is not running (code={code})") raise RuntimeError(f"Kamil ADC process is not running (code={code})")
points = int(self._settings.points)
self._reader.discard_pending(process) self._reader.discard_pending(process)
s21 = self._reader.read_sweep( s21 = self._reader.read_sweep(
points=points,
timeout_s=self.config.radar.kamil_adc.sweep_timeout_s, timeout_s=self.config.radar.kamil_adc.sweep_timeout_s,
process=process, 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( return SweepResult(
x=self._frequency_hz.copy(), x=self._frequency_hz.copy(),
traces={ traces={
@@ -353,79 +401,6 @@ class KamilAdcService:
f"Timed out waiting for Kamil ADC TTY `{adc.tty_path}` to be created by the collector" 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: def _validate_config(self) -> None:
if not self.config.is_kamil_adc: if not self.config.is_kamil_adc:
raise RuntimeError("KamilAdcService requires radar.model='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): if not os.access(executable_path, os.X_OK):
raise RuntimeError(f"radar.kamil_adc.executable_path is not executable: {executable_path}") 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 @staticmethod
def _validate_sweep(sweep: RadarSweepModel) -> None: 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): if float(sweep.stop_hz) < float(sweep.start_hz):
raise ValueError("Kamil ADC sweep stop_hz must be >= 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: def _tty_identity(path: str) -> tuple[object, ...] | None:
try: try:
if os.path.islink(path): 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) stat_result = os.stat(path)
except FileNotFoundError: except FileNotFoundError:
return None return None
@@ -491,3 +468,95 @@ def _tty_identity(path: str) -> tuple[object, ...] | None:
int(stat_result.st_ino), int(stat_result.st_ino),
int(stat_result.st_mtime_ns), 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 regulator coefficients (match firmware defaults)
DEFAULT_PI_P = 2560 # 10 * 256 DEFAULT_PI_P = 2560 # 10 * 256
DEFAULT_PI_I = 128 # 0.5 * 256 DEFAULT_PI_I = 128 # 0.5 * 256
DEVICE_MAIN_MESSAGE_ID = 0x00FF
class LaserController: class LaserController:
@@ -121,6 +122,7 @@ class LaserController:
temp2: float, temp2: float,
current1: float, current1: float,
current2: float, current2: float,
message_id: Optional[int] = None,
) -> None: ) -> None:
""" """
Set manual control parameters for both lasers. Set manual control parameters for both lasers.
@@ -134,6 +136,9 @@ class LaserController:
Valid range: [15.0 60.0] mA. Valid range: [15.0 60.0] mA.
current2: Drive current for laser 2, mA. current2: Drive current for laser 2, mA.
Valid range: [15.0 60.0] 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: Raises:
ValidationError: If any parameter is out of range. ValidationError: If any parameter is out of range.
@@ -142,7 +147,10 @@ class LaserController:
validated = ParameterValidator.validate_manual_mode_params( validated = ParameterValidator.validate_manual_mode_params(
temp1, temp2, current1, current2 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( cmd = Protocol.encode_decode_enable(
temp1=validated['temp1'], temp1=validated['temp1'],
@@ -244,7 +252,7 @@ class LaserController:
validated['max_value'], validated['max_value'],
validated['step']) 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. """Stop the current task and restore manual mode.
Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last
@@ -257,20 +265,13 @@ class LaserController:
self._send_and_read_state(cmd_reset) self._send_and_read_state(cmd_reset)
logger.info("Task stopped (DEFAULT_ENABLE sent)") logger.info("Task stopped (DEFAULT_ENABLE sent)")
# Restore manual mode so the board is ready for TRANS_ENABLE requests self.set_manual_mode(
self._message_id = (self._message_id + 1) & 0xFFFF
cmd_restore = Protocol.encode_decode_enable(
temp1=self._last_temp1, temp1=self._last_temp1,
temp2=self._last_temp2, temp2=self._last_temp2,
current1=self._last_current1, current1=self._last_current1,
current2=self._last_current2, current2=self._last_current2,
pi_coeff1_p=self._pi1_p, message_id=restore_message_id,
pi_coeff1_i=self._pi1_i,
pi_coeff2_p=self._pi2_p,
pi_coeff2_i=self._pi2_i,
message_id=self._message_id,
) )
self._send_and_read_state(cmd_restore)
logger.info("Manual mode restored after task stop") logger.info("Manual mode restored after task stop")
def get_measurements(self) -> Optional[Measurements]: def get_measurements(self) -> Optional[Measurements]:
@@ -380,4 +381,4 @@ class LaserController:
except Exception: except Exception:
pass pass
self.disconnect() self.disconnect()
return False return False
+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.angle_comp_power,
"gui.processing.gpr", "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( start_freq_mhz=_optional_float(
gpr_object, gpr_object,
"start_freq_mhz", "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), 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), 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), 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), 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_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), 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") raise ValueError("gui.processing.bscan.axis must be one of: abs, real, phase")
if gui.processing.gpr.render_mode not in {"heatmap", "objects_only"}: 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") 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"}: if gui.processing.legacy_gpr.mode not in {"point", "extended"}:
raise ValueError("gui.processing.legacy_gpr.mode must be one of: 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"}: 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") raise ValueError("gui.processing.gpr.angle_comp_power must be >= 0")
if gui.processing.gpr.min_visible_score < 0.0: if gui.processing.gpr.min_visible_score < 0.0:
raise ValueError("gui.processing.gpr.min_visible_score must be >= 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: if gui.processing.legacy_gpr.comp_power < 0.0:
raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0") raise ValueError("gui.processing.legacy_gpr.comp_power must be >= 0")
if gui.processing.legacy_gpr.snr_thresh < 0.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, "max_depth_m": gui.processing.gpr.max_depth_m,
"range_comp_power": gui.processing.gpr.range_comp_power, "range_comp_power": gui.processing.gpr.range_comp_power,
"angle_comp_power": gui.processing.gpr.angle_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, "start_freq_mhz": gui.processing.gpr.start_freq_mhz,
"stop_freq_mhz": gui.processing.gpr.stop_freq_mhz, "stop_freq_mhz": gui.processing.gpr.stop_freq_mhz,
"background_subtract_enabled": gui.processing.gpr.background_subtract_enabled, "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, "start_freq_mhz": gui.processing.legacy_gpr.start_freq_mhz,
"stop_freq_mhz": gui.processing.legacy_gpr.stop_freq_mhz, "stop_freq_mhz": gui.processing.legacy_gpr.stop_freq_mhz,
"speed_m_s": gui.processing.legacy_gpr.speed_m_s, "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, "look_angle_deg": gui.processing.legacy_gpr.look_angle_deg,
"snr_thresh": gui.processing.legacy_gpr.snr_thresh, "snr_thresh": gui.processing.legacy_gpr.snr_thresh,
"snr_comp_max": gui.processing.legacy_gpr.snr_comp_max, "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 max_depth_m: float = 14.0
range_comp_power: float = 0.28 range_comp_power: float = 0.28
angle_comp_power: float = 0.10 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 start_freq_mhz: float = 3000.0
stop_freq_mhz: float = 6000.0 stop_freq_mhz: float = 6000.0
background_subtract_enabled: bool = True background_subtract_enabled: bool = True
@@ -81,6 +84,7 @@ class GuiLegacyGprStateModel:
start_freq_mhz: float = 3000.0 start_freq_mhz: float = 3000.0
stop_freq_mhz: float = 6000.0 stop_freq_mhz: float = 6000.0
speed_m_s: float = 0.0 speed_m_s: float = 0.0
ignore_socket_speed_enabled: bool = False
look_angle_deg: float = 0.0 look_angle_deg: float = 0.0
snr_thresh: float = 4.5 snr_thresh: float = 4.5
snr_comp_max: float = 25.0 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]: def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"""Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure.""" """Encode :class:`RunConfigModel` to C++ pipeline-compatible JSON structure."""
model.ensure_combos() 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 { return {
"radar": { "radar": {
"model": model.radar.model, "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, "delay_time": model.radar.laser_control.variation.delay_time,
}, },
}, },
"sweep": { "sweep": sweep_payload,
"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,
},
}, },
"switches": { "switches": {
"port1": { "port1": {
+7
View File
@@ -71,6 +71,7 @@ def locator_observations_from_collection(
min_score: float, min_score: float,
*, *,
visible_bounds: tuple[float, float, float, float] | None = None, visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> list[dict[str, float]]: ) -> list[dict[str, float]]:
"""Build locator observations from GPR rows using score threshold and optional X/Z bounds.""" """Build locator observations from GPR rows using score threshold and optional X/Z bounds."""
rows = gpr_object_rows(collection) rows = gpr_object_rows(collection)
@@ -88,6 +89,12 @@ def locator_observations_from_collection(
& (rows[:, 1] <= z_max) & (rows[:, 1] <= z_max)
) )
filtered = rows[visible_mask] 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]] = [] observations: list[dict[str, float]] = []
for x_m, z_m, _score in filtered: for x_m, z_m, _score in filtered:
@@ -31,6 +31,9 @@ class ProcessingLiveConfig:
gpr_range_comp_power: float = 0.28 gpr_range_comp_power: float = 0.28
gpr_angle_comp_power: float = 0.10 gpr_angle_comp_power: float = 0.10
gpr_comp_power: float = 0.2 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_speed_m_s: float = 0.0
gpr_look_angle_deg: float = 0.0 gpr_look_angle_deg: float = 0.0
gpr_snr_thresh: float = 4.5 gpr_snr_thresh: float = 4.5
@@ -40,6 +43,7 @@ class ProcessingLiveConfig:
gpr_background_subtract_enabled: bool = True gpr_background_subtract_enabled: bool = True
gpr_background_mean_count: int = 10 gpr_background_mean_count: int = 10
gpr_remove_sidelobe_objects_enabled: bool = True gpr_remove_sidelobe_objects_enabled: bool = True
reprocess_current_result: bool = True
history_command_seq: int = 0 history_command_seq: int = 0
history_command: str = "none" history_command: str = "none"
@@ -77,6 +81,9 @@ class ProcessingLiveConfig:
"gpr_range_comp_power": float(self.gpr_range_comp_power), "gpr_range_comp_power": float(self.gpr_range_comp_power),
"gpr_angle_comp_power": float(self.gpr_angle_comp_power), "gpr_angle_comp_power": float(self.gpr_angle_comp_power),
"gpr_comp_power": float(self.gpr_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_speed_m_s": float(self.gpr_speed_m_s),
"gpr_look_angle_deg": float(self.gpr_look_angle_deg), "gpr_look_angle_deg": float(self.gpr_look_angle_deg),
"gpr_snr_thresh": float(self.gpr_snr_thresh), "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_subtract_enabled": bool(self.gpr_background_subtract_enabled),
"gpr_background_mean_count": int(self.gpr_background_mean_count), "gpr_background_mean_count": int(self.gpr_background_mean_count),
"gpr_remove_sidelobe_objects_enabled": bool(self.gpr_remove_sidelobe_objects_enabled), "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_seq": int(self.history_command_seq),
"history_command": str(self.history_command), "history_command": str(self.history_command),
} }
+56 -11
View File
@@ -63,6 +63,21 @@ def parse_vlc(payload: dict[str, Any]) -> float:
return vlc 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: def format_peer_name(writer: asyncio.StreamWriter) -> str:
"""Return a readable peer address for logs.""" """Return a readable peer address for logs."""
peer_name = writer.get_extra_info("peername") peer_name = writer.get_extra_info("peername")
@@ -118,6 +133,7 @@ class LocatorTcpService:
self._logger = logger or logging.getLogger(str(logger_name)) self._logger = logger or logging.getLogger(str(logger_name))
self._client_queue_size = int(client_queue_size) self._client_queue_size = int(client_queue_size)
self._speed_updates: queue.Queue[float] = queue.Queue() self._speed_updates: queue.Queue[float] = queue.Queue()
self._log_updates: queue.Queue[str] = queue.Queue()
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
self._server: asyncio.AbstractServer | None = None self._server: asyncio.AbstractServer | None = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
@@ -186,12 +202,14 @@ class LocatorTcpService:
min_score: float, min_score: float,
*, *,
visible_bounds: tuple[float, float, float, float] | None = None, visible_bounds: tuple[float, float, float, float] | None = None,
object_draw_limits: tuple[int, int] | None = None,
) -> None: ) -> None:
"""Publish one locator payload derived from a GPR result collection.""" """Publish one locator payload derived from a GPR result collection."""
observations = locator_observations_from_collection( observations = locator_observations_from_collection(
collection, collection,
min_score, min_score,
visible_bounds=visible_bounds, visible_bounds=visible_bounds,
object_draw_limits=object_draw_limits,
) )
payload = build_locator_payload( payload = build_locator_payload(
observations, observations,
@@ -218,6 +236,24 @@ class LocatorTcpService:
except queue.Empty: except queue.Empty:
return latest 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: def _publish_packet(self, packet: bytes) -> None:
"""Store latest packet and broadcast it to all connected clients.""" """Store latest packet and broadcast it to all connected clients."""
with self._snapshot_lock: with self._snapshot_lock:
@@ -368,6 +404,17 @@ class LocatorTcpService:
packet = await client.queue.get() packet = await client.queue.get()
client.writer.write(packet) client.writer.write(packet)
await client.writer.drain() 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( async def _receive_packets(
self, self,
@@ -377,21 +424,19 @@ class LocatorTcpService:
"""Receive inbound client packets and queue valid speed updates.""" """Receive inbound client packets and queue valid speed updates."""
while True: while True:
device_id, payload = await read_packet_with_limit(reader, self._max_payload_bytes) 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: if isinstance(payload, dict) and "vlc" in payload:
self._speed_updates.put(parse_vlc(payload)) speed_m_s = parse_vlc(payload)
self._logger.debug( self._speed_updates.put(speed_m_s)
"Received locator speed from %s: device_id=%d payload=%s", self._log_socket_traffic(
client.peer_name, "Locator socket received from %s: device_id=%d payload=%s speed_m_s=%g"
device_id, % (client.peer_name, device_id, payload_text, speed_m_s)
payload,
) )
continue continue
self._logger.info( self._log_socket_traffic(
"Received locator payload from %s: device_id=%d payload=%s", "Locator socket received from %s: device_id=%d payload=%s"
client.peer_name, % (client.peer_name, device_id, payload_text)
device_id,
json.dumps(payload, ensure_ascii=True, separators=(",", ":")),
) )
def _broadcast_packet(self, packet: bytes) -> None: 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) serial_part = "_".join(sanitize_path_component(value) for value in serial_parts)
start_token = _format_float_for_key(sweep_start_hz) start_token = _format_float_for_key(sweep_start_hz)
stop_token = _format_float_for_key(sweep_stop_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) ifbw_token = _format_float_for_key(ifbw_hz)
power_token = _format_float_for_key(power_dbm) power_token = _format_float_for_key(power_dbm)
return ( return (
f"{model_name}_{serial_part}" f"{model_name}_{serial_part}"
f"_st{start_token}_sp{stop_token}" 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) tty.setraw(slave_fd)
reader = KamilAdcTtyReader(os.ttyname(slave_fd)) reader = KamilAdcTtyReader(os.ttyname(slave_fd))
reader.open() 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)]) self.assertEqual(values.tolist(), [complex(10, -1), complex(-20, 2)])
finally: finally:
@@ -58,7 +64,61 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
os.close(master_fd) os.close(master_fd)
os.close(slave_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() master_fd, slave_fd = pty.openpty()
reader: KamilAdcTtyReader | None = None reader: KamilAdcTtyReader | None = None
try: try:
@@ -67,8 +127,8 @@ class KamilAdcTtyReaderTest(unittest.TestCase):
reader.open() reader.open()
os.write(master_fd, _start_frame() + _point_frame(1, 10, -1)) os.write(master_fd, _start_frame() + _point_frame(1, 10, -1))
with self.assertRaisesRegex(TimeoutError, "received 1/2"): with self.assertRaisesRegex(TimeoutError, "sweep end: received 1 points"):
reader.read_sweep(points=2, timeout_s=0.05) reader.read_sweep(timeout_s=0.05)
finally: finally:
if reader is not None: if reader is not None:
reader.close() reader.close()
@@ -137,6 +197,7 @@ class KamilAdcConfigTest(unittest.TestCase):
encoded = RunConfigModel.from_dict(payload).to_dict() encoded = RunConfigModel.from_dict(payload).to_dict()
self.assertEqual(encoded["radar"]["model"], "kamil_adc") 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"]["tty_path"], "/tmp/ttyADC_data")
self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"]) self.assertEqual(encoded["radar"]["kamil_adc"]["args"], ["profile:phase", "do1_pair_subtract_avg"])
self.assertEqual(encoded["radar"]["kamil_adc"]["env"], {"ADC_ENV": "1"}) 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": { "kamil_adc": {
"project_dir": "/home/europa/Documents/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", "tty_path": "/tmp/ttyADC_data",
"args": [ "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"
],
"env": {}, "env": {},
"startup_timeout_s": 5.0, "startup_timeout_s": 5.0,
"sweep_timeout_s": 5.0, "sweep_timeout_s": 15.0,
"stop_timeout_s": 2.0 "stop_timeout_s": 2.0
}, },
"laser_control": { "laser_control": {
@@ -59,7 +49,6 @@
"sweep": { "sweep": {
"start_hz": 1000000.0, "start_hz": 1000000.0,
"stop_hz": 6000000000.0, "stop_hz": 6000000000.0,
"points": 201,
"if_bandwidth_hz": 50000.0, "if_bandwidth_hz": 50000.0,
"stimulus_power_dbm": -10.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" VENV_PIP="${PROJECT_ROOT}/.venv/bin/pip"
GUI_ENTRY="${PROJECT_ROOT}/python_app/gui/main.py" GUI_ENTRY="${PROJECT_ROOT}/python_app/gui/main.py"
REQUIREMENTS_FILE="${PROJECT_ROOT}/requirements.txt" REQUIREMENTS_FILE="${PROJECT_ROOT}/requirements.txt"
PYTHON_CMD=""
PROFILE_PATH=""
SKIP_BUILD=0 SKIP_BUILD=0
BUILD_ONLY=0 BUILD_ONLY=0
CLEAN_SHM=0 CLEAN_SHM=0
KAMIL_ADC_MODE=0
AUTO_START=0
PRODUCER_ONLY=0
print_usage() { print_usage() {
cat <<'EOF' cat <<'EOF'
Usage: ./start.sh [options] Usage: ./start.sh [options]
Options: Options:
--skip-build Skip C++ build step and only run GUI --kamil-adc Use the Raspberry Pi Kamil ADC profile
--build-only Build C++ binaries and exit --profile PATH Use a specific GUI/run config profile
--clean-shm Remove known radar shared-memory segments before start --auto-start Start the GUI pipeline automatically after launch
-h, --help Show this help --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 EOF
} }
@@ -36,6 +45,23 @@ parse_args() {
--clean-shm) --clean-shm)
CLEAN_SHM=1 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) -h|--help)
print_usage print_usage
exit 0 exit 0
@@ -50,6 +76,35 @@ parse_args() {
done 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() { check_environment() {
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
echo "python3 is not installed or not found in PATH." >&2 echo "python3 is not installed or not found in PATH." >&2
@@ -68,6 +123,13 @@ check_environment() {
} }
ensure_python_dependencies() { 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 if [[ ! -x "${VENV_PYTHON}" ]]; then
echo "[start.sh] Creating virtual environment..." echo "[start.sh] Creating virtual environment..."
python3 -m venv "${PROJECT_ROOT}/.venv" python3 -m venv "${PROJECT_ROOT}/.venv"
@@ -78,9 +140,19 @@ ensure_python_dependencies() {
exit 1 exit 1
fi fi
echo "[start.sh] Installing Python dependencies..." if ! "${VENV_PYTHON}" -c "${dependency_check}" >/dev/null 2>&1; then
"${VENV_PIP}" install --upgrade pip echo "[start.sh] Installing Python dependencies into virtual environment..."
"${VENV_PIP}" install -r "${REQUIREMENTS_FILE}" "${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() { run_privileged() {
@@ -170,19 +242,57 @@ build_cpp_binaries() {
cleanup_known_shm() { cleanup_known_shm() {
echo "[start.sh] Cleaning known shared-memory segments..." 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() { 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..." 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() { main() {
parse_args "$@" parse_args "$@"
resolve_profile_path
check_environment check_environment
ensure_system_dependencies if ((KAMIL_ADC_MODE == 0)); then
ensure_usb_access_rules ensure_system_dependencies
ensure_usb_access_rules
fi
ensure_python_dependencies ensure_python_dependencies
if ((CLEAN_SHM == 1)); then if ((CLEAN_SHM == 1)); then
@@ -198,6 +308,10 @@ main() {
exit 0 exit 0
fi fi
if ((PRODUCER_ONLY == 1)); then
run_producer_only
fi
run_gui 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. Вот желательно немного кода добавтиь. И еще у этого девайса своя конфигурация, увидеть как настраивается устройство и какие параметры можно в проекте