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