From 30478cc59b1093914fc0c3f89512e616b7fca0da Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Fri, 24 Apr 2026 16:14:24 +0900 Subject: [PATCH 01/21] Add frequency selection for heterodyne --- .gitignore | 1 + src/core/sensor/CMakeLists.txt | 10 +- .../sensor/frequency_bandpass_filters.cpp | 152 ++++++++ src/core/sensor/frequency_bandpass_filters.h | 34 ++ .../sensor/frequency_channel_selection.cpp | 76 ++++ src/core/sensor/frequency_channel_selection.h | 47 +++ src/core/sensor/frequency_range_selection.cpp | 342 ++++++++++++++++++ src/core/sensor/frequency_range_selection.h | 96 +++++ src/core/sensor/sensor_builder.h | 9 + src/core/util/arts_conversions.h | 6 + src/python_interface/py_griddedfield.cpp | 7 + src/python_interface/py_sensor.cpp | 191 +++++++++- .../sensor/heterodyne_frequency_response.py | 180 +++++++++ 13 files changed, 1149 insertions(+), 2 deletions(-) create mode 100644 src/core/sensor/frequency_bandpass_filters.cpp create mode 100644 src/core/sensor/frequency_bandpass_filters.h create mode 100644 src/core/sensor/frequency_channel_selection.cpp create mode 100644 src/core/sensor/frequency_channel_selection.h create mode 100644 src/core/sensor/frequency_range_selection.cpp create mode 100644 src/core/sensor/frequency_range_selection.h create mode 100644 src/core/sensor/sensor_builder.h create mode 100644 tests/core/sensor/heterodyne_frequency_response.py diff --git a/.gitignore b/.gitignore index 141e29e9e7..998fa9705a 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ qdrant_storage/ .roo/ tests/core/sensor/sensor_count.xml tests/core/xml/test.xml +tmp/ diff --git a/src/core/sensor/CMakeLists.txt b/src/core/sensor/CMakeLists.txt index e5f48ad45e..3b00883357 100644 --- a/src/core/sensor/CMakeLists.txt +++ b/src/core/sensor/CMakeLists.txt @@ -1,3 +1,11 @@ -add_library(sensor STATIC obsel.cpp sensor_meta_info.cpp) +add_library(sensor STATIC + obsel.cpp + sensor_meta_info.cpp + frequency_bandpass_filters.cpp + frequency_channel_selection.cpp + frequency_range_selection.cpp +) + target_link_libraries(sensor PUBLIC matpack rtepack arts_enum_options) + target_include_directories(sensor PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/core/sensor/frequency_bandpass_filters.cpp b/src/core/sensor/frequency_bandpass_filters.cpp new file mode 100644 index 0000000000..1bd27bb9a1 --- /dev/null +++ b/src/core/sensor/frequency_bandpass_filters.cpp @@ -0,0 +1,152 @@ +#include "frequency_bandpass_filters.h" + +#include +#include +#include +#include +#include + +namespace sensor { +namespace { +constexpr Numeric response_eps = 64 * std::numeric_limits::epsilon(); + +Numeric scale(Numeric x) { + return std::max(1.0, std::abs(x)); +} + +bool is_close(Numeric a, Numeric b) { + return std::abs(a - b) <= response_eps * std::max(scale(a), scale(b)); +} + +Numeric sample_filter(const SortedGriddedField1& filter, Numeric f) { + const auto& grid = filter.grid<0>(); + + if (grid.empty() or f < grid.front() or f > grid.back()) return 0.0; + + auto it = std::lower_bound(grid.begin(), grid.end(), f); + + if (it == grid.begin()) return filter.data.front(); + if (it == grid.end()) return filter.data.back(); + + const Size upper = static_cast(std::distance(grid.begin(), it)); + if (is_close(*it, f)) return filter.data[upper]; + + const Size lower = upper - 1; + const Numeric x0 = grid[lower]; + const Numeric x1 = grid[upper]; + const Numeric y0 = filter.data[lower]; + const Numeric y1 = filter.data[upper]; + const Numeric t = (f - x0) / (x1 - x0); + + return y0 + t * (y1 - y0); +} + +void add_support_points(std::vector& points, + const AscendingGrid& grid, + Numeric low, + Numeric high) { + if (grid.empty() or low > high) return; + + points.push_back(low); + if (not is_close(low, high)) points.push_back(high); + + auto lower = std::lower_bound(grid.begin(), grid.end(), low); + auto upper = std::upper_bound(grid.begin(), grid.end(), high); + for (auto it = lower; it != upper; ++it) points.push_back(*it); +} + +void sort_unique(std::vector& points) { + std::sort(points.begin(), points.end()); + points.erase(std::unique(points.begin(), points.end(), [](Numeric a, Numeric b) { + return is_close(a, b); + }), + points.end()); +} + +SortedGriddedField1 make_filter(const std::vector>& samples, + std::string_view name) { + std::vector grid(samples.size()); + Vector data(samples.size()); + + for (Size i = 0; i < samples.size(); i++) { + grid[i] = samples[i].first; + data[i] = samples[i].second; + } + + return {.data_name = String{name}, + .data = std::move(data), + .grid_names = std::array{"frequency"s}, + .grids = std::array{AscendingGrid{grid}}}; +} +} // namespace + +Numeric BandpassFilter::operator()(Numeric f) const { + Numeric weight = 0.0; + for (const auto& filter : filters) weight += sample_filter(filter, f); + return weight; +} + +Vector BandpassFilter::operator()(ConstVectorView f) const { + Vector out(f.size(), 0.0); + + for (Size i = 0; i < out.size(); i++) out[i] = (*this)(f[i]); + + return out; +} + +FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( + const FrequencyRange& range, const std::vector& channels) + : FrequencyRangeBandpassFilter( + range, std::span{channels.data(), channels.size()}) {} + +FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( + const FrequencyRange& range, const std::span& channels) { + filters.reserve(channels.size()); + + for (Size ichan = 0; ichan < channels.size(); ichan++) { + const auto& channel = channels[ichan]; + const auto& channel_grid = channel.freq_grid(); + std::vector> samples; + + for (const auto& path : range.paths()) { + if (channel_grid.empty()) continue; + + const Numeric local_low = std::max(path.local_range[0], channel_grid.front()); + const Numeric local_high = std::min(path.local_range[1], channel_grid.back()); + + if (local_low > local_high and not is_close(local_low, local_high)) continue; + + std::vector local_points; + add_support_points(local_points, channel_grid, local_low, local_high); + for (const auto& filter : path.filters) { + add_support_points(local_points, filter.grid<0>(), local_low, local_high); + } + sort_unique(local_points); + + for (Numeric local_frequency : local_points) { + const Numeric weight = path.local_weight(local_frequency) * + sample_filter(channel.channel, local_frequency); + if (weight == 0.0) continue; + samples.emplace_back(path.map_to_global(local_frequency), weight); + } + } + + std::sort(samples.begin(), samples.end(), [](const auto& a, const auto& b) { + return a.first < b.first; + }); + + std::vector> combined; + combined.reserve(samples.size()); + for (const auto& sample : samples) { + if (combined.empty() or not is_close(combined.back().first, sample.first)) { + combined.push_back(sample); + } else { + combined.back().second += sample.second; + } + } + + filters.push_back( + make_filter(combined, std::format("channel-response-{}", ichan))); + } +} +} // namespace sensor diff --git a/src/core/sensor/frequency_bandpass_filters.h b/src/core/sensor/frequency_bandpass_filters.h new file mode 100644 index 0000000000..1f0ad0c001 --- /dev/null +++ b/src/core/sensor/frequency_bandpass_filters.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include + +#include "frequency_channel_selection.h" +#include "frequency_range_selection.h" + +namespace sensor { +//! Real frequency bandpass filter. Others inherit from this. +struct BandpassFilter; + +//! Sets the bandpass filter from weights on derived frequeny ranges. +struct FrequencyRangeBandpassFilter; + +//! Concept that creates a valid frequency bandpass filter for a given set of channels and frequency ranges. +template +concept FrequencyBandpassFilter = std::derived_from; + +struct BandpassFilter { + std::vector filters; + + [[nodiscard]] Numeric operator()(Numeric f) const; + [[nodiscard]] Vector operator()(ConstVectorView f) const; +}; + +struct FrequencyRangeBandpassFilter final : BandpassFilter { + FrequencyRangeBandpassFilter(const FrequencyRange& range, + const std::span& channels); + FrequencyRangeBandpassFilter(const FrequencyRange& range, + const std::vector& channels); +}; +} // namespace sensor diff --git a/src/core/sensor/frequency_channel_selection.cpp b/src/core/sensor/frequency_channel_selection.cpp new file mode 100644 index 0000000000..01ec2f9305 --- /dev/null +++ b/src/core/sensor/frequency_channel_selection.cpp @@ -0,0 +1,76 @@ +#include "frequency_channel_selection.h" + +#include + +#include +#include + +namespace sensor { +const AscendingGrid& Channel::freq_grid() const { return channel.grid<0>(); } + +const Vector& Channel::weights() const { return channel.data; } + +bool Channel::is_always_relative() const { return freq_grid().front() <= 0; } + +DiracChannel::DiracChannel(Numeric f) + : Channel{.channel = { + .data_name = "dirac"s, + .data = Vector(1, 1.0), + .grid_names = std::array{"frequency"s}, + .grids = std::array{AscendingGrid{f}}}} {} + +DiracChannel::DiracChannel() : DiracChannel(0) {} + +BoxChannel::BoxChannel(AscendingGrid f) + : Channel{ + .channel = { + .data_name = "box"s, + .data = Vector(f.size(), 1.0 / static_cast(f.size())), + .grid_names = std::array{"frequency"s}, + .grids = std::array{std::move(f)}}} {} + +BoxChannel::BoxChannel(Numeric lower, Numeric upper, Size N) + : BoxChannel{nlinspace(lower, upper, N)} {} + +BoxChannel::BoxChannel(Numeric hw, Size N) : BoxChannel(-hw, hw, N) {} + +namespace { +SortedGriddedField1 gauss(AscendingGrid&& f, Numeric f0, Numeric std) { + using gauss = boost::math::normal_distribution; + + Vector data{std::from_range, + f | stdv::transform([dist = gauss(f0, std)](Numeric fi) { + return pdf(dist, fi); + })}; + + return {.data_name = "gaussian"s, + .data = std::move(data), + .grid_names = std::array{"frequency"s}, + .grids = std::array{std::move(f)}}; +} +} // namespace + +GaussianChannel::GaussianChannel(AscendingGrid f, Numeric f0, Numeric std) + : Channel{.channel = gauss(std::move(f), f0, std)} {} + +GaussianChannel::GaussianChannel(Numeric f0, Numeric std, Size N, Size M) + : GaussianChannel( + nlinspace( + -static_cast(M) * std, static_cast(M) * std, N), + f0, + std) {} + +GaussianChannel::GaussianChannel(AscendingGrid f, Numeric std) + : GaussianChannel(std::move(f), 0, std) {} + +GaussianChannel::GaussianChannel(Numeric std, Size N, Size M) + : GaussianChannel( + nlinspace( + -static_cast(M) * std, static_cast(M) * std, N), + std) {} + +static_assert(FrequencyChannelSelection); +static_assert(FrequencyChannelSelection); +static_assert(FrequencyChannelSelection); +static_assert(FrequencyChannelSelection); +} // namespace sensor diff --git a/src/core/sensor/frequency_channel_selection.h b/src/core/sensor/frequency_channel_selection.h new file mode 100644 index 0000000000..60701ee1ff --- /dev/null +++ b/src/core/sensor/frequency_channel_selection.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +namespace sensor { +//! Free-form channel struct. Others inherit from this. +struct Channel; + +//! A channel that is even between a lower and upper frequency, with equal weights. +struct BoxChannel; + +//! A channel with a single frequency and all weight on that frequency. +struct DiracChannel; + +//! A channel with Gaussian weights centered at the center frequency with given standard deviation. +struct GaussianChannel; + +//! Concept for selecting frequencies for a channel +template +concept FrequencyChannelSelection = std::derived_from; + +struct Channel { + SortedGriddedField1 channel; + + [[nodiscard]] const AscendingGrid& freq_grid() const; + [[nodiscard]] const Vector& weights() const; + [[nodiscard]] bool is_always_relative() const; +}; + +struct BoxChannel final : Channel { + BoxChannel(Numeric lower, Numeric upper, Size N); // [lower, upper] + BoxChannel(Numeric hw, Size N); // [-hw, hw] + BoxChannel(AscendingGrid f); // f +}; + +struct DiracChannel final : Channel { + DiracChannel(Numeric f); // f + DiracChannel(); // f = 0 +}; + +struct GaussianChannel final : Channel { + GaussianChannel(AscendingGrid f, Numeric f0, Numeric std); // std around f0 + GaussianChannel(Numeric f0, Numeric std, Size N, Size M); // f0 +- M*std + GaussianChannel(AscendingGrid f, Numeric std); // f, f0 = 0 + GaussianChannel(Numeric std, Size N, Size M); // +-M*std, f0 = 0 +}; +} // namespace sensor diff --git a/src/core/sensor/frequency_range_selection.cpp b/src/core/sensor/frequency_range_selection.cpp new file mode 100644 index 0000000000..bed7b3e3ae --- /dev/null +++ b/src/core/sensor/frequency_range_selection.cpp @@ -0,0 +1,342 @@ +#include "frequency_range_selection.h" + +#include +#include +#include +#include + +#include "matpack_mdspan_cdata_t.h" + +namespace sensor { +Size FrequencyRange::size() const { return response_paths.size(); } + +const FrequencyResponsePath& FrequencyRange::path(Size index) const { + return response_paths.at(index); +} + +const std::vector& FrequencyRange::paths() const { + return response_paths; +} + +void FrequencyRange::sync_ranges() { + global_ranges.clear(); + local_ranges.clear(); + + global_ranges.reserve(response_paths.size()); + local_ranges.reserve(response_paths.size()); + + for (const auto& path : response_paths) { + global_ranges.push_back(path.global_range()); + local_ranges.push_back(path.local_range); + } +} + +namespace { +constexpr Numeric response_eps = 64 * std::numeric_limits::epsilon(); + +Numeric scale(Numeric x) { return std::max(1.0, std::abs(x)); } + +bool is_close(Numeric a, Numeric b) { + return std::abs(a - b) <= response_eps * std::max(scale(a), scale(b)); +} + +bool is_in_closed_interval(Numeric x, Numeric low, Numeric high) { + return x >= low - response_eps * scale(low) and + x <= high + response_eps * scale(high); +} + +void assert_frange(const Vector2& sideband) { + if (sideband[0] > sideband[1] or is_close(sideband[0], sideband[1]) or + sideband[0] < 0) { + throw std::invalid_argument(std::format( + "Frequency range must be unique, sorted, and non-negative. Got: [{}, {}].", + sideband[0], + sideband[1])); + } +} + +void assert_bandpass_ranges(const std::span& bandpasses) { + if (bandpasses.empty()) { + throw std::invalid_argument("At least one sideband must be provided."); + } + + for (auto sideband : bandpasses) assert_frange(sideband); +} + +void assert_lo_nonnegative(const Numeric& LO) { + if (LO < 0) { + throw std::invalid_argument( + std::format("LO frequency must be non-negative. Got: {}.", LO)); + } +} + +void assert_clocks(const std::span& clocks) { + if (clocks.empty()) { + throw std::invalid_argument("At least one LO frequency must be provided."); + } + + for (auto clock : clocks) assert_lo_nonnegative(clock); +} + +void assert_weighted_bandpass(const SortedGriddedField1& bandpass_filter) { + const auto& grid = bandpass_filter.grid<0>(); + + if (grid.empty()) { + throw std::invalid_argument("Bandpass filter grid must not be empty."); + } + + if (grid.size() != bandpass_filter.data.size()) { + throw std::invalid_argument(std::format( + "Bandpass filter grid and data must have the same size. Got {} grid points and {} weights.", + grid.size(), + bandpass_filter.data.size())); + } + + for (Size i = 1; i < grid.size(); i++) { + if (grid[i - 1] > grid[i] or is_close(grid[i - 1], grid[i])) { + throw std::invalid_argument( + "Bandpass filter grid must be unique and sorted."); + } + } +} + +Numeric sample_filter(const SortedGriddedField1& filter, Numeric f) { + const auto& grid = filter.grid<0>(); + + if (grid.empty() or f < grid.front() or f > grid.back()) return 0.0; + + auto it = std::lower_bound(grid.begin(), grid.end(), f); + + if (it == grid.begin()) return filter.data.front(); + if (it == grid.end()) return filter.data.back(); + + const Size upper = static_cast(std::distance(grid.begin(), it)); + if (is_close(*it, f)) return filter.data[upper]; + + const Size lower = upper - 1; + const Numeric x0 = grid[lower]; + const Numeric x1 = grid[upper]; + const Numeric y0 = filter.data[lower]; + const Numeric y1 = filter.data[upper]; + + const Numeric t = (f - x0) / (x1 - x0); + return y0 + t * (y1 - y0); +} + +std::vector transformed_filters( + const std::vector& filters, Numeric LO, bool upper) { + std::vector out; + out.reserve(filters.size()); + + for (const auto& filter : filters) { + const auto& grid = filter.grid<0>(); + std::vector mapped(grid.size()); + Vector weights(filter.data.size()); + + if (upper) { + for (Size i = 0; i < grid.size(); i++) { + mapped[i] = grid[i] - LO; + weights[i] = filter.data[i]; + } + } else { + for (Size i = 0; i < grid.size(); i++) { + const Size j = grid.size() - 1 - i; + mapped[i] = LO - grid[j]; + weights[i] = filter.data[j]; + } + } + + out.push_back( + {.data_name = filter.data_name, + .data = std::move(weights), + .grid_names = filter.grid_names, + .grids = std::array{AscendingGrid{mapped}}}); + } + + return out; +} + +void apply_interval_clip(std::vector& paths, + Numeric low, + Numeric high) { + std::erase_if(paths, [low, high](FrequencyResponsePath& path) { + path.local_range[0] = std::max(path.local_range[0], low); + path.local_range[1] = std::min(path.local_range[1], high); + return path.local_range[0] >= path.local_range[1] or + is_close(path.local_range[0], path.local_range[1]); + }); +} +} // namespace + +Vector2 FrequencyResponsePath::global_range() const { + return {map_to_global(local_range[0]), map_to_global(local_range[1])}; +} + +Numeric FrequencyResponsePath::map_to_global(Numeric local_frequency) const { + return intercept + slope * local_frequency; +} + +std::optional FrequencyResponsePath::map_to_local( + Numeric global_frequency) const { + const Numeric local_frequency = (global_frequency - intercept) / slope; + + if (not is_in_closed_interval( + local_frequency, local_range[0], local_range[1])) { + return std::nullopt; + } + + return std::clamp(local_frequency, local_range[0], local_range[1]); +} + +Numeric FrequencyResponsePath::local_weight(Numeric local_frequency) const { + if (not is_in_closed_interval( + local_frequency, local_range[0], local_range[1])) { + return 0.0; + } + + Numeric weight = 1.0; + + for (const auto& filter : filters) { + weight *= sample_filter(filter, local_frequency); + if (weight == 0.0) break; + } + + return weight; +} + +Numeric FrequencyResponsePath::global_weight(Numeric global_frequency) const { + const auto local_frequency = map_to_local(global_frequency); + return local_frequency.has_value() ? local_weight(*local_frequency) : 0.0; +} + +HeterodyneFrequencyRange::HeterodyneFrequencyRange( + const std::span& clocks, + const std::span& bandpasses) + : FrequencyRange() { + const Size N = clocks.size(); + + if (N != bandpasses.size()) { + throw std::invalid_argument(std::format( + "Number of clock frequencies and bandpasses must match. Got: {} clock " + "frequencies and {} bandpasses.", + clocks.size(), + bandpasses.size())); + } + + assert_bandpass_ranges(bandpasses); + assert_clocks(clocks); + + for (Size i = 0; i < N; i++) { + apply_bandpass(bandpasses[i]); + apply_mixer(clocks[i]); + } +} + +HeterodyneFrequencyRange::HeterodyneFrequencyRange(Numeric clock_frequency, + const Vector2& sideband) + : HeterodyneFrequencyRange(std::span{&clock_frequency, 1}, + std::span{&sideband, 1}) {} + +void HeterodyneFrequencyRange::apply_lowpass(Numeric upper_frequency) { + if (upper_frequency < 0) { + throw std::invalid_argument(std::format( + "Lowpass cutoff must be non-negative. Got: {}.", upper_frequency)); + } + + apply_interval_clip(response_paths, 0.0, upper_frequency); + sync_ranges(); +} + +void HeterodyneFrequencyRange::apply_highpass(Numeric lower_frequency) { + if (lower_frequency < 0) { + throw std::invalid_argument(std::format( + "Highpass cutoff must be non-negative. Got: {}.", lower_frequency)); + } + + apply_interval_clip(response_paths, lower_frequency, inf); + sync_ranges(); +} + +void HeterodyneFrequencyRange::apply_bandpass(const Vector2& bandpass_range) { + assert_frange(bandpass_range); + apply_interval_clip(response_paths, bandpass_range[0], bandpass_range[1]); + sync_ranges(); +} + +void HeterodyneFrequencyRange::apply_bandpass( + const SortedGriddedField1& bandpass_filter) { + assert_weighted_bandpass(bandpass_filter); + + apply_interval_clip(response_paths, + bandpass_filter.grid<0>().front(), + bandpass_filter.grid<0>().back()); + + for (auto& path : response_paths) path.filters.push_back(bandpass_filter); + + sync_ranges(); +} + +void HeterodyneFrequencyRange::apply_mixer(Numeric clock_frequency) { + assert_lo_nonnegative(clock_frequency); + + std::vector mixed_paths; + mixed_paths.reserve(2 * response_paths.size()); + + for (const auto& path : response_paths) { + const Numeric upper_low = std::max(path.local_range[0], clock_frequency); + const Numeric upper_high = path.local_range[1]; + + if (upper_low < upper_high and not is_close(upper_low, upper_high)) { + auto upper = path; + upper.intercept += upper.slope * clock_frequency; + upper.local_range = {upper_low - clock_frequency, + upper_high - clock_frequency}; + upper.filters = transformed_filters(path.filters, clock_frequency, true); + mixed_paths.push_back(std::move(upper)); + } + + const Numeric lower_low = path.local_range[0]; + const Numeric lower_high = std::min(path.local_range[1], clock_frequency); + + if (lower_low < lower_high and not is_close(lower_low, lower_high)) { + auto lower = path; + lower.intercept += lower.slope * clock_frequency; + lower.slope *= -1; + lower.local_range = {clock_frequency - lower_high, + clock_frequency - lower_low}; + lower.filters = transformed_filters(path.filters, clock_frequency, false); + mixed_paths.push_back(std::move(lower)); + } + } + + response_paths = std::move(mixed_paths); + sync_ranges(); +} + +Vector HeterodyneFrequencyRange::local_response( + ConstVectorView local_frequency_grid, Size path_index) const { + const auto& response_path = path(path_index); + Vector response(local_frequency_grid.size()); + + for (Size i = 0; i < response.size(); i++) { + response[i] = response_path.local_weight(local_frequency_grid[i]); + } + + return response; +} + +Vector HeterodyneFrequencyRange::global_response( + ConstVectorView global_frequency_grid, Size path_index) const { + const auto& response_path = path(path_index); + Vector response(global_frequency_grid.size()); + + for (Size i = 0; i < response.size(); i++) { + response[i] = response_path.global_weight(global_frequency_grid[i]); + } + + return response; +} + +static_assert(FrequencyRangeSelection); +static_assert(FrequencyRangeSelection); +} // namespace sensor diff --git a/src/core/sensor/frequency_range_selection.h b/src/core/sensor/frequency_range_selection.h new file mode 100644 index 0000000000..887def4f30 --- /dev/null +++ b/src/core/sensor/frequency_range_selection.h @@ -0,0 +1,96 @@ +#pragma once + +#include + +#include +#include + +namespace sensor { +//! Free-form frequency range struct. Others inherit from this. +struct FrequencyRange; + +//! A frequency range for heterodyne channels. True frequencies are computed. +struct HeterodyneFrequencyRange; + +//! A single affine path through a staged heterodyne chain. +struct FrequencyResponsePath; + +//! Concept for selecting frequency ranges for a set of channels +template +concept FrequencyRangeSelection = std::derived_from; + +struct FrequencyResponsePath { + static constexpr Numeric inf = std::numeric_limits::infinity(); + + Numeric intercept{0.0}; + Numeric slope{1.0}; + Vector2 local_range{0.0, inf}; + std::vector filters{}; + + [[nodiscard]] Vector2 global_range() const; + [[nodiscard]] Numeric map_to_global(Numeric local_frequency) const; + [[nodiscard]] std::optional map_to_local( + Numeric global_frequency) const; + [[nodiscard]] Numeric local_weight(Numeric local_frequency) const; + [[nodiscard]] Numeric global_weight(Numeric global_frequency) const; +}; + +struct FrequencyRange { + static constexpr Numeric inf = FrequencyResponsePath::inf; + + std::vector global_ranges{{0, inf}}; + std::vector local_ranges{{0, inf}}; + + [[nodiscard]] Size size() const; + [[nodiscard]] const FrequencyResponsePath& path(Size index) const; + [[nodiscard]] const std::vector& paths() const; + + protected: + std::vector response_paths{{}}; + + void sync_ranges(); +}; + +struct HeterodyneFrequencyRange final : FrequencyRange { + HeterodyneFrequencyRange() = default; + HeterodyneFrequencyRange(Numeric clock_frequency, + const Vector2& bandpass_range); + HeterodyneFrequencyRange(const std::span& clock_frequencies, + const std::span& bandpass_ranges); + + void apply_lowpass(Numeric upper_frequency); + void apply_highpass(Numeric lower_frequency); + void apply_bandpass(const Vector2& bandpass_range); + void apply_bandpass(const SortedGriddedField1& bandpass_filter); + void apply_mixer(Numeric clock_frequency); + + [[nodiscard]] Vector local_response(ConstVectorView local_frequency_grid, + Size path_index = 0) const; + [[nodiscard]] Vector global_response(ConstVectorView global_frequency_grid, + Size path_index = 0) const; +}; +} // namespace sensor + +template <> +struct std::formatter { + format_tags tags{}; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const sensor::FrequencyRange& v, + FmtContext& ctx) const { + return tags.format( + ctx, "GLOBALS: "sv, v.global_ranges, "; LOCALS : "sv, v.local_ranges); + } +}; + +template <> +struct std::formatter final + : std::formatter {}; \ No newline at end of file diff --git a/src/core/sensor/sensor_builder.h b/src/core/sensor/sensor_builder.h new file mode 100644 index 0000000000..d1770cd527 --- /dev/null +++ b/src/core/sensor/sensor_builder.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +#include "frequency_channel_selection.h" +#include "obsel.h" + +namespace sensor { +} // namespace sensor diff --git a/src/core/util/arts_conversions.h b/src/core/util/arts_conversions.h index d174173cbe..70cd8f783b 100644 --- a/src/core/util/arts_conversions.h +++ b/src/core/util/arts_conversions.h @@ -166,6 +166,12 @@ constexpr auto angstrom2meter(auto x) noexcept { return x * 1e-10; } /** Conversion from meter to Å **/ constexpr auto meter2angstrom(auto x) noexcept { return x * 1e10; } +/** Conversion from HWHM to STD */ +constexpr auto hwhm2std(auto x) noexcept { return x / (Constant::sqrt_ln_2 * Constant::sqrt_2); } + +/** Conversion from FWHM to STD */ +constexpr auto fwhm2std(auto x) noexcept { return 0.5 * hwhm2std(x); } + //! Converts the number to a metric prefix (kilo:=k, Mega:=M, nano:=n, etc) // And empty char (' ') is used when no conversion happens. The function returns a pair // containing the prefix character and the scaled value. diff --git a/src/python_interface/py_griddedfield.cpp b/src/python_interface/py_griddedfield.cpp index 0d9a1a7904..b1a29dbe02 100644 --- a/src/python_interface/py_griddedfield.cpp +++ b/src/python_interface/py_griddedfield.cpp @@ -197,6 +197,13 @@ void py_griddedfield(py::module_& m) try { m, "ArrayOfNamedGriddedField2"); generic_interface(d2); vector_interface(d2); + + auto vsgf1num = py::bind_vector, + py::rv_policy::reference_internal>( + m, "ArrayOfSortedGriddedField1"); + vsgf1num.doc() = "A list of :class:`~pyarts3.arts.SortedGriddedField1`"; + generic_interface(vsgf1num); + vector_interface(vsgf1num); } catch (std::exception& e) { throw std::runtime_error( std::format("DEV ERROR:\nCannot initialize gridded field\n{}", e.what())); diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 84905031ce..9a4659d279 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -1,6 +1,10 @@ #include +#include +#include +#include #include #include +#include #include #include #include @@ -16,7 +20,6 @@ #include "hpy_arts.h" #include "hpy_numpy.h" #include "hpy_vector.h" -#include "rtepack.h" namespace Python { void py_sensor(py::module_& m) try { @@ -428,6 +431,192 @@ Numeric, Vector, or Matrix generic_interface(a2); vector_interface(a2); + auto sch = py::class_(m, "SensorChannel"); + sch.doc() = "Base class for relative spectrometer channel responses."; + generic_interface(sch); + sch.def_prop_ro( + "response", + [](const sensor::Channel& self) -> const SortedGriddedField1& { + return self.channel; + }, + py::rv_policy::reference_internal, + "Relative channel response as a gridded field." + "\n\n.. :class:`~pyarts3.arts.SortedGriddedField1`") + .def_prop_ro("freq_grid", + &sensor::Channel::freq_grid, + py::rv_policy::reference_internal, + "Relative frequency grid.\n\n.. :class:`AscendingGrid`") + .def_prop_ro("weights", + &sensor::Channel::weights, + py::rv_policy::reference_internal, + "Channel weights on the relative frequency grid." + "\n\n.. :class:`Vector`") + .def("is_always_relative", + &sensor::Channel::is_always_relative, + "Whether the channel grid is anchored at or below zero."); + + auto sbox = + py::class_(m, "SensorBoxChannel"); + sbox.doc() = + "A channel with uniform weights across a finite relative-frequency interval."; + sbox.def(py::init(), + "lower"_a, + "upper"_a, + "n"_a, + "Construct a box channel on ``[lower, upper]`` with ``n`` points.") + .def( + py::init(), + "half_width"_a, + "n"_a, + "Construct a symmetric box channel on ``[-half_width, half_width]``.") + .def(py::init(), + "freq_grid"_a, + "Construct a box channel directly from a relative frequency grid."); + generic_interface(sbox); + + auto sdirac = py::class_( + m, "SensorDiracChannel"); + sdirac.doc() = "A single-frequency relative channel."; + sdirac + .def(py::init(), + "frequency"_a, + "Construct a Dirac channel at one relative frequency.") + .def(py::init<>(), "Construct a Dirac channel at 0 relative frequency."); + generic_interface(sdirac); + + auto sgauss = py::class_( + m, "SensorGaussianChannel"); + sgauss.doc() = "A Gaussian relative channel response."; + sgauss + .def(py::init(), + "freq_grid"_a, + "center"_a, + "std"_a, + "Construct a Gaussian channel on a custom relative frequency grid.") + .def( + py::init(), + "center"_a, + "std"_a, + "n"_a, + "m"_a, + "Construct a Gaussian channel on ``center +/- m * std`` with ``n`` points.") + .def(py::init(), + "freq_grid"_a, + "std"_a, + "Construct a zero-centered Gaussian channel on a custom grid.") + .def(py::init(), + "std"_a, + "n"_a, + "m"_a, + "Construct a zero-centered Gaussian channel on ``+/- m * std``."); + generic_interface(sgauss); + + auto shdfr = py::class_( + m, "SensorHeterodyneFrequencyRange"); + shdfr.def(py::init<>(), + R"(Construct an empty staged heterodyne response. + + Stages can then be applied in sequence using :func:`lowpass`, :func:`highpass`, + :func:`bandpass`, :func:`filter`, and :func:`mix`.)"); + shdfr.def( + py::init(), + "lo"_a, + "bandpass"_a, + R"(Construct a heterodyne response from one ideal bandpass and one LO stage. + + This is shorthand for creating an empty object, applying :func:`bandpass`, and + then applying :func:`mix`.)"); + shdfr.def( + "__init__", + [](sensor::HeterodyneFrequencyRange* v, + const std::vector& clock_frequencies, + const std::vector& bandpasses) { + new (v) sensor::HeterodyneFrequencyRange(clock_frequencies, bandpasses); + }, + "lo"_a, + "bandpasses"_a, + R"(Construct a heterodyne response from a sequence of ideal bandpass and LO stages. + + The sequence is applied as ``bandpass[0] -> lo[0] -> bandpass[1] -> lo[1] -> ...``.)"); + shdfr + .def_ro("global_ranges", + &sensor::HeterodyneFrequencyRange::global_ranges, + "Global frequency range\n\n.. :class:`list[Vector2]`") + .def_ro("local_ranges", + &sensor::HeterodyneFrequencyRange::local_ranges, + "Local frequency range\n\n.. :class:`list[Vector2]`") + .def("lowpass", + &sensor::HeterodyneFrequencyRange::apply_lowpass, + "upper"_a, + "Apply an ideal lowpass filter on the current local frequency axis.") + .def( + "highpass", + &sensor::HeterodyneFrequencyRange::apply_highpass, + "lower"_a, + "Apply an ideal highpass filter on the current local frequency axis.") + .def( + "bandpass", + [](sensor::HeterodyneFrequencyRange& self, const Vector2& bandpass) { + self.apply_bandpass(bandpass); + }, + "bandpass"_a, + "Apply an ideal bandpass filter on the current local frequency axis.") + .def( + "filter", + [](sensor::HeterodyneFrequencyRange& self, + const SortedGriddedField1& bandpass_filter) { + self.apply_bandpass(bandpass_filter); + }, + "bandpass_filter"_a, + R"(Apply a weighted bandpass filter on the current local frequency axis. + + The filter weights are interpreted on the filter's relative frequency grid and + are zero outside that grid.)") + .def("mix", + &sensor::HeterodyneFrequencyRange::apply_mixer, + "lo"_a, + "Apply one heterodyne LO mixing stage.") + .def( + "local_response", + [](const sensor::HeterodyneFrequencyRange& self, + const Vector& f, + Size path_index) { return self.local_response(f, path_index); }, + "f"_a, + "path_index"_a = 0, + "Evaluate one path response on the current local frequency axis.") + .def( + "global_response", + [](const sensor::HeterodyneFrequencyRange& self, + const Vector& f, + Size path_index) { return self.global_response(f, path_index); }, + "f"_a, + "path_index"_a = 0, + "Evaluate one path response on the original real-frequency axis.") + .def( + "channel_response", + [](const sensor::HeterodyneFrequencyRange& self, + const sensor::Channel& channel) { + return sensor::FrequencyRangeBandpassFilter( + self, std::vector{channel}) + .filters.front(); + }, + "channel"_a, + R"(Compute the real-frequency response for one spectrometer channel. + + The returned gridded field is aggregated across all active mixer paths.)") + .def( + "channel_responses", + [](const sensor::HeterodyneFrequencyRange& self, + const std::vector& channels) { + return sensor::FrequencyRangeBandpassFilter(self, channels).filters; + }, + "channels"_a, + R"(Compute the real-frequency response for multiple spectrometer channels. + + Each returned gridded field is aggregated across all active mixer paths for the + matching input channel.)"); + shdfr.doc() = "A staged heterodyne mixer and filter response builder."; + generic_interface(shdfr); } catch (std::exception& e) { throw std::runtime_error( std::format("DEV ERROR:\nCannot initialize sensors\n{}", e.what())); diff --git a/tests/core/sensor/heterodyne_frequency_response.py b/tests/core/sensor/heterodyne_frequency_response.py new file mode 100644 index 0000000000..b2773c0765 --- /dev/null +++ b/tests/core/sensor/heterodyne_frequency_response.py @@ -0,0 +1,180 @@ +import numpy as np +import pyarts3 as pyarts + + +def ranges_as_lists(ranges): + return [list(map(float, pair)) for pair in ranges] + + +def affine_from_ranges(global_range, local_range): + g0, g1 = map(float, global_range) + l0, l1 = map(float, local_range) + slope = (g1 - g0) / (l1 - l0) + intercept = g0 - slope * l0 + return [intercept, slope] + + +def assert_close(name, got, expected, atol=1e-12): + got = np.asarray(got, dtype=float) + expected = np.asarray(expected, dtype=float) + print(f"{name}: got = {got}") + print(f"{name}: expected = {expected}") + np.testing.assert_allclose(got, expected, atol=atol, rtol=0.0) + + +def inspect_case(name, selector, expected_global, expected_local, expected_affine): + print(f"\n=== {name} ===") + + got_global = ranges_as_lists(selector.global_ranges) + got_local = ranges_as_lists(selector.local_ranges) + got_affine = [ + affine_from_ranges(global_range, local_range) + for global_range, local_range in zip(got_global, got_local) + ] + + print("Affine form per path: global = intercept + slope * local") + assert_close(f"{name} global_ranges", got_global, expected_global) + assert_close(f"{name} local_ranges", got_local, expected_local) + assert_close(f"{name} affine", got_affine, expected_affine) + + +def make_triangular_filter(): + filt = pyarts.arts.SortedGriddedField1() + filt.grids = (pyarts.arts.AscendingGrid(np.array([5.0, 10.0, 15.0])),) + filt.data = pyarts.arts.Vector(np.array([0.0, 1.0, 0.0])) + filt.gridnames = ("frequency",) + filt.dataname = "triangular" + return filt + + +def test_lowpass_then_lo_then_box_spectrometer(): + selector = pyarts.arts.SensorHeterodyneFrequencyRange() + print("test_lowpass_then_lo_then_box_spectrometer") + selector.lowpass(15.0) + print("Apply lowpass keeping 0-15:", selector) + selector.mix(16.0) + print("Apply LO at 16 :", selector) + + inspect_case( + "lowpass -> LO", + selector, + expected_global=[[15.0, 0.0]], + expected_local=[[1.0, 16.0]], + expected_affine=[[16.0, -1.0]], + ) + + channel = pyarts.arts.SensorBoxChannel(2.6, 4.6, 5) + response = selector.channel_response(channel) + + assert_close( + "lowpass -> LO channel points", + np.array(response.grids[0]), + np.array([11.4, 11.9, 12.4, 12.9, 13.4]), + ) + assert_close( + "lowpass -> LO channel weights", + np.array(response.data), + np.full(5, 0.2), + ) + + +def test_bandpass_lo_bandpass_lo_chain(): + selector = pyarts.arts.SensorHeterodyneFrequencyRange() + selector.bandpass(np.array([5.0, 15.0])) + selector.mix(3.0) + selector.bandpass(np.array([3.0, 7.0])) + selector.mix(6.0) + + inspect_case( + "bandpass -> LO -> bandpass -> LO", + selector, + expected_global=[[9.0, 10.0], [9.0, 6.0]], + expected_local=[[0.0, 1.0], [0.0, 3.0]], + expected_affine=[[9.0, 1.0], [9.0, -1.0]], + ) + + channel = pyarts.arts.SensorBoxChannel(0.0, 1.0, 3) + response = selector.channel_response(channel) + + assert_close( + "bandpass -> LO -> bandpass -> LO channel points", + np.array(response.grids[0]), + np.array([8.0, 8.5, 9.0, 9.5, 10.0]), + ) + assert_close( + "bandpass -> LO -> bandpass -> LO channel weights", + np.array(response.data), + np.array([1.0 / 3.0, 1.0 / 3.0, 2.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]), + ) + + +def test_ideal_split_about_lo(): + selector = pyarts.arts.SensorHeterodyneFrequencyRange() + selector.bandpass(np.array([5.0, 15.0])) + selector.mix(10.0) + + inspect_case( + "ideal split about LO", + selector, + expected_global=[[10.0, 15.0], [10.0, 5.0]], + expected_local=[[0.0, 5.0], [0.0, 5.0]], + expected_affine=[[10.0, 1.0], [10.0, -1.0]], + ) + + assert_close( + "ideal split path 0 global response at 12", + selector.global_response(np.array([12.0]), 0), + np.array([1.0]), + ) + assert_close( + "ideal split path 1 global response at 8", + selector.global_response(np.array([8.0]), 1), + np.array([1.0]), + ) + + +def test_weighted_split_about_lo(): + selector = pyarts.arts.SensorHeterodyneFrequencyRange() + selector.filter(make_triangular_filter()) + selector.mix(10.0) + + inspect_case( + "weighted split about LO", + selector, + expected_global=[[10.0, 15.0], [10.0, 5.0]], + expected_local=[[0.0, 5.0], [0.0, 5.0]], + expected_affine=[[10.0, 1.0], [10.0, -1.0]], + ) + + assert_close( + "weighted split path 0 local response at 2", + selector.local_response(np.array([2.0]), 0), + np.array([0.6]), + ) + assert_close( + "weighted split path 1 local response at 2", + selector.local_response(np.array([2.0]), 1), + np.array([0.6]), + ) + + channel = pyarts.arts.SensorDiracChannel(2.0) + response = selector.channel_response(channel) + + assert_close( + "weighted split channel points", + np.array(response.grids[0]), + np.array([8.0, 12.0]), + ) + assert_close( + "weighted split channel weights", + np.array(response.data), + np.array([0.6, 0.6]), + ) + + +test_lowpass_then_lo_then_box_spectrometer() +test_bandpass_lo_bandpass_lo_chain() +test_ideal_split_about_lo() +test_weighted_split_about_lo() + +print("\nAll heterodyne frequency-response checks passed.") \ No newline at end of file From 8923103bc17323e1e25d0a08ff72b61897650ca6 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Fri, 24 Apr 2026 17:49:23 +0900 Subject: [PATCH 02/21] Add antenna Co-authored-by: Copilot --- src/core/sensor/CMakeLists.txt | 1 + src/core/sensor/antenna_pattern.cpp | 279 ++++++++++++++++++ src/core/sensor/antenna_pattern.h | 98 ++++++ src/python_interface/py_sensor.cpp | 132 +++++++++ tests/core/sensor/antenna_pattern_response.py | 174 +++++++++++ .../sensor/heterodyne_frequency_response.py | 87 ++++++ 6 files changed, 771 insertions(+) create mode 100644 src/core/sensor/antenna_pattern.cpp create mode 100644 src/core/sensor/antenna_pattern.h create mode 100644 tests/core/sensor/antenna_pattern_response.py diff --git a/src/core/sensor/CMakeLists.txt b/src/core/sensor/CMakeLists.txt index 3b00883357..1a443d3381 100644 --- a/src/core/sensor/CMakeLists.txt +++ b/src/core/sensor/CMakeLists.txt @@ -1,4 +1,5 @@ add_library(sensor STATIC + antenna_pattern.cpp obsel.cpp sensor_meta_info.cpp frequency_bandpass_filters.cpp diff --git a/src/core/sensor/antenna_pattern.cpp b/src/core/sensor/antenna_pattern.cpp new file mode 100644 index 0000000000..4fea8d24a5 --- /dev/null +++ b/src/core/sensor/antenna_pattern.cpp @@ -0,0 +1,279 @@ +#include "antenna_pattern.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace sensor { +namespace { +constexpr Numeric response_eps = 64 * std::numeric_limits::epsilon(); +using identity = lagrange_interp::identity; + +Numeric scale(Numeric x) { return std::max(1.0, std::abs(x)); } + +bool is_close(Numeric a, Numeric b) { + return std::abs(a - b) <= response_eps * std::max(scale(a), scale(b)); +} + +bool is_within_support(const AscendingGrid& grid, Numeric x) { + if (grid.empty()) return false; + return x >= grid.front() - response_eps * scale(grid.front()) and + x <= grid.back() + response_eps * scale(grid.back()); +} + +template +using LookupLag = + std::variant, + lagrange_interp::lag_t<1, Transform>>; + +template +LookupLag lookup_lag(const Grid& grid, Numeric x) { + if (grid.size() == 1) return grid.template lag<0, Transform>(x); + return grid.template lag<1, Transform>(x); +} + +template +Numeric lookup_interpolate_impl(const ZenithGrid& zenith_grid, + const AzimuthGrid& azimuth_grid, + ConstMatrixView lookup_response, + Numeric delta_zenith, + Numeric delta_azimuth) { + return std::visit( + [&lookup_response](const auto& zenith_lag, const auto& azimuth_lag) { + return lagrange_interp::interp( + lookup_response, zenith_lag, azimuth_lag); + }, + lookup_lag(zenith_grid, delta_zenith), + lookup_lag(azimuth_grid, delta_azimuth)); +} + +Numeric lookup_interpolate(const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + ConstMatrixView lookup_response, + Numeric delta_zenith, + Numeric delta_azimuth) { + if (not is_within_support(zenith_grid, delta_zenith) or + not is_within_support(azimuth_grid, delta_azimuth)) { + return 0.0; + } + + return std::visit( + [&lookup_response](const auto& zenith_lag, const auto& azimuth_lag) { + return lagrange_interp::interp( + lookup_response, zenith_lag, azimuth_lag); + }, + lookup_lag(zenith_grid, delta_zenith), + lookup_lag(azimuth_grid, delta_azimuth)); +} + +Numeric gaussian_component(Numeric delta, Numeric sigma) { + using gauss = boost::math::normal_distribution; + using boost::math::pdf; + + static const gauss unit_normal(0.0, 1.0); + static const Numeric unit_peak = pdf(unit_normal, 0.0); + + return pdf(unit_normal, delta / sigma) / unit_peak; +} + +void assert_positive_width(Numeric value, std::string_view name) { + if (value <= 0) { + throw std::invalid_argument( + std::format("{} must be positive. Got: {}.", name, value)); + } +} +} // namespace + +AntennaPattern AntennaPattern::pencil() { + AntennaPattern pattern; + pattern.set_pencil_beam(); + return pattern; +} + +AntennaPattern AntennaPattern::gaussian(Numeric zenith_std, + Numeric azimuth_std) { + AntennaPattern pattern; + pattern.set_gaussian(zenith_std, azimuth_std); + return pattern; +} + +AntennaPattern AntennaPattern::gaussian_fwhm(Numeric zenith_fwhm, + Numeric azimuth_fwhm) { + AntennaPattern pattern; + pattern.set_gaussian_fwhm(zenith_fwhm, azimuth_fwhm); + return pattern; +} + +AntennaPattern AntennaPattern::lookup(const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + const Matrix& response_lookup) { + AntennaPattern pattern; + pattern.set_lookup(zenith_grid, azimuth_grid, response_lookup); + return pattern; +} + +AntennaPattern AntennaPattern::lookup(const ZenGrid& zenith_grid, + const AziGrid& azimuth_grid, + const Matrix& response_lookup) { + AntennaPattern pattern; + pattern.set_lookup(zenith_grid, azimuth_grid, response_lookup); + return pattern; +} + +void AntennaPattern::set_pencil_beam() { + type = AntennaType::PencilBeam; + sigma_zenith = 0.0; + sigma_azimuth = 0.0; + lookup_uses_angular_grids = false; + lookup_zenith_grid = AscendingGrid{}; + lookup_azimuth_grid = AscendingGrid{}; + lookup_response.resize(0, 0); +} + +void AntennaPattern::set_gaussian(Numeric zenith_std, Numeric azimuth_std) { + assert_positive_width(zenith_std, "Zenith standard deviation"); + assert_positive_width(azimuth_std, "Azimuth standard deviation"); + + type = AntennaType::Gaussian; + sigma_zenith = zenith_std; + sigma_azimuth = azimuth_std; + lookup_uses_angular_grids = false; + lookup_zenith_grid = AscendingGrid{}; + lookup_azimuth_grid = AscendingGrid{}; + lookup_response.resize(0, 0); +} + +void AntennaPattern::set_gaussian_fwhm(Numeric zenith_fwhm, + Numeric azimuth_fwhm) { + set_gaussian(Conversion::fwhm2std(zenith_fwhm), + Conversion::fwhm2std(azimuth_fwhm)); +} + +void AntennaPattern::set_lookup(const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + ConstMatrixView response_lookup_) { + if (zenith_grid.empty() or azimuth_grid.empty()) { + throw std::invalid_argument("Lookup antenna grids must not be empty."); + } + + if (response_lookup_.nrows() != static_cast(zenith_grid.size()) or + response_lookup_.ncols() != static_cast(azimuth_grid.size())) { + throw std::invalid_argument(std::format( + "Lookup antenna response must have shape ({}, {}). Got ({}, {}).", + zenith_grid.size(), + azimuth_grid.size(), + response_lookup_.nrows(), + response_lookup_.ncols())); + } + + type = AntennaType::Lookup; + sigma_zenith = 0.0; + sigma_azimuth = 0.0; + lookup_uses_angular_grids = false; + lookup_zenith_grid = zenith_grid; + lookup_azimuth_grid = azimuth_grid; + lookup_response = Matrix(response_lookup_); +} + +void AntennaPattern::set_lookup(const ZenGrid& zenith_grid, + const AziGrid& azimuth_grid, + ConstMatrixView response_lookup_) { + set_lookup(AscendingGrid{zenith_grid.vec()}, + AscendingGrid{azimuth_grid.vec()}, + response_lookup_); + lookup_uses_angular_grids = true; +} + +Numeric AntennaPattern::operator()(Numeric delta_zenith, + Numeric delta_azimuth) const { + switch (type) { + case AntennaType::PencilBeam: + return is_close(delta_zenith, 0.0) and is_close(delta_azimuth, 0.0) ? 1.0 + : 0.0; + + case AntennaType::Gaussian: + return gaussian_component(delta_zenith, sigma_zenith) * + gaussian_component(delta_azimuth, sigma_azimuth); + + case AntennaType::Lookup: + if (lookup_uses_angular_grids) { + if (not ZenGrid::is_valid(delta_zenith) or + not is_within_support(lookup_zenith_grid, delta_zenith) or + not is_within_support(lookup_azimuth_grid, delta_azimuth)) { + return 0.0; + } + + return lookup_interpolate_impl(ZenGrid{lookup_zenith_grid.vec()}, + AziGrid{lookup_azimuth_grid.vec()}, + lookup_response, + delta_zenith, + delta_azimuth); + } + + return lookup_interpolate(lookup_zenith_grid, + lookup_azimuth_grid, + lookup_response, + delta_zenith, + delta_azimuth); + + default: + throw std::invalid_argument( + std::format("Unsupported antenna type: {}.", type)); + } +} + +AntennaPatternGriddedField AntennaPattern::response( + const AscendingGrid& zenith_grid, const AscendingGrid& azimuth_grid) const { + Matrix data(zenith_grid.size(), azimuth_grid.size()); + + for (Size i = 0; i < zenith_grid.size(); i++) { + for (Size j = 0; j < azimuth_grid.size(); j++) { + data[i, j] = (*this)(zenith_grid[i], azimuth_grid[j]); + } + } + + return {.data_name = "antenna-pattern"s, + .data = std::move(data), + .grid_names = std::array{"dzen"s, "dazi"s}, + .grids = std::array{zenith_grid, azimuth_grid}}; +} + +AntennaPatternGriddedField AntennaPattern::normalized_response( + const AscendingGrid& zenith_grid, const AscendingGrid& azimuth_grid) const { + auto out = response(zenith_grid, azimuth_grid); + + Numeric total = 0.0; + for (Index i = 0; i < out.data.nrows(); i++) { + for (Index j = 0; j < out.data.ncols(); j++) { + total += out.data[i, j]; + } + } + + if (total > 0) out.data /= total; + + out.data_name = "antenna-pattern-normalized"s; + return out; +} + +AntennaPatternGriddedField AntennaPattern::raw_sensor( + const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const { + auto out = response(dzen_grid, dazi_grid); + out.data_name = "antenna-raw-sensor"s; + return out; +} + +AntennaPatternGriddedField AntennaPattern::normalized_raw_sensor( + const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const { + auto out = normalized_response(dzen_grid, dazi_grid); + out.data_name = "antenna-raw-sensor-normalized"s; + return out; +} + +static_assert(AntennaPatternSelection); +} // namespace sensor \ No newline at end of file diff --git a/src/core/sensor/antenna_pattern.h b/src/core/sensor/antenna_pattern.h new file mode 100644 index 0000000000..40ce635c20 --- /dev/null +++ b/src/core/sensor/antenna_pattern.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include + +namespace sensor { +//! A 2D angular antenna pattern on local zenith and azimuth offsets. +struct AntennaPattern; + +//! 2D gridded field of antenna weights on local zenith and azimuth offsets. +using AntennaPatternGriddedField = + matpack::gridded_data_t; + +//! Concept for selecting antenna patterns. +template +concept AntennaPatternSelection = std::derived_from; + +struct AntennaPattern { + AntennaType type{AntennaType::PencilBeam}; + + Numeric sigma_zenith{0.0}; + Numeric sigma_azimuth{0.0}; + bool lookup_uses_angular_grids{false}; + + AscendingGrid lookup_zenith_grid{}; + AscendingGrid lookup_azimuth_grid{}; + Matrix lookup_response{}; + + AntennaPattern() = default; + + static AntennaPattern pencil(); + static AntennaPattern gaussian(Numeric zenith_std, Numeric azimuth_std); + static AntennaPattern gaussian_fwhm(Numeric zenith_fwhm, + Numeric azimuth_fwhm); + static AntennaPattern lookup(const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + const Matrix& response_lookup); + static AntennaPattern lookup(const ZenGrid& zenith_grid, + const AziGrid& azimuth_grid, + const Matrix& response_lookup); + + void set_pencil_beam(); + void set_gaussian(Numeric zenith_std, Numeric azimuth_std); + void set_gaussian_fwhm(Numeric zenith_fwhm, Numeric azimuth_fwhm); + void set_lookup(const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + ConstMatrixView response_lookup); + void set_lookup(const ZenGrid& zenith_grid, + const AziGrid& azimuth_grid, + ConstMatrixView response_lookup); + + [[nodiscard]] Numeric operator()(Numeric delta_zenith, + Numeric delta_azimuth) const; + + [[nodiscard]] AntennaPatternGriddedField response( + const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid) const; + [[nodiscard]] AntennaPatternGriddedField normalized_response( + const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid) const; + + [[nodiscard]] AntennaPatternGriddedField raw_sensor( + const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const; + [[nodiscard]] AntennaPatternGriddedField normalized_raw_sensor( + const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const; +}; +} // namespace sensor + +template <> +struct std::formatter { + format_tags tags{}; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const sensor::AntennaPattern& v, + FmtContext& ctx) const { + return tags.format( + ctx, + "type="sv, + v.type, + tags.sep(), + "sigma_zenith="sv, + v.sigma_zenith, + tags.sep(), + "sigma_azimuth="sv, + v.sigma_azimuth, + tags.sep(), + "lookup_shape="sv, + std::array{v.lookup_response.nrows(), v.lookup_response.ncols()}); + } +}; \ No newline at end of file diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 9a4659d279..2f363c760b 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -511,6 +512,137 @@ Numeric, Vector, or Matrix "Construct a zero-centered Gaussian channel on ``+/- m * std``."); generic_interface(sgauss); + auto sap = py::class_(m, "SensorAntennaPattern"); + sap.doc() = + "A local 2D antenna pattern on zenith and azimuth offsets from boresight."; + sap.def(py::init<>(), + "Construct a pencil-beam antenna pattern.") + .def_static("pencil", + &sensor::AntennaPattern::pencil, + "Construct a pencil-beam antenna pattern.") + .def_static("gaussian", + &sensor::AntennaPattern::gaussian, + "zenith_std"_a, + "azimuth_std"_a, + "Construct a Gaussian antenna pattern from zenith and azimuth standard deviations.") + .def_static("gaussian_fwhm", + &sensor::AntennaPattern::gaussian_fwhm, + "zenith_fwhm"_a, + "azimuth_fwhm"_a, + "Construct a Gaussian antenna pattern from zenith and azimuth FWHM values.") + .def_static("lookup", + [](const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + const Matrix& response_lookup) { + return sensor::AntennaPattern::lookup( + zenith_grid, azimuth_grid, response_lookup); + }, + "zenith_grid"_a, + "azimuth_grid"_a, + "response_lookup"_a, + "Construct an antenna pattern from a lookup table on local zenith and azimuth grids.") + .def_static("lookup", + [](const ZenGrid& zenith_grid, + const AziGrid& azimuth_grid, + const Matrix& response_lookup) { + return sensor::AntennaPattern::lookup( + zenith_grid, azimuth_grid, response_lookup); + }, + "zenith_grid"_a, + "azimuth_grid"_a, + "response_lookup"_a, + "Construct an antenna pattern from lookup tables defined on typed zenith and azimuth angle grids.") + .def("set_pencil_beam", + &sensor::AntennaPattern::set_pencil_beam, + "Reset the antenna pattern to a pencil beam.") + .def("set_gaussian", + &sensor::AntennaPattern::set_gaussian, + "zenith_std"_a, + "azimuth_std"_a, + "Set the antenna pattern to a Gaussian described by standard deviations.") + .def("set_gaussian_fwhm", + &sensor::AntennaPattern::set_gaussian_fwhm, + "zenith_fwhm"_a, + "azimuth_fwhm"_a, + "Set the antenna pattern to a Gaussian described by FWHM values.") + .def("set_lookup", + [](sensor::AntennaPattern& self, + const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + const Matrix& response_lookup) { + self.set_lookup(zenith_grid, azimuth_grid, response_lookup); + }, + "zenith_grid"_a, + "azimuth_grid"_a, + "response_lookup"_a, + "Set the antenna pattern from a lookup table.") + .def("set_lookup", + [](sensor::AntennaPattern& self, + const ZenGrid& zenith_grid, + const AziGrid& azimuth_grid, + const Matrix& response_lookup) { + self.set_lookup(zenith_grid, azimuth_grid, response_lookup); + }, + "zenith_grid"_a, + "azimuth_grid"_a, + "response_lookup"_a, + "Set the antenna pattern from typed zenith and azimuth angle grids.") + .def("__call__", + [](const sensor::AntennaPattern& self, + Numeric delta_zenith, + Numeric delta_azimuth) { + return self(delta_zenith, delta_azimuth); + }, + "delta_zenith"_a, + "delta_azimuth"_a, + "Evaluate the antenna pattern at one local zenith and azimuth offset.") + .def("response", + [](const sensor::AntennaPattern& self, + const AscendingGrid& zenith_grid, + const AscendingGrid& azimuth_grid, + bool normalize) { + return normalize ? self.normalized_response(zenith_grid, azimuth_grid) + : self.response(zenith_grid, azimuth_grid); + }, + "zenith_grid"_a, + "azimuth_grid"_a, + "normalize"_a = false, + "Evaluate the antenna pattern on a 2D local angular grid.") + .def("raw_sensor", + [](const sensor::AntennaPattern& self, + const AscendingGrid& dzen_grid, + const AscendingGrid& dazi_grid, + bool normalize) { + return normalize ? self.normalized_raw_sensor(dzen_grid, dazi_grid) + : self.raw_sensor(dzen_grid, dazi_grid); + }, + "dzen_grid"_a, + "dazi_grid"_a, + "normalize"_a = false, + R"(Evaluate the antenna pattern on local angular offsets and return a raw-sensor gridded field. + + The returned field uses grid names ``dzen`` and ``dazi`` and can be passed to + ``measurement_sensorAddRawSensor``.)") + .def_ro("type", + &sensor::AntennaPattern::type, + "Antenna pattern family.") + .def_ro("sigma_zenith", + &sensor::AntennaPattern::sigma_zenith, + "Zenith standard deviation used by Gaussian patterns.") + .def_ro("sigma_azimuth", + &sensor::AntennaPattern::sigma_azimuth, + "Azimuth standard deviation used by Gaussian patterns.") + .def_ro("lookup_zenith_grid", + &sensor::AntennaPattern::lookup_zenith_grid, + "Zenith grid used by lookup antenna patterns.") + .def_ro("lookup_azimuth_grid", + &sensor::AntennaPattern::lookup_azimuth_grid, + "Azimuth grid used by lookup antenna patterns.") + .def_ro("lookup_response", + &sensor::AntennaPattern::lookup_response, + "Response matrix used by lookup antenna patterns."); + generic_interface(sap); + auto shdfr = py::class_( m, "SensorHeterodyneFrequencyRange"); shdfr.def(py::init<>(), diff --git a/tests/core/sensor/antenna_pattern_response.py b/tests/core/sensor/antenna_pattern_response.py new file mode 100644 index 0000000000..e1051be4f9 --- /dev/null +++ b/tests/core/sensor/antenna_pattern_response.py @@ -0,0 +1,174 @@ +import numpy as np +import pyarts3 as pyarts + + +def assert_close(name, got, expected, atol=1e-10): + got = np.asarray(got, dtype=float) + expected = np.asarray(expected, dtype=float) + print(f"{name}: got = {got}") + print(f"{name}: expected = {expected}") + np.testing.assert_allclose(got, expected, atol=atol, rtol=0.0) + + +def test_pencil_pattern(): + pattern = pyarts.arts.SensorAntennaPattern.pencil() + dzen = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) + dazi = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) + raw = pattern.raw_sensor(dzen, dazi) + + assert_close( + "pencil raw sensor", + np.array(raw.data), + np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 0.0], + ] + ), + ) + + +def test_gaussian_pattern_and_fwhm_equivalence(): + std_zenith = 1.0 + std_azimuth = 2.0 + fwhm_factor = 2.0 * np.sqrt(2.0 * np.log(2.0)) + + pattern_std = pyarts.arts.SensorAntennaPattern.gaussian( + std_zenith, std_azimuth + ) + pattern_fwhm = pyarts.arts.SensorAntennaPattern.gaussian_fwhm( + fwhm_factor * std_zenith, fwhm_factor * std_azimuth + ) + + assert_close( + "gaussian point evaluation", + np.array([pattern_std(1.0, 2.0)]), + np.array([np.exp(-1.0)]), + ) + + dzen = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) + dazi = pyarts.arts.AscendingGrid(np.array([-2.0, 0.0, 2.0])) + + std_raw = pattern_std.raw_sensor(dzen, dazi) + fwhm_raw = pattern_fwhm.raw_sensor(dzen, dazi) + normalized_raw = pattern_std.raw_sensor(dzen, dazi, normalize=True) + + expected_raw = np.array( + [ + [np.exp(-1.0), np.exp(-0.5), np.exp(-1.0)], + [np.exp(-0.5), 1.0, np.exp(-0.5)], + [np.exp(-1.0), np.exp(-0.5), np.exp(-1.0)], + ] + ) + expected_normalized = expected_raw / expected_raw.sum() + + assert_close("gaussian raw sensor", np.array(std_raw.data), expected_raw) + assert_close( + "gaussian vs fwhm raw sensor", + np.array(fwhm_raw.data), + expected_raw, + ) + assert_close( + "gaussian normalized raw sensor", + np.array(normalized_raw.data), + expected_normalized, + ) + + +def test_lookup_pattern(): + pattern = pyarts.arts.SensorAntennaPattern.lookup( + pyarts.arts.AscendingGrid(np.array([0.0, 1.0])), + pyarts.arts.AscendingGrid(np.array([0.0, 1.0])), + pyarts.arts.Matrix(np.array([[1.0, 2.0], [3.0, 4.0]])), + ) + + assert_close( + "lookup bilinear center", + np.array([pattern(0.5, 0.5)]), + np.array([2.5]), + ) + assert_close( + "lookup outside support", + np.array([pattern(2.0, 2.0)]), + np.array([0.0]), + ) + + +def test_lookup_pattern_with_typed_angular_grids(): + pattern = pyarts.arts.SensorAntennaPattern.lookup( + pyarts.arts.ZenGrid(np.array([0.0])), + pyarts.arts.AziGrid(np.array([0.0, 120.0, 240.0])), + pyarts.arts.Matrix(np.array([[1.0, 2.0, 3.0]])), + ) + + assert_close( + "typed angular-grid lookup exact point", + np.array([pattern(0.0, 120.0)]), + np.array([2.0]), + ) + assert_close( + "typed angular-grid lookup interpolated midpoint", + np.array([pattern(0.0, 60.0)]), + np.array([1.5]), + ) + assert_close( + "typed angular-grid lookup outside support", + np.array([pattern(0.0, 300.0)]), + np.array([0.0]), + ) + + +def test_pattern_to_sensor_via_raw_sensor(): + pattern = pyarts.arts.SensorAntennaPattern.gaussian(1.0, 2.0) + dzen = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) + dazi = pyarts.arts.AscendingGrid(np.array([-2.0, 0.0, 2.0])) + raw = pattern.raw_sensor(dzen, dazi, normalize=True) + + ws = pyarts.Workspace() + ws.measurement_sensor = pyarts.arts.ArrayOfSensorObsel() + ws.measurement_sensor_meta = pyarts.arts.ArrayOfSensorMetaInfo() + ws.measurement_sensorAddRawSensor( + freq_grid=pyarts.arts.AscendingGrid(np.array([100.0, 101.0])), + pos=pyarts.arts.Vector3(np.array([600000.0, 0.0, 0.0])), + los=pyarts.arts.Vector2(np.array([180.0, 0.0])), + raw_sensor_perturbation=raw, + normalize=0, + ) + + expected_weight_vector = np.array(raw.data).reshape(-1) + + assert_close( + "sensor count from raw antenna pattern", + np.array([len(ws.measurement_sensor)]), + np.array([2.0]), + ) + assert_close( + "sensor meta count from raw antenna pattern", + np.array([len(ws.measurement_sensor_meta)]), + np.array([1.0]), + ) + assert_close( + "sensor poslos size from raw antenna pattern", + np.array([len(ws.measurement_sensor[0].poslos)]), + np.array([9.0]), + ) + assert_close( + "first sensor poslos entry", + np.array(ws.measurement_sensor[0].poslos[0]), + np.array([600000.0, 0.0, 0.0, 179.0, -2.0]), + ) + assert_close( + "sensor weights from raw antenna pattern", + np.array(ws.measurement_sensor[0].weight_matrix.reduce(along_freq=True)), + expected_weight_vector, + ) + + +test_pencil_pattern() +test_gaussian_pattern_and_fwhm_equivalence() +test_lookup_pattern() +test_lookup_pattern_with_typed_angular_grids() +test_pattern_to_sensor_via_raw_sensor() + +print("\nAll antenna pattern checks passed.") \ No newline at end of file diff --git a/tests/core/sensor/heterodyne_frequency_response.py b/tests/core/sensor/heterodyne_frequency_response.py index b2773c0765..94e21f1d4b 100644 --- a/tests/core/sensor/heterodyne_frequency_response.py +++ b/tests/core/sensor/heterodyne_frequency_response.py @@ -2,6 +2,10 @@ import pyarts3 as pyarts +def db_to_lin(db): + return 10.0 ** (db / 10.0) + + def ranges_as_lists(ranges): return [list(map(float, pair)) for pair in ranges] @@ -47,6 +51,29 @@ def make_triangular_filter(): return filt +def make_asymmetric_sideband_filter(): + filt = pyarts.arts.SortedGriddedField1() + filt.grids = ( + pyarts.arts.AscendingGrid(np.array([7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0])), + ) + filt.data = pyarts.arts.Vector( + np.array( + [ + db_to_lin(-10.0), + db_to_lin(-10.0), + db_to_lin(-10.0), + db_to_lin(0.0), + db_to_lin(1.0), + db_to_lin(5.0), + db_to_lin(10.0), + ] + ) + ) + filt.gridnames = ("frequency",) + filt.dataname = "asymmetric_sidebands" + return filt + + def test_lowpass_then_lo_then_box_spectrometer(): selector = pyarts.arts.SensorHeterodyneFrequencyRange() print("test_lowpass_then_lo_then_box_spectrometer") @@ -172,9 +199,69 @@ def test_weighted_split_about_lo(): ) +def test_overlapping_sidebands_with_asymmetric_bandpass(): + selector = pyarts.arts.SensorHeterodyneFrequencyRange() + selector.filter(make_asymmetric_sideband_filter()) + selector.mix(10.0) + + inspect_case( + "overlapping sidebands with asymmetric bandpass", + selector, + expected_global=[[10.0, 13.0], [10.0, 7.0]], + expected_local=[[0.0, 3.0], [0.0, 3.0]], + expected_affine=[[10.0, 1.0], [10.0, -1.0]], + ) + + upper_expected = np.array([db_to_lin(1.0), db_to_lin(5.0), db_to_lin(10.0)]) + lower_expected = np.full(3, db_to_lin(-10.0)) + + assert_close( + "overlapping sidebands upper-path local gains", + selector.local_response(np.array([1.0, 2.0, 3.0]), 0), + upper_expected, + ) + assert_close( + "overlapping sidebands lower-path local gains", + selector.local_response(np.array([1.0, 2.0, 3.0]), 1), + lower_expected, + ) + + responses = selector.channel_responses( + [ + pyarts.arts.SensorDiracChannel(1.0), + pyarts.arts.SensorDiracChannel(2.0), + pyarts.arts.SensorDiracChannel(3.0), + ] + ) + + expected_points = [ + np.array([9.0, 11.0]), + np.array([8.0, 12.0]), + np.array([7.0, 13.0]), + ] + expected_weights = [ + np.array([db_to_lin(-10.0), db_to_lin(1.0)]), + np.array([db_to_lin(-10.0), db_to_lin(5.0)]), + np.array([db_to_lin(-10.0), db_to_lin(10.0)]), + ] + + for index, response in enumerate(responses): + assert_close( + f"overlapping sidebands channel {index} points", + np.array(response.grids[0]), + expected_points[index], + ) + assert_close( + f"overlapping sidebands channel {index} weights", + np.array(response.data), + expected_weights[index], + ) + + test_lowpass_then_lo_then_box_spectrometer() test_bandpass_lo_bandpass_lo_chain() test_ideal_split_about_lo() test_weighted_split_about_lo() +test_overlapping_sidebands_with_asymmetric_bandpass() print("\nAll heterodyne frequency-response checks passed.") \ No newline at end of file From b661eb7df314569a0a8ef5f6c5d61d2839570413 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 27 Apr 2026 15:31:51 +0900 Subject: [PATCH 03/21] Add clearer antenna patterns Co-authored-by: Copilot --- src/core/sensor/antenna_pattern.cpp | 365 ++++++------------ src/core/sensor/antenna_pattern.h | 155 ++++---- .../sensor/frequency_bandpass_filters.cpp | 42 +- src/core/sensor/frequency_bandpass_filters.h | 34 ++ src/core/sensor/frequency_channel_selection.h | 71 ++++ src/core/sensor/frequency_range_selection.h | 65 +++- src/core/util/aggregate_helper.h | 216 +++++++++++ src/core/util/format_tags.h | 32 ++ src/core/xml/xml_io_stream_aggregate.h | 218 +---------- src/python_interface/py_sensor.cpp | 133 +------ src/tests/CMakeLists.txt | 6 + src/tests/test_antenna_pattern.cc | 138 +++++++ tests/core/sensor/antenna_pattern_response.py | 174 --------- 13 files changed, 758 insertions(+), 891 deletions(-) create mode 100644 src/core/util/aggregate_helper.h create mode 100644 src/tests/test_antenna_pattern.cc delete mode 100644 tests/core/sensor/antenna_pattern_response.py diff --git a/src/core/sensor/antenna_pattern.cpp b/src/core/sensor/antenna_pattern.cpp index 4fea8d24a5..fb1a116f1e 100644 --- a/src/core/sensor/antenna_pattern.cpp +++ b/src/core/sensor/antenna_pattern.cpp @@ -1,279 +1,132 @@ #include "antenna_pattern.h" #include -#include +#include -#include -#include -#include -#include -#include -#include +#include namespace sensor { -namespace { -constexpr Numeric response_eps = 64 * std::numeric_limits::epsilon(); -using identity = lagrange_interp::identity; - -Numeric scale(Numeric x) { return std::max(1.0, std::abs(x)); } - -bool is_close(Numeric a, Numeric b) { - return std::abs(a - b) <= response_eps * std::max(scale(a), scale(b)); -} - -bool is_within_support(const AscendingGrid& grid, Numeric x) { - if (grid.empty()) return false; - return x >= grid.front() - response_eps * scale(grid.front()) and - x <= grid.back() + response_eps * scale(grid.back()); -} - -template -using LookupLag = - std::variant, - lagrange_interp::lag_t<1, Transform>>; - -template -LookupLag lookup_lag(const Grid& grid, Numeric x) { - if (grid.size() == 1) return grid.template lag<0, Transform>(x); - return grid.template lag<1, Transform>(x); -} - -template -Numeric lookup_interpolate_impl(const ZenithGrid& zenith_grid, - const AzimuthGrid& azimuth_grid, - ConstMatrixView lookup_response, - Numeric delta_zenith, - Numeric delta_azimuth) { - return std::visit( - [&lookup_response](const auto& zenith_lag, const auto& azimuth_lag) { - return lagrange_interp::interp( - lookup_response, zenith_lag, azimuth_lag); - }, - lookup_lag(zenith_grid, delta_zenith), - lookup_lag(azimuth_grid, delta_azimuth)); -} - -Numeric lookup_interpolate(const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - ConstMatrixView lookup_response, - Numeric delta_zenith, - Numeric delta_azimuth) { - if (not is_within_support(zenith_grid, delta_zenith) or - not is_within_support(azimuth_grid, delta_azimuth)) { - return 0.0; - } - - return std::visit( - [&lookup_response](const auto& zenith_lag, const auto& azimuth_lag) { - return lagrange_interp::interp( - lookup_response, zenith_lag, azimuth_lag); - }, - lookup_lag(zenith_grid, delta_zenith), - lookup_lag(azimuth_grid, delta_azimuth)); -} - -Numeric gaussian_component(Numeric delta, Numeric sigma) { - using gauss = boost::math::normal_distribution; - using boost::math::pdf; - - static const gauss unit_normal(0.0, 1.0); - static const Numeric unit_peak = pdf(unit_normal, 0.0); - - return pdf(unit_normal, delta / sigma) / unit_peak; -} - -void assert_positive_width(Numeric value, std::string_view name) { - if (value <= 0) { - throw std::invalid_argument( - std::format("{} must be positive. Got: {}.", name, value)); - } -} -} // namespace - -AntennaPattern AntennaPattern::pencil() { - AntennaPattern pattern; - pattern.set_pencil_beam(); - return pattern; -} - -AntennaPattern AntennaPattern::gaussian(Numeric zenith_std, - Numeric azimuth_std) { - AntennaPattern pattern; - pattern.set_gaussian(zenith_std, azimuth_std); - return pattern; -} - -AntennaPattern AntennaPattern::gaussian_fwhm(Numeric zenith_fwhm, - Numeric azimuth_fwhm) { - AntennaPattern pattern; - pattern.set_gaussian_fwhm(zenith_fwhm, azimuth_fwhm); - return pattern; -} - -AntennaPattern AntennaPattern::lookup(const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - const Matrix& response_lookup) { - AntennaPattern pattern; - pattern.set_lookup(zenith_grid, azimuth_grid, response_lookup); - return pattern; -} - -AntennaPattern AntennaPattern::lookup(const ZenGrid& zenith_grid, - const AziGrid& azimuth_grid, - const Matrix& response_lookup) { - AntennaPattern pattern; - pattern.set_lookup(zenith_grid, azimuth_grid, response_lookup); - return pattern; -} -void AntennaPattern::set_pencil_beam() { - type = AntennaType::PencilBeam; - sigma_zenith = 0.0; - sigma_azimuth = 0.0; - lookup_uses_angular_grids = false; - lookup_zenith_grid = AscendingGrid{}; - lookup_azimuth_grid = AscendingGrid{}; - lookup_response.resize(0, 0); -} - -void AntennaPattern::set_gaussian(Numeric zenith_std, Numeric azimuth_std) { - assert_positive_width(zenith_std, "Zenith standard deviation"); - assert_positive_width(azimuth_std, "Azimuth standard deviation"); - - type = AntennaType::Gaussian; - sigma_zenith = zenith_std; - sigma_azimuth = azimuth_std; - lookup_uses_angular_grids = false; - lookup_zenith_grid = AscendingGrid{}; - lookup_azimuth_grid = AscendingGrid{}; - lookup_response.resize(0, 0); -} - -void AntennaPattern::set_gaussian_fwhm(Numeric zenith_fwhm, - Numeric azimuth_fwhm) { - set_gaussian(Conversion::fwhm2std(zenith_fwhm), - Conversion::fwhm2std(azimuth_fwhm)); -} - -void AntennaPattern::set_lookup(const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - ConstMatrixView response_lookup_) { - if (zenith_grid.empty() or azimuth_grid.empty()) { - throw std::invalid_argument("Lookup antenna grids must not be empty."); - } - - if (response_lookup_.nrows() != static_cast(zenith_grid.size()) or - response_lookup_.ncols() != static_cast(azimuth_grid.size())) { - throw std::invalid_argument(std::format( - "Lookup antenna response must have shape ({}, {}). Got ({}, {}).", - zenith_grid.size(), - azimuth_grid.size(), - response_lookup_.nrows(), - response_lookup_.ncols())); - } - - type = AntennaType::Lookup; - sigma_zenith = 0.0; - sigma_azimuth = 0.0; - lookup_uses_angular_grids = false; - lookup_zenith_grid = zenith_grid; - lookup_azimuth_grid = azimuth_grid; - lookup_response = Matrix(response_lookup_); -} - -void AntennaPattern::set_lookup(const ZenGrid& zenith_grid, - const AziGrid& azimuth_grid, - ConstMatrixView response_lookup_) { - set_lookup(AscendingGrid{zenith_grid.vec()}, - AscendingGrid{azimuth_grid.vec()}, - response_lookup_); - lookup_uses_angular_grids = true; -} - -Numeric AntennaPattern::operator()(Numeric delta_zenith, - Numeric delta_azimuth) const { - switch (type) { - case AntennaType::PencilBeam: - return is_close(delta_zenith, 0.0) and is_close(delta_azimuth, 0.0) ? 1.0 - : 0.0; - - case AntennaType::Gaussian: - return gaussian_component(delta_zenith, sigma_zenith) * - gaussian_component(delta_azimuth, sigma_azimuth); - - case AntennaType::Lookup: - if (lookup_uses_angular_grids) { - if (not ZenGrid::is_valid(delta_zenith) or - not is_within_support(lookup_zenith_grid, delta_zenith) or - not is_within_support(lookup_azimuth_grid, delta_azimuth)) { - return 0.0; - } - - return lookup_interpolate_impl(ZenGrid{lookup_zenith_grid.vec()}, - AziGrid{lookup_azimuth_grid.vec()}, - lookup_response, - delta_zenith, - delta_azimuth); +namespace { +struct AntennaBasis { + Vector3 v; + Vector3 h; + Vector3 k; +}; + +[[nodiscard]] AntennaBasis antenna_basis(Vector2 bore_los) { + using Conversion::cosd, Conversion::sind; + + const Numeric cza = cosd(bore_los[0]); + const Numeric sza = sind(bore_los[0]); + const Numeric caa = cosd(bore_los[1]); + const Numeric saa = sind(bore_los[1]); + + return { + .v = {-cza * saa, -cza * caa, sza}, + .h = {caa, -saa, 0.0}, + .k = {sza * saa, sza * caa, cza}, + }; +} + +[[nodiscard]] Vector3 antenna_frame_los(Vector2 local_los) { + using Conversion::cosd, Conversion::sind; + + const Numeric cza = cosd(local_los[0]); + const Numeric sza = sind(local_los[0]); + const Numeric caa = cosd(local_los[1]); + const Numeric saa = sind(local_los[1]); + + return {-sza * caa, sza * saa, cza}; +} + +[[nodiscard]] AntennaPatternField make_gaussian_field(ZenGrid zen_grid, + AziGrid azi_grid, + Numeric zenith_std, + Numeric azimuth_std, + Stokvec weight) { + ARTS_USER_ERROR_IF(zenith_std <= 0.0, + "Gaussian antenna zenith_std must be positive") + ARTS_USER_ERROR_IF(azimuth_std <= 0.0, + "Gaussian antenna azimuth_std must be positive") + + AntennaPatternField out{ + .data_name = "gaussian"s, + .data = StokvecMatrix(zen_grid.size(), azi_grid.size()), + .grid_names = {"zenith"s, "azimuth"s}, + .grids = {std::move(zen_grid), std::move(azi_grid)}, + }; + + using Conversion::atan2d; + + for (Size izen = 0; izen < out.grid<0>().size(); ++izen) { + for (Size iazi = 0; iazi < out.grid<1>().size(); ++iazi) { + const Vector3 local = + antenna_frame_los({out.grid<0>()[izen], out.grid<1>()[iazi]}); + + if (local[2] <= 0.0) { + out[izen, iazi] = {0.0, 0.0, 0.0, 0.0}; + continue; } - return lookup_interpolate(lookup_zenith_grid, - lookup_azimuth_grid, - lookup_response, - delta_zenith, - delta_azimuth); - - default: - throw std::invalid_argument( - std::format("Unsupported antenna type: {}.", type)); - } -} - -AntennaPatternGriddedField AntennaPattern::response( - const AscendingGrid& zenith_grid, const AscendingGrid& azimuth_grid) const { - Matrix data(zenith_grid.size(), azimuth_grid.size()); + const Numeric ant_zen = atan2d(local[0], local[2]); + const Numeric ant_azi = atan2d(local[1], local[2]); + const Numeric exponent = + -0.5 * ((ant_zen / zenith_std) * (ant_zen / zenith_std) + + (ant_azi / azimuth_std) * (ant_azi / azimuth_std)); - for (Size i = 0; i < zenith_grid.size(); i++) { - for (Size j = 0; j < azimuth_grid.size(); j++) { - data[i, j] = (*this)(zenith_grid[i], azimuth_grid[j]); + out[izen, iazi] = std::exp(exponent) * weight; } } - return {.data_name = "antenna-pattern"s, - .data = std::move(data), - .grid_names = std::array{"dzen"s, "dazi"s}, - .grids = std::array{zenith_grid, azimuth_grid}}; + return out; } +} // namespace -AntennaPatternGriddedField AntennaPattern::normalized_response( - const AscendingGrid& zenith_grid, const AscendingGrid& azimuth_grid) const { - auto out = response(zenith_grid, azimuth_grid); - - Numeric total = 0.0; - for (Index i = 0; i < out.data.nrows(); i++) { - for (Index j = 0; j < out.data.ncols(); j++) { - total += out.data[i, j]; +PencilBeamAntenna::PencilBeamAntenna(Stokvec weight) + : AntennaPattern({.data_name = "pencil beam"s, + .data = StokvecMatrix(1, 1, weight), + .grid_names = {"zenith"s, "azimuth"s}, + .grids = {Vector{0.0}, Vector{0.0}}}) {} + +GaussianAntenna::GaussianAntenna(ZenGrid zen_grid, + AziGrid azi_grid, + Numeric zenith_std, + Numeric azimuth_std, + Stokvec weight) + : AntennaPattern(make_gaussian_field(std::move(zen_grid), + std::move(azi_grid), + zenith_std, + azimuth_std, + weight)) {} + +std::vector> AntennaPattern::operator()( + Vector2 bore_los) const { + ARTS_USER_ERROR_IF(not data.ok(), + "SensorAntennaPattern data shape does not match its grids") + + const auto& zen_grid = data.grid<0>(); + const auto& azi_grid = data.grid<1>(); + + std::vector> out; + out.reserve(static_cast(zen_grid.size()) * + static_cast(azi_grid.size())); + + const auto basis = antenna_basis(bore_los); + + for (Size izen = 0; izen < zen_grid.size(); ++izen) { + for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { + const Vector3 local = antenna_frame_los({zen_grid[izen], azi_grid[iazi]}); + const Vector3 enu = normalized(local[0] * basis.v + local[1] * basis.h + + local[2] * basis.k); + out.emplace_back(data[izen, iazi], enu2los(enu)); } } - if (total > 0) out.data /= total; - - out.data_name = "antenna-pattern-normalized"s; - return out; -} - -AntennaPatternGriddedField AntennaPattern::raw_sensor( - const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const { - auto out = response(dzen_grid, dazi_grid); - out.data_name = "antenna-raw-sensor"s; - return out; -} - -AntennaPatternGriddedField AntennaPattern::normalized_raw_sensor( - const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const { - auto out = normalized_response(dzen_grid, dazi_grid); - out.data_name = "antenna-raw-sensor-normalized"s; return out; } static_assert(AntennaPatternSelection); +static_assert(AntennaPatternSelection); +static_assert(AntennaPatternSelection); } // namespace sensor \ No newline at end of file diff --git a/src/core/sensor/antenna_pattern.h b/src/core/sensor/antenna_pattern.h index 40ce635c20..93acd29f1c 100644 --- a/src/core/sensor/antenna_pattern.h +++ b/src/core/sensor/antenna_pattern.h @@ -1,98 +1,97 @@ #pragma once -#include #include +#include namespace sensor { //! A 2D angular antenna pattern on local zenith and azimuth offsets. struct AntennaPattern; +//! A 1x1 antenna pattern that samples only the bore line of sight. +struct PencilBeamAntenna; + +//! A 2D Gaussian antenna pattern on local zenith and azimuth offsets. +struct GaussianAntenna; + //! 2D gridded field of antenna weights on local zenith and azimuth offsets. -using AntennaPatternGriddedField = - matpack::gridded_data_t; +using AntennaPatternField = matpack::gridded_data_t; //! Concept for selecting antenna patterns. template concept AntennaPatternSelection = std::derived_from; struct AntennaPattern { - AntennaType type{AntennaType::PencilBeam}; - - Numeric sigma_zenith{0.0}; - Numeric sigma_azimuth{0.0}; - bool lookup_uses_angular_grids{false}; - - AscendingGrid lookup_zenith_grid{}; - AscendingGrid lookup_azimuth_grid{}; - Matrix lookup_response{}; - - AntennaPattern() = default; - - static AntennaPattern pencil(); - static AntennaPattern gaussian(Numeric zenith_std, Numeric azimuth_std); - static AntennaPattern gaussian_fwhm(Numeric zenith_fwhm, - Numeric azimuth_fwhm); - static AntennaPattern lookup(const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - const Matrix& response_lookup); - static AntennaPattern lookup(const ZenGrid& zenith_grid, - const AziGrid& azimuth_grid, - const Matrix& response_lookup); - - void set_pencil_beam(); - void set_gaussian(Numeric zenith_std, Numeric azimuth_std); - void set_gaussian_fwhm(Numeric zenith_fwhm, Numeric azimuth_fwhm); - void set_lookup(const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - ConstMatrixView response_lookup); - void set_lookup(const ZenGrid& zenith_grid, - const AziGrid& azimuth_grid, - ConstMatrixView response_lookup); - - [[nodiscard]] Numeric operator()(Numeric delta_zenith, - Numeric delta_azimuth) const; - - [[nodiscard]] AntennaPatternGriddedField response( - const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid) const; - [[nodiscard]] AntennaPatternGriddedField normalized_response( - const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid) const; - - [[nodiscard]] AntennaPatternGriddedField raw_sensor( - const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const; - [[nodiscard]] AntennaPatternGriddedField normalized_raw_sensor( - const AscendingGrid& dzen_grid, const AscendingGrid& dazi_grid) const; + AntennaPatternField data; // center at [0, 0] + + //! Maps the local antenna pattern to global LOS values around a new bore LOS. + //! + //! Local zenith 0 points along the bore. Local azimuth 0 points toward + //! increasing global zenith, and local azimuth 90 points toward increasing + //! global azimuth. + [[nodiscard]] std::vector> operator()( + Vector2 bore_los) const; +}; + +struct PencilBeamAntenna final : AntennaPattern { + PencilBeamAntenna(Stokvec weight = {1.0, 0.0, 0.0, 0.0}); +}; + +struct GaussianAntenna final : AntennaPattern { + GaussianAntenna(ZenGrid zen_grid, + AziGrid azi_grid, + Numeric zenith_std, + Numeric azimuth_std, + Stokvec weight = {1.0, 0.0, 0.0, 0.0}); }; } // namespace sensor +// AntennaPattern format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorAntennaPattern"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// PencilBeamAntenna format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorPencilBeamAntenna"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// GaussianAntenna format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorGaussianAntenna"; +}; + template <> -struct std::formatter { - format_tags tags{}; - - [[nodiscard]] constexpr auto& inner_fmt() { return *this; } - [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } - - constexpr std::format_parse_context::iterator parse( - std::format_parse_context& ctx) { - return parse_format_tags(tags, ctx); - } - - template - FmtContext::iterator format(const sensor::AntennaPattern& v, - FmtContext& ctx) const { - return tags.format( - ctx, - "type="sv, - v.type, - tags.sep(), - "sigma_zenith="sv, - v.sigma_zenith, - tags.sep(), - "sigma_azimuth="sv, - v.sigma_azimuth, - tags.sep(), - "lookup_shape="sv, - std::array{v.lookup_response.nrows(), v.lookup_response.ncols()}); - } +struct xml_io_stream_aggregate { + static constexpr bool value = true; }; \ No newline at end of file diff --git a/src/core/sensor/frequency_bandpass_filters.cpp b/src/core/sensor/frequency_bandpass_filters.cpp index 1bd27bb9a1..af540608b4 100644 --- a/src/core/sensor/frequency_bandpass_filters.cpp +++ b/src/core/sensor/frequency_bandpass_filters.cpp @@ -10,9 +10,7 @@ namespace sensor { namespace { constexpr Numeric response_eps = 64 * std::numeric_limits::epsilon(); -Numeric scale(Numeric x) { - return std::max(1.0, std::abs(x)); -} +Numeric scale(Numeric x) { return std::max(1.0, std::abs(x)); } bool is_close(Numeric a, Numeric b) { return std::abs(a - b) <= response_eps * std::max(scale(a), scale(b)); @@ -23,7 +21,7 @@ Numeric sample_filter(const SortedGriddedField1& filter, Numeric f) { if (grid.empty() or f < grid.front() or f > grid.back()) return 0.0; - auto it = std::lower_bound(grid.begin(), grid.end(), f); + auto it = stdr::lower_bound(grid, f); if (it == grid.begin()) return filter.data.front(); if (it == grid.end()) return filter.data.back(); @@ -50,21 +48,22 @@ void add_support_points(std::vector& points, points.push_back(low); if (not is_close(low, high)) points.push_back(high); - auto lower = std::lower_bound(grid.begin(), grid.end(), low); - auto upper = std::upper_bound(grid.begin(), grid.end(), high); + auto lower = stdr::lower_bound(grid, low); + auto upper = stdr::upper_bound(grid, high); for (auto it = lower; it != upper; ++it) points.push_back(*it); } void sort_unique(std::vector& points) { - std::sort(points.begin(), points.end()); - points.erase(std::unique(points.begin(), points.end(), [](Numeric a, Numeric b) { - return is_close(a, b); - }), + stdr::sort(points); + points.erase(std::unique(points.begin(), + points.end(), + [](Numeric a, Numeric b) { return is_close(a, b); }), points.end()); } -SortedGriddedField1 make_filter(const std::vector>& samples, - std::string_view name) { +SortedGriddedField1 make_filter( + const std::vector>& samples, + std::string_view name) { std::vector grid(samples.size()); Vector data(samples.size()); @@ -76,7 +75,7 @@ SortedGriddedField1 make_filter(const std::vector>& return {.data_name = String{name}, .data = std::move(data), .grid_names = std::array{"frequency"s}, - .grids = std::array{AscendingGrid{grid}}}; + .grids = std::array{AscendingGrid{grid}}}; } } // namespace @@ -104,22 +103,26 @@ FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( filters.reserve(channels.size()); for (Size ichan = 0; ichan < channels.size(); ichan++) { - const auto& channel = channels[ichan]; + const auto& channel = channels[ichan]; const auto& channel_grid = channel.freq_grid(); std::vector> samples; for (const auto& path : range.paths()) { if (channel_grid.empty()) continue; - const Numeric local_low = std::max(path.local_range[0], channel_grid.front()); - const Numeric local_high = std::min(path.local_range[1], channel_grid.back()); + const Numeric local_low = + std::max(path.local_range[0], channel_grid.front()); + const Numeric local_high = + std::min(path.local_range[1], channel_grid.back()); - if (local_low > local_high and not is_close(local_low, local_high)) continue; + if (local_low > local_high and not is_close(local_low, local_high)) + continue; std::vector local_points; add_support_points(local_points, channel_grid, local_low, local_high); for (const auto& filter : path.filters) { - add_support_points(local_points, filter.grid<0>(), local_low, local_high); + add_support_points( + local_points, filter.grid<0>(), local_low, local_high); } sort_unique(local_points); @@ -138,7 +141,8 @@ FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( std::vector> combined; combined.reserve(samples.size()); for (const auto& sample : samples) { - if (combined.empty() or not is_close(combined.back().first, sample.first)) { + if (combined.empty() or + not is_close(combined.back().first, sample.first)) { combined.push_back(sample); } else { combined.back().second += sample.second; diff --git a/src/core/sensor/frequency_bandpass_filters.h b/src/core/sensor/frequency_bandpass_filters.h index 1f0ad0c001..3f2b0df1e1 100644 --- a/src/core/sensor/frequency_bandpass_filters.h +++ b/src/core/sensor/frequency_bandpass_filters.h @@ -32,3 +32,37 @@ struct FrequencyRangeBandpassFilter final : BandpassFilter { const std::vector& channels); }; } // namespace sensor + +// BandpassFilter format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorBandpassFilter"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// FrequencyRangeBandpassFilter format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorFrequencyRangeBandpassFilter"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; diff --git a/src/core/sensor/frequency_channel_selection.h b/src/core/sensor/frequency_channel_selection.h index 60701ee1ff..cb93d1317b 100644 --- a/src/core/sensor/frequency_channel_selection.h +++ b/src/core/sensor/frequency_channel_selection.h @@ -1,6 +1,9 @@ #pragma once #include +#include + +#include "matpack_mdspan_helpers_gridded_data_t.h" namespace sensor { //! Free-form channel struct. Others inherit from this. @@ -45,3 +48,71 @@ struct GaussianChannel final : Channel { GaussianChannel(Numeric std, Size N, Size M); // +-M*std, f0 = 0 }; } // namespace sensor + +// Channel format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorChannel"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// BoxChannel format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorBoxChannel"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// DiracChannel format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorDiracChannel"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// GaussianChannel format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorGaussianChannel"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; diff --git a/src/core/sensor/frequency_range_selection.h b/src/core/sensor/frequency_range_selection.h index 887def4f30..0ee06425ea 100644 --- a/src/core/sensor/frequency_range_selection.h +++ b/src/core/sensor/frequency_range_selection.h @@ -71,26 +71,53 @@ struct HeterodyneFrequencyRange final : FrequencyRange { }; } // namespace sensor +// FrequencyRange format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorFrequencyRange"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// HeterodyneFrequencyRange format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorHeterodyneFrequencyRange"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; + +// FrequencyResponsePath format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + template <> -struct std::formatter { - format_tags tags{}; - - [[nodiscard]] constexpr auto& inner_fmt() { return *this; } - [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } - - constexpr std::format_parse_context::iterator parse( - std::format_parse_context& ctx) { - return parse_format_tags(tags, ctx); - } - - template - FmtContext::iterator format(const sensor::FrequencyRange& v, - FmtContext& ctx) const { - return tags.format( - ctx, "GLOBALS: "sv, v.global_ranges, "; LOCALS : "sv, v.local_ranges); - } +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorFrequencyResponsePath"; }; template <> -struct std::formatter final - : std::formatter {}; \ No newline at end of file +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; \ No newline at end of file diff --git a/src/core/util/aggregate_helper.h b/src/core/util/aggregate_helper.h new file mode 100644 index 0000000000..c68cc0961b --- /dev/null +++ b/src/core/util/aggregate_helper.h @@ -0,0 +1,216 @@ +#pragma once + +#include +#include + +template +concept aggregate_0 = std::is_aggregate_v and requires { T{}; }; + +template +concept aggregate_1 = aggregate_0 and requires { T({}); }; + +template +concept aggregate_2 = aggregate_1 and requires { T({}, {}); }; + +template +concept aggregate_3 = aggregate_2 and requires { T({}, {}, {}); }; + +template +concept aggregate_4 = aggregate_3 and requires { T({}, {}, {}, {}); }; + +template +concept aggregate_5 = aggregate_4 and requires { T({}, {}, {}, {}, {}); }; + +template +concept aggregate_6 = + aggregate_5 and requires { T({}, {}, {}, {}, {}, {}); }; + +template +concept aggregate_7 = + aggregate_6 and requires { T({}, {}, {}, {}, {}, {}, {}); }; + +template +concept aggregate_8 = + aggregate_7 and requires { T({}, {}, {}, {}, {}, {}, {}, {}); }; + +template +concept aggregate_9 = + aggregate_8 and requires { T({}, {}, {}, {}, {}, {}, {}, {}, {}); }; + +template +concept aggregate_10 = + aggregate_9 and requires { T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}); }; + +template +concept aggregate_11 = aggregate_10 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_12 = aggregate_11 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_13 = aggregate_12 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_14 = aggregate_13 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_15 = aggregate_14 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_16 = aggregate_15 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_17 = aggregate_16 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_18 = aggregate_17 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_19 = aggregate_18 and requires { + T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); +}; + +template +concept aggregate_20 = aggregate_19 and requires { + T({}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}); +}; + +auto as_tuple(aggregate_0 auto&) { return std::tuple<>(); } + +auto as_tuple(aggregate_1 auto& x) { + auto&& [a] = x; + return std::tie(a); +} + +auto as_tuple(aggregate_2 auto& x) { + auto&& [a, b] = x; + return std::tie(a, b); +} + +auto as_tuple(aggregate_3 auto& x) { + auto&& [a, b, c] = x; + return std::tie(a, b, c); +} + +auto as_tuple(aggregate_4 auto& x) { + auto&& [a, b, c, d] = x; + return std::tie(a, b, c, d); +} + +auto as_tuple(aggregate_5 auto& x) { + auto&& [a, b, c, d, e] = x; + return std::tie(a, b, c, d, e); +} + +auto as_tuple(aggregate_6 auto& x) { + auto&& [a, b, c, d, e, f] = x; + return std::tie(a, b, c, d, e, f); +} + +auto as_tuple(aggregate_7 auto& x) { + auto&& [a, b, c, d, e, f, g] = x; + return std::tie(a, b, c, d, e, f, g); +} + +auto as_tuple(aggregate_8 auto& x) { + auto&& [a, b, c, d, e, f, g, h] = x; + return std::tie(a, b, c, d, e, f, g, h); +} + +auto as_tuple(aggregate_9 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i] = x; + return std::tie(a, b, c, d, e, f, g, h, i); +} + +auto as_tuple(aggregate_10 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j); +} + +auto as_tuple(aggregate_11 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k); +} + +auto as_tuple(aggregate_12 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l); +} + +auto as_tuple(aggregate_13 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m); +} + +auto as_tuple(aggregate_14 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n); +} + +auto as_tuple(aggregate_15 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o); +} + +auto as_tuple(aggregate_16 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p); +} + +auto as_tuple(aggregate_17 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q); +} + +auto as_tuple(aggregate_18 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r); +} + +auto as_tuple(aggregate_19 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s); +} + +auto as_tuple(aggregate_20 auto& x) { + auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t] = x; + return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t); +} + +template +concept arts_aggregate = requires(T a) { as_tuple(a); }; diff --git a/src/core/util/format_tags.h b/src/core/util/format_tags.h index be49ba8143..345df37e8f 100644 --- a/src/core/util/format_tags.h +++ b/src/core/util/format_tags.h @@ -17,6 +17,8 @@ #include #include +#include "aggregate_helper.h" + using namespace std::literals; template @@ -604,3 +606,33 @@ struct std::formatter> { return tags.format(ctx, ""); } }; + +template +struct format_tag_aggregate { + constexpr static bool value = false; +}; + +template +constexpr bool format_tag_aggregate_v = format_tag_aggregate::value; + +template +concept format_tag_aggratable = format_tag_aggregate_v and arts_aggregate; + +template +struct std::formatter { + format_tags tags; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + + [[nodiscard]] constexpr const auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const T& v, FmtContext& ctx) const { + return tags.format(ctx, as_tuple(v)); + } +}; diff --git a/src/core/xml/xml_io_stream_aggregate.h b/src/core/xml/xml_io_stream_aggregate.h index 3b9a121096..4797bf4dd1 100644 --- a/src/core/xml/xml_io_stream_aggregate.h +++ b/src/core/xml/xml_io_stream_aggregate.h @@ -1,218 +1,10 @@ #pragma once -#include +#include #include "xml_io_base.h" #include "xml_io_stream.h" -template -concept aggregate_0 = std::is_aggregate_v and requires { T{}; }; - -template -concept aggregate_1 = aggregate_0 and requires { T({}); }; - -template -concept aggregate_2 = aggregate_1 and requires { T({}, {}); }; - -template -concept aggregate_3 = aggregate_2 and requires { T({}, {}, {}); }; - -template -concept aggregate_4 = aggregate_3 and requires { T({}, {}, {}, {}); }; - -template -concept aggregate_5 = aggregate_4 and requires { T({}, {}, {}, {}, {}); }; - -template -concept aggregate_6 = - aggregate_5 and requires { T({}, {}, {}, {}, {}, {}); }; - -template -concept aggregate_7 = - aggregate_6 and requires { T({}, {}, {}, {}, {}, {}, {}); }; - -template -concept aggregate_8 = - aggregate_7 and requires { T({}, {}, {}, {}, {}, {}, {}, {}); }; - -template -concept aggregate_9 = - aggregate_8 and requires { T({}, {}, {}, {}, {}, {}, {}, {}, {}); }; - -template -concept aggregate_10 = - aggregate_9 and requires { T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}); }; - -template -concept aggregate_11 = aggregate_10 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_12 = aggregate_11 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_13 = aggregate_12 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_14 = aggregate_13 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_15 = aggregate_14 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_16 = aggregate_15 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_17 = aggregate_16 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_18 = aggregate_17 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_19 = aggregate_18 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); -}; - -template -concept aggregate_20 = aggregate_19 and requires { - T({}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}); -}; - -auto as_tuple(aggregate_0 auto&) { return std::tuple<>(); } -auto as_tuple(aggregate_1 auto& x) { - auto&& [a] = x; - return std::tie(a); -} - -auto as_tuple(aggregate_2 auto& x) { - auto&& [a, b] = x; - return std::tie(a, b); -} - -auto as_tuple(aggregate_3 auto& x) { - auto&& [a, b, c] = x; - return std::tie(a, b, c); -} - -auto as_tuple(aggregate_4 auto& x) { - auto&& [a, b, c, d] = x; - return std::tie(a, b, c, d); -} - -auto as_tuple(aggregate_5 auto& x) { - auto&& [a, b, c, d, e] = x; - return std::tie(a, b, c, d, e); -} - -auto as_tuple(aggregate_6 auto& x) { - auto&& [a, b, c, d, e, f] = x; - return std::tie(a, b, c, d, e, f); -} - -auto as_tuple(aggregate_7 auto& x) { - auto&& [a, b, c, d, e, f, g] = x; - return std::tie(a, b, c, d, e, f, g); -} - -auto as_tuple(aggregate_8 auto& x) { - auto&& [a, b, c, d, e, f, g, h] = x; - return std::tie(a, b, c, d, e, f, g, h); -} - -auto as_tuple(aggregate_9 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i] = x; - return std::tie(a, b, c, d, e, f, g, h, i); -} - -auto as_tuple(aggregate_10 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j); -} - -auto as_tuple(aggregate_11 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k); -} - -auto as_tuple(aggregate_12 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l); -} - -auto as_tuple(aggregate_13 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m); -} - -auto as_tuple(aggregate_14 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n); -} - -auto as_tuple(aggregate_15 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o); -} - -auto as_tuple(aggregate_16 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p); -} - -auto as_tuple(aggregate_17 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q); -} - -auto as_tuple(aggregate_18 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r); -} - -auto as_tuple(aggregate_19 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s); -} - -auto as_tuple(aggregate_20 auto& x) { - auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t] = x; - return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t); -} - template struct xml_io_stream_aggregate { constexpr static bool value = false; @@ -223,7 +15,7 @@ constexpr bool xml_io_stream_aggregate_v = xml_io_stream_aggregate::value; template concept xml_io_aggregratable = - xml_io_stream_aggregate_v and requires(T a) { as_tuple(a); }; + xml_io_stream_aggregate_v and arts_aggregate; template struct xml_io_stream { @@ -238,8 +30,8 @@ struct xml_io_stream { const T& t, bofstream* pbofs = nullptr, std::string_view name = ""sv) { - XMLTag tag(type_name, "name", name); - tag.write_to_stream(os); + XMLTag tag(type_name, "name", name); + tag.write_to_stream(os); std::apply( [&os, &pbofs](const Ts&... v) { @@ -247,7 +39,7 @@ struct xml_io_stream { }, as_tuple(t)); - tag.write_to_end_stream(os); + tag.write_to_end_stream(os); } static void read(std::istream& is, T& t, bifstream* pbifs = nullptr) try { diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 2f363c760b..5d9918fabc 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -1,5 +1,5 @@ -#include #include +#include #include #include #include @@ -512,137 +512,6 @@ Numeric, Vector, or Matrix "Construct a zero-centered Gaussian channel on ``+/- m * std``."); generic_interface(sgauss); - auto sap = py::class_(m, "SensorAntennaPattern"); - sap.doc() = - "A local 2D antenna pattern on zenith and azimuth offsets from boresight."; - sap.def(py::init<>(), - "Construct a pencil-beam antenna pattern.") - .def_static("pencil", - &sensor::AntennaPattern::pencil, - "Construct a pencil-beam antenna pattern.") - .def_static("gaussian", - &sensor::AntennaPattern::gaussian, - "zenith_std"_a, - "azimuth_std"_a, - "Construct a Gaussian antenna pattern from zenith and azimuth standard deviations.") - .def_static("gaussian_fwhm", - &sensor::AntennaPattern::gaussian_fwhm, - "zenith_fwhm"_a, - "azimuth_fwhm"_a, - "Construct a Gaussian antenna pattern from zenith and azimuth FWHM values.") - .def_static("lookup", - [](const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - const Matrix& response_lookup) { - return sensor::AntennaPattern::lookup( - zenith_grid, azimuth_grid, response_lookup); - }, - "zenith_grid"_a, - "azimuth_grid"_a, - "response_lookup"_a, - "Construct an antenna pattern from a lookup table on local zenith and azimuth grids.") - .def_static("lookup", - [](const ZenGrid& zenith_grid, - const AziGrid& azimuth_grid, - const Matrix& response_lookup) { - return sensor::AntennaPattern::lookup( - zenith_grid, azimuth_grid, response_lookup); - }, - "zenith_grid"_a, - "azimuth_grid"_a, - "response_lookup"_a, - "Construct an antenna pattern from lookup tables defined on typed zenith and azimuth angle grids.") - .def("set_pencil_beam", - &sensor::AntennaPattern::set_pencil_beam, - "Reset the antenna pattern to a pencil beam.") - .def("set_gaussian", - &sensor::AntennaPattern::set_gaussian, - "zenith_std"_a, - "azimuth_std"_a, - "Set the antenna pattern to a Gaussian described by standard deviations.") - .def("set_gaussian_fwhm", - &sensor::AntennaPattern::set_gaussian_fwhm, - "zenith_fwhm"_a, - "azimuth_fwhm"_a, - "Set the antenna pattern to a Gaussian described by FWHM values.") - .def("set_lookup", - [](sensor::AntennaPattern& self, - const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - const Matrix& response_lookup) { - self.set_lookup(zenith_grid, azimuth_grid, response_lookup); - }, - "zenith_grid"_a, - "azimuth_grid"_a, - "response_lookup"_a, - "Set the antenna pattern from a lookup table.") - .def("set_lookup", - [](sensor::AntennaPattern& self, - const ZenGrid& zenith_grid, - const AziGrid& azimuth_grid, - const Matrix& response_lookup) { - self.set_lookup(zenith_grid, azimuth_grid, response_lookup); - }, - "zenith_grid"_a, - "azimuth_grid"_a, - "response_lookup"_a, - "Set the antenna pattern from typed zenith and azimuth angle grids.") - .def("__call__", - [](const sensor::AntennaPattern& self, - Numeric delta_zenith, - Numeric delta_azimuth) { - return self(delta_zenith, delta_azimuth); - }, - "delta_zenith"_a, - "delta_azimuth"_a, - "Evaluate the antenna pattern at one local zenith and azimuth offset.") - .def("response", - [](const sensor::AntennaPattern& self, - const AscendingGrid& zenith_grid, - const AscendingGrid& azimuth_grid, - bool normalize) { - return normalize ? self.normalized_response(zenith_grid, azimuth_grid) - : self.response(zenith_grid, azimuth_grid); - }, - "zenith_grid"_a, - "azimuth_grid"_a, - "normalize"_a = false, - "Evaluate the antenna pattern on a 2D local angular grid.") - .def("raw_sensor", - [](const sensor::AntennaPattern& self, - const AscendingGrid& dzen_grid, - const AscendingGrid& dazi_grid, - bool normalize) { - return normalize ? self.normalized_raw_sensor(dzen_grid, dazi_grid) - : self.raw_sensor(dzen_grid, dazi_grid); - }, - "dzen_grid"_a, - "dazi_grid"_a, - "normalize"_a = false, - R"(Evaluate the antenna pattern on local angular offsets and return a raw-sensor gridded field. - - The returned field uses grid names ``dzen`` and ``dazi`` and can be passed to - ``measurement_sensorAddRawSensor``.)") - .def_ro("type", - &sensor::AntennaPattern::type, - "Antenna pattern family.") - .def_ro("sigma_zenith", - &sensor::AntennaPattern::sigma_zenith, - "Zenith standard deviation used by Gaussian patterns.") - .def_ro("sigma_azimuth", - &sensor::AntennaPattern::sigma_azimuth, - "Azimuth standard deviation used by Gaussian patterns.") - .def_ro("lookup_zenith_grid", - &sensor::AntennaPattern::lookup_zenith_grid, - "Zenith grid used by lookup antenna patterns.") - .def_ro("lookup_azimuth_grid", - &sensor::AntennaPattern::lookup_azimuth_grid, - "Azimuth grid used by lookup antenna patterns.") - .def_ro("lookup_response", - &sensor::AntennaPattern::lookup_response, - "Response matrix used by lookup antenna patterns."); - generic_interface(sap); - auto shdfr = py::class_( m, "SensorHeterodyneFrequencyRange"); shdfr.def(py::init<>(), diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 7c3a86da3c..c7308c0c92 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -171,6 +171,12 @@ add_executable(test_rng test_rng.cc) target_link_libraries(test_rng PUBLIC artscore) target_include_directories(test_rng PRIVATE ${ARTS_SOURCE_DIR}/src) +# #### +add_executable(test_antenna_pattern test_antenna_pattern.cc) +target_link_libraries(test_antenna_pattern PUBLIC artsworkspace) +add_test(NAME "cpp.fast.test_antenna_pattern" COMMAND test_antenna_pattern) +add_dependencies(check-deps test_antenna_pattern) + # #### add_executable(test_rtepack test_rtepack.cc) target_link_libraries(test_rtepack PUBLIC artstime rtepack rng) diff --git a/src/tests/test_antenna_pattern.cc b/src/tests/test_antenna_pattern.cc new file mode 100644 index 0000000000..5e64e11b19 --- /dev/null +++ b/src/tests/test_antenna_pattern.cc @@ -0,0 +1,138 @@ +#include + +#include +#include + +namespace { +Numeric angle_error(Numeric actual, Numeric expected) { + return std::abs(std::remainder(actual - expected, 360.0)); +} + +void assert_los(Vector2 actual, + Vector2 expected, + Numeric tol, + std::string_view name) { + ARTS_USER_ERROR_IF(std::abs(actual[0] - expected[0]) > tol or + angle_error(actual[1], expected[1]) > tol, + "{} mismatch: actual {} expected {}", + name, + actual, + expected) +} + +void assert_stokvec(Stokvec actual, + Stokvec expected, + Numeric tol, + std::string_view name) { + for (Size i = 0; i < 4; ++i) { + ARTS_USER_ERROR_IF(std::abs(actual[i] - expected[i]) > tol, + "{} mismatch at {}: actual {} expected {}", + name, + i, + actual, + expected) + } +} + +sensor::AntennaPattern pattern(ZenGrid zen_grid, AziGrid azi_grid) { + sensor::AntennaPattern out; + out.data.grid<0>() = std::move(zen_grid); + out.data.grid<1>() = std::move(azi_grid); + out.data.resize(out.data.grid<0>().size(), out.data.grid<1>().size()); + + for (Size izen = 0; izen < out.data.grid<0>().size(); ++izen) { + for (Size iazi = 0; iazi < out.data.grid<1>().size(); ++iazi) { + out.data[izen, iazi] = {Numeric(10 * izen + iazi + 1), 0.0, 0.0, 0.0}; + } + } + + return out; +} + +void test_local_wraparound_maps_across_bore() { + auto ant = pattern(ZenGrid{{1.0}}, AziGrid{{0.0, 180.0}}); + + const auto samples = ant({45.0, 30.0}); + const auto first_weight = ant.data[0, 0]; + const auto second_weight = ant.data[0, 1]; + + ARTS_USER_ERROR_IF( + samples.size() != 2, "Expected 2 antenna samples, got {}", samples.size()) + ARTS_USER_ERROR_IF(samples[0].first != first_weight, + "First Stokvec changed during LOS mapping") + ARTS_USER_ERROR_IF(samples[1].first != second_weight, + "Second Stokvec changed during LOS mapping") + + assert_los(samples[0].second, {46.0, 30.0}, 1e-12, "local [1, 0]"); + assert_los(samples[1].second, {44.0, 30.0}, 1e-12, "local [1, 180]"); +} + +void test_bore_at_zenith_keeps_defined_local_azimuth() { + auto ant = pattern(ZenGrid{{1.0}}, AziGrid{{0.0, 90.0, 180.0, 270.0}}); + + const auto samples = ant({0.0, 15.0}); + + ARTS_USER_ERROR_IF( + samples.size() != 4, "Expected 4 antenna samples, got {}", samples.size()) + + assert_los(samples[0].second, {1.0, 15.0}, 1e-12, "pole local [1, 0]"); + assert_los(samples[1].second, {1.0, 105.0}, 1e-12, "pole local [1, 90]"); + assert_los(samples[2].second, {1.0, 195.0}, 1e-12, "pole local [1, 180]"); + assert_los(samples[3].second, {1.0, 285.0}, 1e-12, "pole local [1, 270]"); +} + +void test_pencil_beam_defaults_and_custom_weight() { + const sensor::PencilBeamAntenna def{}; + + ARTS_USER_ERROR_IF( + def.data.grid<0>().size() != 1 or def.data.grid<1>().size() != 1, + "Pencil beam must be 1x1") + ARTS_USER_ERROR_IF( + def.data.grid<0>()[0] != 0.0 or def.data.grid<1>()[0] != 0.0, + "Pencil beam grid must be centered at [0, 0]") + assert_stokvec( + def.data[0, 0], {1.0, 0.0, 0.0, 0.0}, 0.0, "default pencil beam weight"); + + const auto def_samples = def({45.0, 30.0}); + ARTS_USER_ERROR_IF(def_samples.size() != 1, + "Default pencil beam should return one sample") + assert_stokvec(def_samples[0].first, + {1.0, 0.0, 0.0, 0.0}, + 0.0, + "default pencil beam mapped weight"); + assert_los( + def_samples[0].second, {45.0, 30.0}, 1e-12, "default pencil beam LOS"); + + const Stokvec custom_weight{0.5, 0.25, 0.0, 0.0}; + const sensor::PencilBeamAntenna custom{custom_weight}; + assert_stokvec( + custom.data[0, 0], custom_weight, 0.0, "custom pencil beam weight"); +} + +void test_gaussian_initialization_uses_antenna_frame_offsets() { + const Stokvec peak_weight{2.0, 1.0, 0.0, 0.0}; + const sensor::GaussianAntenna ant{ + ZenGrid{{0.0, 1.0}}, AziGrid{{0.0, 180.0}}, 1.0, 2.0, peak_weight}; + + assert_stokvec(ant.data[0, 0], peak_weight, 1e-12, "gaussian peak weight"); + + const Numeric scale = std::exp(-0.5); + const Stokvec expected = scale * peak_weight; + assert_stokvec(ant.data[1, 0], expected, 1e-12, "gaussian local [1, 0]"); + assert_stokvec(ant.data[1, 1], expected, 1e-12, "gaussian local [1, 180]"); + + const sensor::GaussianAntenna def{ZenGrid{{0.0}}, AziGrid{{0.0}}, 1.0, 1.0}; + assert_stokvec(def.data[0, 0], + {1.0, 0.0, 0.0, 0.0}, + 1e-12, + "default gaussian peak weight"); +} +} // namespace + +int main() { + test_local_wraparound_maps_across_bore(); + test_bore_at_zenith_keeps_defined_local_azimuth(); + test_pencil_beam_defaults_and_custom_weight(); + test_gaussian_initialization_uses_antenna_frame_offsets(); + return 0; +} \ No newline at end of file diff --git a/tests/core/sensor/antenna_pattern_response.py b/tests/core/sensor/antenna_pattern_response.py deleted file mode 100644 index e1051be4f9..0000000000 --- a/tests/core/sensor/antenna_pattern_response.py +++ /dev/null @@ -1,174 +0,0 @@ -import numpy as np -import pyarts3 as pyarts - - -def assert_close(name, got, expected, atol=1e-10): - got = np.asarray(got, dtype=float) - expected = np.asarray(expected, dtype=float) - print(f"{name}: got = {got}") - print(f"{name}: expected = {expected}") - np.testing.assert_allclose(got, expected, atol=atol, rtol=0.0) - - -def test_pencil_pattern(): - pattern = pyarts.arts.SensorAntennaPattern.pencil() - dzen = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) - dazi = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) - raw = pattern.raw_sensor(dzen, dazi) - - assert_close( - "pencil raw sensor", - np.array(raw.data), - np.array( - [ - [0.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - [0.0, 0.0, 0.0], - ] - ), - ) - - -def test_gaussian_pattern_and_fwhm_equivalence(): - std_zenith = 1.0 - std_azimuth = 2.0 - fwhm_factor = 2.0 * np.sqrt(2.0 * np.log(2.0)) - - pattern_std = pyarts.arts.SensorAntennaPattern.gaussian( - std_zenith, std_azimuth - ) - pattern_fwhm = pyarts.arts.SensorAntennaPattern.gaussian_fwhm( - fwhm_factor * std_zenith, fwhm_factor * std_azimuth - ) - - assert_close( - "gaussian point evaluation", - np.array([pattern_std(1.0, 2.0)]), - np.array([np.exp(-1.0)]), - ) - - dzen = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) - dazi = pyarts.arts.AscendingGrid(np.array([-2.0, 0.0, 2.0])) - - std_raw = pattern_std.raw_sensor(dzen, dazi) - fwhm_raw = pattern_fwhm.raw_sensor(dzen, dazi) - normalized_raw = pattern_std.raw_sensor(dzen, dazi, normalize=True) - - expected_raw = np.array( - [ - [np.exp(-1.0), np.exp(-0.5), np.exp(-1.0)], - [np.exp(-0.5), 1.0, np.exp(-0.5)], - [np.exp(-1.0), np.exp(-0.5), np.exp(-1.0)], - ] - ) - expected_normalized = expected_raw / expected_raw.sum() - - assert_close("gaussian raw sensor", np.array(std_raw.data), expected_raw) - assert_close( - "gaussian vs fwhm raw sensor", - np.array(fwhm_raw.data), - expected_raw, - ) - assert_close( - "gaussian normalized raw sensor", - np.array(normalized_raw.data), - expected_normalized, - ) - - -def test_lookup_pattern(): - pattern = pyarts.arts.SensorAntennaPattern.lookup( - pyarts.arts.AscendingGrid(np.array([0.0, 1.0])), - pyarts.arts.AscendingGrid(np.array([0.0, 1.0])), - pyarts.arts.Matrix(np.array([[1.0, 2.0], [3.0, 4.0]])), - ) - - assert_close( - "lookup bilinear center", - np.array([pattern(0.5, 0.5)]), - np.array([2.5]), - ) - assert_close( - "lookup outside support", - np.array([pattern(2.0, 2.0)]), - np.array([0.0]), - ) - - -def test_lookup_pattern_with_typed_angular_grids(): - pattern = pyarts.arts.SensorAntennaPattern.lookup( - pyarts.arts.ZenGrid(np.array([0.0])), - pyarts.arts.AziGrid(np.array([0.0, 120.0, 240.0])), - pyarts.arts.Matrix(np.array([[1.0, 2.0, 3.0]])), - ) - - assert_close( - "typed angular-grid lookup exact point", - np.array([pattern(0.0, 120.0)]), - np.array([2.0]), - ) - assert_close( - "typed angular-grid lookup interpolated midpoint", - np.array([pattern(0.0, 60.0)]), - np.array([1.5]), - ) - assert_close( - "typed angular-grid lookup outside support", - np.array([pattern(0.0, 300.0)]), - np.array([0.0]), - ) - - -def test_pattern_to_sensor_via_raw_sensor(): - pattern = pyarts.arts.SensorAntennaPattern.gaussian(1.0, 2.0) - dzen = pyarts.arts.AscendingGrid(np.array([-1.0, 0.0, 1.0])) - dazi = pyarts.arts.AscendingGrid(np.array([-2.0, 0.0, 2.0])) - raw = pattern.raw_sensor(dzen, dazi, normalize=True) - - ws = pyarts.Workspace() - ws.measurement_sensor = pyarts.arts.ArrayOfSensorObsel() - ws.measurement_sensor_meta = pyarts.arts.ArrayOfSensorMetaInfo() - ws.measurement_sensorAddRawSensor( - freq_grid=pyarts.arts.AscendingGrid(np.array([100.0, 101.0])), - pos=pyarts.arts.Vector3(np.array([600000.0, 0.0, 0.0])), - los=pyarts.arts.Vector2(np.array([180.0, 0.0])), - raw_sensor_perturbation=raw, - normalize=0, - ) - - expected_weight_vector = np.array(raw.data).reshape(-1) - - assert_close( - "sensor count from raw antenna pattern", - np.array([len(ws.measurement_sensor)]), - np.array([2.0]), - ) - assert_close( - "sensor meta count from raw antenna pattern", - np.array([len(ws.measurement_sensor_meta)]), - np.array([1.0]), - ) - assert_close( - "sensor poslos size from raw antenna pattern", - np.array([len(ws.measurement_sensor[0].poslos)]), - np.array([9.0]), - ) - assert_close( - "first sensor poslos entry", - np.array(ws.measurement_sensor[0].poslos[0]), - np.array([600000.0, 0.0, 0.0, 179.0, -2.0]), - ) - assert_close( - "sensor weights from raw antenna pattern", - np.array(ws.measurement_sensor[0].weight_matrix.reduce(along_freq=True)), - expected_weight_vector, - ) - - -test_pencil_pattern() -test_gaussian_pattern_and_fwhm_equivalence() -test_lookup_pattern() -test_lookup_pattern_with_typed_angular_grids() -test_pattern_to_sensor_via_raw_sensor() - -print("\nAll antenna pattern checks passed.") \ No newline at end of file From f03a91003d338d3ceaef84aaf4ff6ca70da16121 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 27 Apr 2026 15:49:39 +0900 Subject: [PATCH 04/21] Tests --- src/python_interface/py_sensor.cpp | 60 ++++++++++++++++++ tests/core/sensor/antenna_pattern_response.py | 63 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 tests/core/sensor/antenna_pattern_response.py diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 5d9918fabc..800325e9b2 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -19,6 +19,7 @@ #include #include "hpy_arts.h" +#include "hpy_matpack.h" #include "hpy_numpy.h" #include "hpy_vector.h" @@ -432,6 +433,65 @@ Numeric, Vector, or Matrix generic_interface(a2); vector_interface(a2); + py::class_ sapf(m, "SensorAntennaPatternField"); + sapf.doc() = + "A 2D gridded antenna response on local zenith and azimuth offsets."; + gridded_data_interface(sapf); + generic_interface(sapf); + + auto sap = py::class_(m, "SensorAntennaPattern"); + sap.doc() = + "Base class for angular antenna responses defined around a bore line of sight."; + sap.def(py::init<>(), "Construct an empty antenna pattern.") + .def( + "__init__", + [](sensor::AntennaPattern* self, + const sensor::AntennaPatternField& response) { + new (self) sensor::AntennaPattern{.data = response}; + }, + "response"_a, + "Construct an antenna pattern from a local zenith/azimuth response field.") + .def_prop_rw( + "response", + [](sensor::AntennaPattern& self) -> sensor::AntennaPatternField& { + return self.data; + }, + [](sensor::AntennaPattern& self, + const sensor::AntennaPatternField& response) { + self.data = response; + }, + py::rv_policy::reference_internal, + "Local antenna response field.\n\n.. :class:`~pyarts3.arts.SensorAntennaPatternField`") + .def( + "__call__", + &sensor::AntennaPattern::operator(), + "bore_los"_a, + R"(Map the local antenna pattern onto global LOS values around ``bore_los``. + + Returns a list of ``(weight, los)`` pairs, where ``weight`` is a :class:`Stokvec` + and ``los`` is the mapped :class:`Vector2`.)"); + generic_interface(sap); + + auto spp = py::class_( + m, "SensorPencilBeamAntenna"); + spp.doc() = "A 1x1 antenna response centered on the bore line of sight."; + spp.def(py::init(), + "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, + "Construct a pencil-beam antenna with one Stokes weight at the bore LOS."); + generic_interface(spp); + + auto sga = py::class_( + m, "SensorGaussianAntenna"); + sga.doc() = "A Gaussian antenna response defined on a local zenith/azimuth grid."; + sga.def(py::init(), + "zen_grid"_a, + "azi_grid"_a, + "zenith_std"_a, + "azimuth_std"_a, + "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, + "Construct a Gaussian antenna response on the supplied local grids."); + generic_interface(sga); + auto sch = py::class_(m, "SensorChannel"); sch.doc() = "Base class for relative spectrometer channel responses."; generic_interface(sch); diff --git a/tests/core/sensor/antenna_pattern_response.py b/tests/core/sensor/antenna_pattern_response.py new file mode 100644 index 0000000000..6f0d7d9807 --- /dev/null +++ b/tests/core/sensor/antenna_pattern_response.py @@ -0,0 +1,63 @@ +import numpy as np +import pyarts3 as pyarts + + +def assert_close(name, got, expected, atol=1e-12): + got = np.asarray(got, dtype=float) + expected = np.asarray(expected, dtype=float) + print(f"{name}: got = {got}") + print(f"{name}: expected = {expected}") + np.testing.assert_allclose(got, expected, atol=atol, rtol=0.0) + + +def test_pencil_beam_binding(): + antenna = pyarts.arts.SensorPencilBeamAntenna() + response = antenna.response + + assert response.ok() + assert_close("pencil zenith grid", response.grids[0], np.array([0.0])) + assert_close("pencil azimuth grid", response.grids[1], np.array([0.0])) + assert_close("pencil response weight", + response.data[0, 0], np.array([1.0, 0.0, 0.0, 0.0])) + + samples = antenna(np.array([45.0, 30.0])) + assert len(samples) == 1 + weight, los = samples[0] + + assert_close("pencil mapped weight", weight, np.array([1.0, 0.0, 0.0, 0.0])) + assert_close("pencil mapped los", los, np.array([45.0, 30.0])) + + +def test_gaussian_binding_and_mapping(): + peak_weight = pyarts.arts.Stokvec([2.0, 1.0, 0.0, 0.0]) + antenna = pyarts.arts.SensorGaussianAntenna( + pyarts.arts.ZenGrid(np.array([0.0, 1.0])), + pyarts.arts.AziGrid(np.array([0.0, 180.0])), + 1.0, + 2.0, + peak_weight, + ) + response = antenna.response + + assert response.ok() + assert_close("gaussian peak weight", response.data[0, 0], np.array(peak_weight)) + + expected_offset_weight = np.exp(-0.5) * np.array(peak_weight) + assert_close("gaussian [1, 0] weight", response.data[1, 0], expected_offset_weight) + assert_close("gaussian [1, 180] weight", + response.data[1, 1], expected_offset_weight) + + samples = antenna(np.array([45.0, 30.0])) + assert len(samples) == 4 + + assert_close("gaussian bore los", samples[0][1], np.array([45.0, 30.0])) + assert_close("gaussian local [1, 0] los", samples[2][1], np.array([46.0, 30.0])) + assert_close("gaussian local [1, 180] los", samples[3][1], np.array([44.0, 30.0])) + assert_close("gaussian local [1, 0] mapped weight", + samples[2][0], expected_offset_weight) + assert_close("gaussian local [1, 180] mapped weight", + samples[3][0], expected_offset_weight) + + +test_pencil_beam_binding() +test_gaussian_binding_and_mapping() From 48142d2ac8304121833d10dbac81313057d3c07c Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 27 Apr 2026 17:37:48 +0900 Subject: [PATCH 05/21] Make simple sensor builder Co-authored-by: Copilot --- src/core/sensor/CMakeLists.txt | 1 + src/core/sensor/antenna_pattern.cpp | 6 +- src/core/sensor/antenna_pattern.h | 2 +- src/core/sensor/frequency_range_selection.h | 2 +- src/core/sensor/sensor_builder.cpp | 118 ++++++++++++++++++++ src/core/sensor/sensor_builder.h | 21 +++- src/python_interface/py_sensor.cpp | 70 ++++++++++++ src/tests/CMakeLists.txt | 6 + src/tests/test_sensor_builder.cc | 80 +++++++++++++ tests/core/sensor/sensor_builder.py | 84 ++++++++++++++ 10 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 src/core/sensor/sensor_builder.cpp create mode 100644 src/tests/test_sensor_builder.cc create mode 100644 tests/core/sensor/sensor_builder.py diff --git a/src/core/sensor/CMakeLists.txt b/src/core/sensor/CMakeLists.txt index 1a443d3381..7e0b2f8af3 100644 --- a/src/core/sensor/CMakeLists.txt +++ b/src/core/sensor/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(sensor STATIC antenna_pattern.cpp obsel.cpp sensor_meta_info.cpp + sensor_builder.cpp frequency_bandpass_filters.cpp frequency_channel_selection.cpp frequency_range_selection.cpp diff --git a/src/core/sensor/antenna_pattern.cpp b/src/core/sensor/antenna_pattern.cpp index fb1a116f1e..8b36cbfceb 100644 --- a/src/core/sensor/antenna_pattern.cpp +++ b/src/core/sensor/antenna_pattern.cpp @@ -86,8 +86,8 @@ struct AntennaBasis { PencilBeamAntenna::PencilBeamAntenna(Stokvec weight) : AntennaPattern({.data_name = "pencil beam"s, .data = StokvecMatrix(1, 1, weight), - .grid_names = {"zenith"s, "azimuth"s}, - .grids = {Vector{0.0}, Vector{0.0}}}) {} + .grid_names = std::array{"zenith"s, "azimuth"s}, + .grids = {ZenGrid{Vector{0.0}}, AziGrid{Vector{0.0}}}}) {} GaussianAntenna::GaussianAntenna(ZenGrid zen_grid, AziGrid azi_grid, @@ -129,4 +129,4 @@ std::vector> AntennaPattern::operator()( static_assert(AntennaPatternSelection); static_assert(AntennaPatternSelection); static_assert(AntennaPatternSelection); -} // namespace sensor \ No newline at end of file +} // namespace sensor diff --git a/src/core/sensor/antenna_pattern.h b/src/core/sensor/antenna_pattern.h index 93acd29f1c..d4fb10586d 100644 --- a/src/core/sensor/antenna_pattern.h +++ b/src/core/sensor/antenna_pattern.h @@ -94,4 +94,4 @@ struct xml_io_stream_name { template <> struct xml_io_stream_aggregate { static constexpr bool value = true; -}; \ No newline at end of file +}; diff --git a/src/core/sensor/frequency_range_selection.h b/src/core/sensor/frequency_range_selection.h index 0ee06425ea..fa0589228d 100644 --- a/src/core/sensor/frequency_range_selection.h +++ b/src/core/sensor/frequency_range_selection.h @@ -120,4 +120,4 @@ struct xml_io_stream_name { template <> struct xml_io_stream_aggregate { static constexpr bool value = true; -}; \ No newline at end of file +}; diff --git a/src/core/sensor/sensor_builder.cpp b/src/core/sensor/sensor_builder.cpp new file mode 100644 index 0000000000..eeee116eec --- /dev/null +++ b/src/core/sensor/sensor_builder.cpp @@ -0,0 +1,118 @@ +#include "sensor_builder.h" + +#include + +#include +#include + +namespace sensor { +namespace { +using AntennaSamples = std::vector>; + +std::shared_ptr make_poslos_grid( + const Vector3& pos, const AntennaSamples& antenna_samples) { + auto out = std::make_shared(antenna_samples.size()); + + for (Size i = 0; i < antenna_samples.size(); ++i) { + (*out)[i] = {.pos = pos, .los = antenna_samples[i].second}; + } + + return out; +} + +SparseStokvecMatrix make_weight_matrix(const AntennaSamples& antenna_samples, + const Channel& channel) { + const auto& channel_weights = channel.weights(); + + SparseStokvecMatrix out(antenna_samples.size(), channel_weights.size()); + + for (Size iposlos = 0; iposlos < antenna_samples.size(); ++iposlos) { + const auto& [antenna_weight, ignored_los] = antenna_samples[iposlos]; + static_cast(ignored_los); + + if (antenna_weight.is_zero()) continue; + + for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { + if (channel_weights[ifreq] == 0.0) continue; + out[iposlos, ifreq] = channel_weights[ifreq] * antenna_weight; + } + } + + return out; +} + +SensorMetaInfo make_meta_info(Size nchannels, Size geometry_index) { + SortedGriddedField1 gf; + gf.data_name = std::format("sensor-builder-{}", geometry_index); + gf.gridname<0>() = "channel"; + Vector channel_axis(nchannels); + for (Size i = 0; i < nchannels; ++i) { + channel_axis[i] = static_cast(i); + } + gf.grid<0>() = AscendingGrid{std::move(channel_axis)}; + gf.data.resize(nchannels); + gf.data = 0.0; + + return SensorMetaInfo{std::move(gf)}; +} +} // namespace + +std::pair SensorBuilder::operator()( + std::span pos, std::span los) const { + ARTS_USER_ERROR_IF(channels.empty(), + "SensorBuilder requires at least one channel") + ARTS_USER_ERROR_IF(pos.empty(), + "SensorBuilder requires at least one sensor position") + ARTS_USER_ERROR_IF(los.empty(), + "SensorBuilder requires at least one bore LOS") + ARTS_USER_ERROR_IF( + pos.size() != los.size(), + "SensorBuilder requires matching position and LOS counts. Got {} positions and {} LOS values.", + pos.size(), + los.size()) + + std::vector> freq_grids; + freq_grids.reserve(channels.size()); + for (const auto& channel : channels) { + freq_grids.push_back( + std::make_shared(channel.freq_grid())); + } + + std::vector antenna_samples; + antenna_samples.reserve(los.size()); + for (const auto& bore_los : los) antenna_samples.push_back(antenna(bore_los)); + + std::vector> weight_cache(los.size()); + for (Size ilos = 0; ilos < los.size(); ++ilos) { + auto& cached = weight_cache[ilos]; + cached.reserve(channels.size()); + for (const auto& channel : channels) { + cached.push_back(make_weight_matrix(antenna_samples[ilos], channel)); + } + } + + ArrayOfSensorObsel out; + out.reserve(pos.size() * channels.size()); + + ArrayOfSensorMetaInfo meta; + meta.reserve(pos.size()); + + const auto append_geometry = + [&](const Vector3& sensor_pos, Size ilos, Size geometry_index) { + auto poslos_grid = make_poslos_grid(sensor_pos, antenna_samples[ilos]); + + for (Size ichan = 0; ichan < channels.size(); ++ichan) { + out.emplace_back( + freq_grids[ichan], poslos_grid, weight_cache[ilos][ichan]); + } + + meta.push_back(make_meta_info(channels.size(), geometry_index)); + }; + + for (Size i = 0; i < pos.size(); ++i) append_geometry(pos[i], i, i); + + return {std::move(out), std::move(meta)}; +} + +static_assert(SensorBuilderSelection); +} // namespace sensor diff --git a/src/core/sensor/sensor_builder.h b/src/core/sensor/sensor_builder.h index d1770cd527..6352b58bb9 100644 --- a/src/core/sensor/sensor_builder.h +++ b/src/core/sensor/sensor_builder.h @@ -1,9 +1,28 @@ #pragma once -#include +#include +#include +#include +#include +#include "antenna_pattern.h" #include "frequency_channel_selection.h" #include "obsel.h" +#include "sensor_meta_info.h" namespace sensor { +//! Combines channels with an antenna pattern and bore geometries into obsels. +struct SensorBuilder; + +//! Concept for selecting sensor builders. +template +concept SensorBuilderSelection = std::derived_from; + +struct SensorBuilder { + std::vector channels; + AntennaPattern antenna; + + [[nodiscard]] std::pair operator()( + std::span pos, std::span los) const; +}; } // namespace sensor diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 800325e9b2..51aa595fd4 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -572,6 +573,75 @@ Numeric, Vector, or Matrix "Construct a zero-centered Gaussian channel on ``+/- m * std``."); generic_interface(sgauss); + auto ssb = py::class_(m, "SensorBuilder"); + ssb.doc() = + "Combines channels and an antenna pattern into sensor obsels for paired position/LOS samples."; + ssb.def(py::init<>(), "Construct an empty sensor builder.") + .def( + "__init__", + [](sensor::SensorBuilder* self, + const std::vector& channels, + const sensor::AntennaPattern& antenna) { + new (self) sensor::SensorBuilder{ + .channels = channels, + .antenna = antenna, + }; + }, + "channels"_a, + "antenna"_a, + "Construct a sensor builder from channels and one antenna pattern.") + .def_prop_rw( + "channels", + [](const sensor::SensorBuilder& self) { return self.channels; }, + [](sensor::SensorBuilder& self, + const std::vector& channels) { + self.channels = channels; + }, + "Spectrometer channels.\n\n.. :class:`list[~pyarts3.arts.SensorChannel]`") + .def_prop_rw( + "antenna", + [](const sensor::SensorBuilder& self) { return self.antenna; }, + [](sensor::SensorBuilder& self, + const sensor::AntennaPattern& antenna) { self.antenna = antenna; }, + "Angular antenna response.\n\n.. :class:`~pyarts3.arts.SensorAntennaPattern`") + .def( + "__call__", + [](const sensor::SensorBuilder& self, + const std::vector& pos, + const std::vector& los) { + if (pos.size() != los.size()) { + throw std::invalid_argument(std::format( + "SensorBuilder expects matching position and LOS counts. Got {} positions and {} LOS values.", + pos.size(), + los.size())); + } + + return self(pos, los); + }, + "pos"_a, + "los"_a, + R"(Build sensor obsels and metadata from paired position and bore-LOS sequences. + +Each ``pos[i]`` is combined with ``los[i]``. The returned obsels are ordered by +geometry first and channel second, and the returned value is +``(measurement_sensor, measurement_sensor_meta)``.)") + .def( + "__call__", + [](const sensor::SensorBuilder& self, const SensorPosLosVector& poslos) { + std::vector pos(poslos.size()); + std::vector los(poslos.size()); + + for (Size i = 0; i < poslos.size(); ++i) { + pos[i] = poslos[i].pos; + los[i] = poslos[i].los; + } + + return self(pos, los); + }, + "poslos"_a, + "Build sensor obsels and metadata from paired position/LOS samples."); + generic_interface(ssb); + auto shdfr = py::class_( m, "SensorHeterodyneFrequencyRange"); shdfr.def(py::init<>(), diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index c7308c0c92..234bbbe634 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -177,6 +177,12 @@ target_link_libraries(test_antenna_pattern PUBLIC artsworkspace) add_test(NAME "cpp.fast.test_antenna_pattern" COMMAND test_antenna_pattern) add_dependencies(check-deps test_antenna_pattern) +# #### +add_executable(test_sensor_builder test_sensor_builder.cc) +target_link_libraries(test_sensor_builder PUBLIC artsworkspace) +add_test(NAME "cpp.fast.test_sensor_builder" COMMAND test_sensor_builder) +add_dependencies(check-deps test_sensor_builder) + # #### add_executable(test_rtepack test_rtepack.cc) target_link_libraries(test_rtepack PUBLIC artstime rtepack rng) diff --git a/src/tests/test_sensor_builder.cc b/src/tests/test_sensor_builder.cc new file mode 100644 index 0000000000..d23248a300 --- /dev/null +++ b/src/tests/test_sensor_builder.cc @@ -0,0 +1,80 @@ +#include + +#include +#include +#include +#include + +namespace { +void assert_close(Numeric actual, + Numeric expected, + Numeric tol, + std::string_view name) { + ARTS_USER_ERROR_IF(std::abs(actual - expected) > tol, + "{} mismatch: actual {} expected {}", + name, + actual, + expected) +} + +void test_sensor_builder_returns_meta_per_geometry() { + sensor::SensorBuilder builder{ + .channels = {sensor::BoxChannel{AscendingGrid{100.0, 101.0}}, + sensor::DiracChannel{200.0}}, + .antenna = sensor::PencilBeamAntenna{}, + }; + + const std::array pos{{{600e3, 10.0, 20.0}, {601e3, 11.0, 21.0}}}; + const std::array los{{{20.0, 30.0}, {40.0, 50.0}}}; + + const auto [obsels, meta] = builder(pos, los); + + ARTS_USER_ERROR_IF(obsels.size() != 4, + "Expected 4 obsels, got {}", + obsels.size()) + ARTS_USER_ERROR_IF(meta.size() != 2, + "Expected 2 meta entries, got {}", + meta.size()) + ARTS_USER_ERROR_IF(meta[0].count() != 2 or meta[1].count() != 2, + "Each meta block must describe one 2-channel geometry") + + const auto& gf0 = std::get(meta[0].data); + const auto& gf1 = std::get(meta[1].data); + + ARTS_USER_ERROR_IF(gf0.gridname<0>() != "channel" or gf1.gridname<0>() != "channel", + "SensorBuilder meta grid must be the channel axis") + assert_close(gf0.grid<0>()[0], 0.0, 0.0, "meta[0] channel index 0"); + assert_close(gf0.grid<0>()[1], 1.0, 0.0, "meta[0] channel index 1"); + + ARTS_USER_ERROR_IF(obsels[0].poslos_grid()[0].pos != pos[0], + "First geometry position mismatch") + ARTS_USER_ERROR_IF(obsels[2].poslos_grid()[0].pos != pos[1], + "Second geometry position mismatch") +} + +void test_sensor_builder_rejects_mismatched_geometry_counts() { + sensor::SensorBuilder builder{ + .channels = {sensor::DiracChannel{}}, + .antenna = sensor::PencilBeamAntenna{}, + }; + + const std::array pos{{{600e3, 10.0, 20.0}}}; + const std::array los{{{20.0, 30.0}, {40.0, 50.0}}}; + + bool threw = false; + try { + static_cast(builder(pos, los)); + } catch (const std::runtime_error&) { + threw = true; + } + + ARTS_USER_ERROR_IF(not threw, + "SensorBuilder must reject mismatching position and LOS counts") +} +} // namespace + +int main() { + test_sensor_builder_returns_meta_per_geometry(); + test_sensor_builder_rejects_mismatched_geometry_counts(); + return 0; +} \ No newline at end of file diff --git a/tests/core/sensor/sensor_builder.py b/tests/core/sensor/sensor_builder.py new file mode 100644 index 0000000000..d03804559c --- /dev/null +++ b/tests/core/sensor/sensor_builder.py @@ -0,0 +1,84 @@ +import numpy as np +import pyarts3 as pyarts + + +def assert_close(name, got, expected, atol=1e-12): + got = np.asarray(got, dtype=float) + expected = np.asarray(expected, dtype=float) + print(f"{name}: got = {got}") + print(f"{name}: expected = {expected}") + np.testing.assert_allclose(got, expected, atol=atol, rtol=0.0) + + +def test_sensor_builder_from_sequences(): + builder = pyarts.arts.SensorBuilder( + [ + pyarts.arts.SensorBoxChannel(pyarts.arts.AscendingGrid([100.0, 101.0])), + pyarts.arts.SensorDiracChannel(200.0), + ], + pyarts.arts.SensorPencilBeamAntenna(), + ) + + pos = [ + pyarts.arts.Vector3([600e3, 10.0, 20.0]), + pyarts.arts.Vector3([601e3, 11.0, 21.0]), + ] + los = [ + pyarts.arts.Vector2([20.0, 30.0]), + pyarts.arts.Vector2([40.0, 50.0]), + ] + + obsels, meta = builder(pos, los) + + assert len(obsels) == 4 + assert len(meta) == 2 + assert meta[0].count == 2 + assert meta[1].count == 2 + assert_close("meta[0] channel grid", meta[0].data.grids[0], [0.0, 1.0]) + assert_close("first pos", obsels[0].poslos[0][:3], pos[0]) + assert_close("first los", obsels[0].poslos[0][3:], los[0]) + assert_close("second geometry pos", obsels[2].poslos[0][:3], pos[1]) + assert_close("second geometry los", obsels[2].poslos[0][3:], los[1]) + assert_close("first channel grid", obsels[0].f_grid, [100.0, 101.0]) + assert_close("second channel grid", obsels[1].f_grid, [200.0]) + assert_close("first channel weights", + obsels[0].weight_matrix.dense[0, 0], + [0.5, 0.0, 0.0, 0.0]) + + +def test_sensor_builder_from_poslos_vector_and_length_guard(): + builder = pyarts.arts.SensorBuilder( + [pyarts.arts.SensorDiracChannel()], + pyarts.arts.SensorPencilBeamAntenna(), + ) + + poslos = pyarts.arts.SensorPosLosVector() + poslos.value = np.array( + [ + [600e3, 10.0, 20.0, 20.0, 30.0], + [601e3, 11.0, 21.0, 40.0, 50.0], + ] + ) + + obsels, meta = builder(poslos) + + assert len(obsels) == 2 + assert len(meta) == 2 + assert meta[0].count == 1 + assert meta[1].count == 1 + assert_close("poslos builder first los", obsels[0].poslos[0][3:], [20.0, 30.0]) + assert_close("poslos builder second los", obsels[1].poslos[0][3:], [40.0, 50.0]) + + try: + builder([pyarts.arts.Vector3([600e3, 10.0, 20.0])], [ + pyarts.arts.Vector2([20.0, 30.0]), + pyarts.arts.Vector2([40.0, 50.0]), + ]) + except ValueError: + pass + else: + raise AssertionError("SensorBuilder must reject mismatching position/LOS lengths") + + +test_sensor_builder_from_sequences() +test_sensor_builder_from_poslos_vector_and_length_guard() \ No newline at end of file From 2496b2aceda6e0d505ef390dfe8e11c377e1f9ea Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Tue, 28 Apr 2026 12:23:22 +0900 Subject: [PATCH 06/21] Add sensor example Co-authored-by: Copilot --- examples/6-satellite-sensors/amsu-a.py | 290 +++++++++++++++++++++++++ src/python_interface/py_sensor.cpp | 119 ++++++---- 2 files changed, 361 insertions(+), 48 deletions(-) create mode 100644 examples/6-satellite-sensors/amsu-a.py diff --git a/examples/6-satellite-sensors/amsu-a.py b/examples/6-satellite-sensors/amsu-a.py new file mode 100644 index 0000000000..78f4de4ac3 --- /dev/null +++ b/examples/6-satellite-sensors/amsu-a.py @@ -0,0 +1,290 @@ +""" +Build an AMSU-A-like microwave sensor from a simple channel sheet. + +This example is intentionally script-like and intentionally approximate: + +- it keeps only O2-PWR98 and H2O-PWR98 absorption +- it uses flat-top lobes instead of a fully measured backend response +- it treats the listed bandwidths as total bandwidth, so each lobe spans + ``center +/- BW/2`` +- it uses one nadir view instead of a full cross-track scan + +The point is to show how a spec-sheet style channel list can be turned into +ARTS sensor elements, and how the LO offsets create the realized RF lobes. +""" + +from dataclasses import dataclass +import os + +import matplotlib.pyplot as plt +import numpy as np +import pyarts3 as pa + + +F0_O2 = 57.290344e9 +SAMPLES_PER_LOBE = 11 + + +@dataclass(frozen=True) +class ChannelSpec: + number: int + spec_text: str + reference_hz: float + bandwidth_hz: float + polarization: str + nedt_k: float + lo_offsets_hz: tuple[float, ...] = () + + +CHANNELS = [ + ChannelSpec(1, "23.800", 23.800e9, 270e6, "QV", 0.30), + ChannelSpec(2, "31.400", 31.400e9, 180e6, "QV", 0.30), + ChannelSpec(3, "50.300", 50.300e9, 180e6, "QV", 0.40), + ChannelSpec(4, "52.800", 52.800e9, 400e6, "QV", 0.25), + ChannelSpec(5, "53.596 ± 0.115", 53.596e9, 170e6, "QH", 0.25, (0.115e9,)), + ChannelSpec(6, "54.400", 54.400e9, 400e6, "QH", 0.25), + ChannelSpec(7, "54.940", 54.940e9, 400e6, "QV", 0.25), + ChannelSpec(8, "55.500", 55.500e9, 330e6, "QH", 0.25), + ChannelSpec(9, "f0 = 57.290344", F0_O2, 330e6, "QH", 0.25), + ChannelSpec(10, "f0 ± 0.217", F0_O2, 78e6, "QH", 0.40, (0.217e9,)), + ChannelSpec(11, "f0 ± 0.3222 ± 0.048", F0_O2, 36e6, "QH", 0.40, (0.3222e9, 0.048e9)), + ChannelSpec(12, "f0 ± 0.3222 ± 0.022", F0_O2, 16e6, "QH", 0.60, (0.3222e9, 0.022e9)), + ChannelSpec(13, "f0 ± 0.3222 ± 0.010", F0_O2, 8e6, "QH", 0.80, (0.3222e9, 0.010e9)), + ChannelSpec(14, "f0 ± 0.3222 ± 0.0045", F0_O2, 3e6, "QH", 1.20, (0.3222e9, 0.0045e9)), + ChannelSpec(15, "89.000 ± 1.0", 89.000e9, 1000e6, "QV", 0.50, (1.0e9,)), +] + + +def lo_recipe(spec: ChannelSpec) -> str: + if not spec.lo_offsets_hz: + return "direct" + + return " -> ".join(f"split ±{offset / 1e9:.4f} GHz" for offset in spec.lo_offsets_hz) + + +def split_centers(reference_hz: float, offsets_hz: tuple[float, ...]) -> np.ndarray: + centers = np.array([reference_hz], dtype=float) + + for offset in offsets_hz: + centers = np.concatenate([centers - offset, centers + offset]) + + return np.sort(centers) + + +def realized_channel(spec: ChannelSpec, samples_per_lobe: int = SAMPLES_PER_LOBE): + half_bw = 0.5 * spec.bandwidth_hz + local_df = np.linspace(-half_bw, half_bw, samples_per_lobe) + lobe_centers = split_centers(spec.reference_hz, spec.lo_offsets_hz) + + freq_grid = np.concatenate([center + local_df for center in lobe_centers]) + weights = np.ones(freq_grid.size, dtype=float) + + order = np.argsort(freq_grid) + freq_grid = freq_grid[order] + weights = weights[order] + weights /= weights.sum() + + return { + "reference_hz": spec.reference_hz, + "lobe_centers_hz": lobe_centers, + "freq_grid_hz": freq_grid, + "weights": weights, + "dfreq_hz": freq_grid - spec.reference_hz, + } + + +def make_raw_sensor(spec: ChannelSpec, channel_data: dict): + raw = pa.arts.SortedGriddedField1() + raw.dataname = f"amsu-ch{spec.number}" + raw.gridnames = ["dfreq"] + raw.grids = [pa.arts.AscendingGrid(channel_data["dfreq_hz"])] + raw.data = channel_data["weights"] + return raw + + +def compute_channel_spectral_rad(ws, pos, los, freq_grid_hz: np.ndarray) -> np.ndarray: + ws.freq_grid = pa.arts.AscendingGrid(freq_grid_hz) + ws.ray_pathGeometric(pos=pos, los=los, max_stepsize=1000.0) + ws.spectral_radClearskyEmission() + ws.spectral_radApplyUnitFromSpectralRadiance() + return np.asarray(ws.spectral_rad.value, dtype=float)[:, 0].copy() + + +def format_table(headers, rows): + widths = [len(header) for header in headers] + + for row in rows: + for i, value in enumerate(row): + widths[i] = max(widths[i], len(str(value))) + + def fmt(row): + return " | ".join(str(value).ljust(widths[i]) for i, value in enumerate(row)) + + line = "-+-".join("-" * width for width in widths) + return "\n".join([fmt(headers), line, *(fmt(row) for row in rows)]) + + +def print_channel_tables(channel_specs, realized): + spec_rows = [] + realized_rows = [] + + for spec in channel_specs: + channel_data = realized[spec.number] + centers_ghz = ", ".join(f"{center / 1e9:.6f}" for center in channel_data["lobe_centers_hz"]) + spec_rows.append( + [ + spec.number, + spec.spec_text, + f"{spec.bandwidth_hz / 1e6:.1f}", + spec.polarization, + f"{spec.nedt_k:.2f}", + lo_recipe(spec), + ] + ) + realized_rows.append( + [ + spec.number, + len(channel_data["lobe_centers_hz"]), + centers_ghz, + len(channel_data["freq_grid_hz"]), + f"{channel_data['freq_grid_hz'][0] / 1e9:.6f}", + f"{channel_data['freq_grid_hz'][-1] / 1e9:.6f}", + ] + ) + + print("\nAMSU-A-like channel sheet used in this example") + print(format_table( + ["Ch", "Spec-sheet RF description", "BW [MHz]", "Pol", "NEΔT [K]", "LO recipe"], + spec_rows, + )) + + print("\nRealized RF lobes after combining the LO offsets") + print(format_table( + ["Ch", "Lobes", "Lobe centers [GHz]", "Internal pts", "f_min [GHz]", "f_max [GHz]"], + realized_rows, + )) + + +def print_measurement_table(channel_specs, realized, manual_measurement, measurement_vec): + rows = [] + for index, spec in enumerate(channel_specs): + channel_data = realized[spec.number] + rows.append( + [ + spec.number, + f"{channel_data['reference_hz'] / 1e9:.6f}", + f"{manual_measurement[index]:.3f}", + f"{measurement_vec[index]:.3f}", + f"{1e3 * (measurement_vec[index] - manual_measurement[index]):.3f}", + ] + ) + + print("\nChannel-weighted results") + print(format_table( + ["Ch", "Representative f [GHz]", "Manual weighted Tb [K]", "measurement_vec [K]", "Δ [mK]"], + rows, + )) + + +pa.data.download() + +ws = pa.workspace.Workspace() + +# Keep the atmosphere intentionally simple. +ws.abs_speciesSet(species=["O2-PWR98", "H2O-PWR98"]) +ws.ReadCatalogData() +ws.spectral_propmat_agendaAuto() + +ws.surf_fieldPlanet(option="Earth") +ws.surf_field[pa.arts.SurfaceKey("t")] = 290.0 +ws.atm_fieldRead(toa=100e3, basename="planets/Earth/afgl/tropical/", missing_is_zero=1) + +ws.spectral_rad_transform_operatorSet(option="Tb") +ws.ray_path_observer_agendaSetGeometric() + +# AMSU-A is a cross-track scanner, we take a single nadir view for simplicity. +pos = pa.arts.Vector3([833e3, 0.0, 0.0]) +los = pa.arts.Vector2([180.0, 0.0]) + +realized = {spec.number: realized_channel(spec) for spec in CHANNELS} +print_channel_tables(CHANNELS, realized) + +# First plot: evaluate spectral radiance directly on each channel's own internal grid. +channel_spectra = {} +manual_measurement = [] +for spec in CHANNELS: + channel_data = realized[spec.number] + spectral_tb = compute_channel_spectral_rad(ws, pos, los, channel_data["freq_grid_hz"]) + channel_spectra[spec.number] = spectral_tb + manual_measurement.append(np.dot(channel_data["weights"], spectral_tb)) + +manual_measurement = np.asarray(manual_measurement) + +# Second step: build the same channel set as an ARTS measurement sensor. +ws.measurement_sensorInit() +for spec in CHANNELS: + ws.measurement_sensorAddRawSensor( + freq_grid=pa.arts.AscendingGrid(np.array([spec.reference_hz])), + pos=pos, + los=los, + raw_sensor_perturbation=make_raw_sensor(spec, realized[spec.number]), + normalize=1, + ) +ws.measurement_sensor.normalize() + +ws.measurement_vecFromSensor(kernel="High Performance") +measurement_vec = np.asarray(ws.measurement_vec) +print_measurement_table(CHANNELS, realized, manual_measurement, measurement_vec) + +colors = plt.get_cmap('tab20')(range(15)) +fig_internal, ax_internal = plt.subplots(figsize=(6, 4)) +for spec in CHANNELS: + channel_data = realized[spec.number] + ax_internal.plot( + channel_data["freq_grid_hz"] / 1e9, + channel_spectra[spec.number], + marker="o", + markersize=2.5, + linewidth=1.1, + label=f"Ch {spec.number}", + color = colors[spec.number - 1], + ) + +ax_internal.set_xlabel("Frequency [GHz]") +ax_internal.set_ylabel("Brightness temperature (internal grid) [K]") +ax_internal.set_title("AMSU-A-like channels") +ax_internal.grid(True, alpha=0.3) +ax_internal.legend(ncol=3, fontsize=8) + +fig_channels, ax_channels = plt.subplots(figsize=(6, 4)) +ax_channels.errorbar( + [spec.number for spec in CHANNELS], + measurement_vec, + yerr=[spec.nedt_k for spec in CHANNELS], + fmt="o", + capsize=3, + linewidth=1.0, +) +ax_channels.plot([spec.number for spec in CHANNELS], measurement_vec, linewidth=1.0, alpha=0.8) +ax_channels.set_xticks([spec.number for spec in CHANNELS]) +ax_channels.set_xlabel("AMSU-A channel number") +ax_channels.set_ylabel("Channel brightness [K]") +ax_channels.set_title("AMSU-A-like channelized measurements") +ax_channels.grid(True, alpha=0.3) + +for x, spec, y in zip([spec.number for spec in CHANNELS], CHANNELS, measurement_vec): + ax_channels.annotate( + spec.spec_text, + (x, y), + textcoords="offset points", + xytext=(0, 7), + ha="center", + fontsize=7, + rotation=90, + ) + +fig_internal.tight_layout() +fig_channels.tight_layout() + +if "ARTS_HEADLESS" not in os.environ: + plt.show() diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 51aa595fd4..45365e5ce3 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -269,7 +269,11 @@ Numeric, Vector, or Matrix .def_prop_ro( "poslos", &SensorObsel::poslos_grid, - "Position and line of sight grid\n\n.. :class:`SensorPosLosVector`"); + "Position and line of sight grid\n\n.. :class:`SensorPosLosVector`") + .def("normalize", + &SensorObsel::normalize, + "pol"_a = Stokvec{1., 0., 0., 0.}, + R"(Normalize the weights so that they sum to pol.)"); auto a0 = py::bind_vector, py::rv_policy::reference_internal>( @@ -416,6 +420,22 @@ Numeric, Vector, or Matrix of one channel will affect all channels. )"); + a1.def( + "normalize", + [](ArrayOfSensorObsel& x, Stokvec pol) { + for (auto& obsel : x) obsel.normalize(pol); + }, + R"(Normalize the weights of each obsel so that they sum to pol. + +See :meth:`SensorObsel.normalize` for details. + +.. note:: + The polarization of each channel will be the same. This is a + catch-all method that simply helps when you want something up-and-running. + Each channel must be normalized separately if they have different polarizations. +)", + "pol"_a = Stokvec{1., 0., 0., 0.}); + py::class_ smi(m, "SensorMetaInfo"); generic_interface(smi); smi.def_rw( @@ -434,64 +454,66 @@ Numeric, Vector, or Matrix generic_interface(a2); vector_interface(a2); - py::class_ sapf(m, "SensorAntennaPatternField"); - sapf.doc() = + py::class_ sapf(m, "SensorAntennaPatternField"); + sapf.doc() = "A 2D gridded antenna response on local zenith and azimuth offsets."; - gridded_data_interface(sapf); - generic_interface(sapf); + gridded_data_interface(sapf); + generic_interface(sapf); - auto sap = py::class_(m, "SensorAntennaPattern"); - sap.doc() = + auto sap = py::class_(m, "SensorAntennaPattern"); + sap.doc() = "Base class for angular antenna responses defined around a bore line of sight."; - sap.def(py::init<>(), "Construct an empty antenna pattern.") + sap.def(py::init<>(), "Construct an empty antenna pattern.") .def( - "__init__", - [](sensor::AntennaPattern* self, - const sensor::AntennaPatternField& response) { - new (self) sensor::AntennaPattern{.data = response}; - }, - "response"_a, - "Construct an antenna pattern from a local zenith/azimuth response field.") + "__init__", + [](sensor::AntennaPattern* self, + const sensor::AntennaPatternField& response) { + new (self) sensor::AntennaPattern{.data = response}; + }, + "response"_a, + "Construct an antenna pattern from a local zenith/azimuth response field.") .def_prop_rw( - "response", - [](sensor::AntennaPattern& self) -> sensor::AntennaPatternField& { - return self.data; - }, - [](sensor::AntennaPattern& self, - const sensor::AntennaPatternField& response) { - self.data = response; - }, - py::rv_policy::reference_internal, - "Local antenna response field.\n\n.. :class:`~pyarts3.arts.SensorAntennaPatternField`") + "response", + [](sensor::AntennaPattern& self) -> sensor::AntennaPatternField& { + return self.data; + }, + [](sensor::AntennaPattern& self, + const sensor::AntennaPatternField& response) { + self.data = response; + }, + py::rv_policy::reference_internal, + "Local antenna response field.\n\n.. :class:`~pyarts3.arts.SensorAntennaPatternField`") .def( - "__call__", - &sensor::AntennaPattern::operator(), - "bore_los"_a, - R"(Map the local antenna pattern onto global LOS values around ``bore_los``. + "__call__", + &sensor::AntennaPattern::operator(), + "bore_los"_a, + R"(Map the local antenna pattern onto global LOS values around ``bore_los``. Returns a list of ``(weight, los)`` pairs, where ``weight`` is a :class:`Stokvec` and ``los`` is the mapped :class:`Vector2`.)"); - generic_interface(sap); + generic_interface(sap); - auto spp = py::class_( + auto spp = py::class_( m, "SensorPencilBeamAntenna"); - spp.doc() = "A 1x1 antenna response centered on the bore line of sight."; - spp.def(py::init(), - "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, - "Construct a pencil-beam antenna with one Stokes weight at the bore LOS."); - generic_interface(spp); - - auto sga = py::class_( + spp.doc() = "A 1x1 antenna response centered on the bore line of sight."; + spp.def( + py::init(), + "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, + "Construct a pencil-beam antenna with one Stokes weight at the bore LOS."); + generic_interface(spp); + + auto sga = py::class_( m, "SensorGaussianAntenna"); - sga.doc() = "A Gaussian antenna response defined on a local zenith/azimuth grid."; - sga.def(py::init(), - "zen_grid"_a, - "azi_grid"_a, - "zenith_std"_a, - "azimuth_std"_a, - "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, - "Construct a Gaussian antenna response on the supplied local grids."); - generic_interface(sga); + sga.doc() = + "A Gaussian antenna response defined on a local zenith/azimuth grid."; + sga.def(py::init(), + "zen_grid"_a, + "azi_grid"_a, + "zenith_std"_a, + "azimuth_std"_a, + "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, + "Construct a Gaussian antenna response on the supplied local grids."); + generic_interface(sga); auto sch = py::class_(m, "SensorChannel"); sch.doc() = "Base class for relative spectrometer channel responses."; @@ -627,7 +649,8 @@ geometry first and channel second, and the returned value is ``(measurement_sensor, measurement_sensor_meta)``.)") .def( "__call__", - [](const sensor::SensorBuilder& self, const SensorPosLosVector& poslos) { + [](const sensor::SensorBuilder& self, + const SensorPosLosVector& poslos) { std::vector pos(poslos.size()); std::vector los(poslos.size()); From 8c684d38521242986d09deac48155747572602e3 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Tue, 28 Apr 2026 12:34:38 +0900 Subject: [PATCH 07/21] Make example for AMSU-A Co-authored-by: Copilot --- examples/6-satellite-sensors/amsu-a.py | 281 +++++++++---------------- src/python_interface/py_sensor.cpp | 8 + 2 files changed, 111 insertions(+), 178 deletions(-) diff --git a/examples/6-satellite-sensors/amsu-a.py b/examples/6-satellite-sensors/amsu-a.py index 78f4de4ac3..0e0e51b691 100644 --- a/examples/6-satellite-sensors/amsu-a.py +++ b/examples/6-satellite-sensors/amsu-a.py @@ -27,163 +27,81 @@ @dataclass(frozen=True) class ChannelSpec: - number: int - spec_text: str - reference_hz: float - bandwidth_hz: float - polarization: str - nedt_k: float - lo_offsets_hz: tuple[float, ...] = () - - + number: int + spec_text: str + reference_hz: float + bandwidth_hz: float + polarization: str + nedt_k: float + lo_offsets_hz: tuple[float, ...] = () + +# fmt: off CHANNELS = [ - ChannelSpec(1, "23.800", 23.800e9, 270e6, "QV", 0.30), - ChannelSpec(2, "31.400", 31.400e9, 180e6, "QV", 0.30), - ChannelSpec(3, "50.300", 50.300e9, 180e6, "QV", 0.40), - ChannelSpec(4, "52.800", 52.800e9, 400e6, "QV", 0.25), - ChannelSpec(5, "53.596 ± 0.115", 53.596e9, 170e6, "QH", 0.25, (0.115e9,)), - ChannelSpec(6, "54.400", 54.400e9, 400e6, "QH", 0.25), - ChannelSpec(7, "54.940", 54.940e9, 400e6, "QV", 0.25), - ChannelSpec(8, "55.500", 55.500e9, 330e6, "QH", 0.25), - ChannelSpec(9, "f0 = 57.290344", F0_O2, 330e6, "QH", 0.25), - ChannelSpec(10, "f0 ± 0.217", F0_O2, 78e6, "QH", 0.40, (0.217e9,)), - ChannelSpec(11, "f0 ± 0.3222 ± 0.048", F0_O2, 36e6, "QH", 0.40, (0.3222e9, 0.048e9)), - ChannelSpec(12, "f0 ± 0.3222 ± 0.022", F0_O2, 16e6, "QH", 0.60, (0.3222e9, 0.022e9)), - ChannelSpec(13, "f0 ± 0.3222 ± 0.010", F0_O2, 8e6, "QH", 0.80, (0.3222e9, 0.010e9)), - ChannelSpec(14, "f0 ± 0.3222 ± 0.0045", F0_O2, 3e6, "QH", 1.20, (0.3222e9, 0.0045e9)), - ChannelSpec(15, "89.000 ± 1.0", 89.000e9, 1000e6, "QV", 0.50, (1.0e9,)), + ChannelSpec(1, "23.800", 23.800e9, 270e6, "QV", 0.30), + ChannelSpec(2, "31.400", 31.400e9, 180e6, "QV", 0.30), + ChannelSpec(3, "50.300", 50.300e9, 180e6, "QV", 0.40), + ChannelSpec(4, "52.800", 52.800e9, 400e6, "QV", 0.25), + ChannelSpec(5, "53.596 ± 0.115", 53.596e9, 170e6, "QH", 0.25, (0.115e9,)), + ChannelSpec(6, "54.400", 54.400e9, 400e6, "QH", 0.25), + ChannelSpec(7, "54.940", 54.940e9, 400e6, "QV", 0.25), + ChannelSpec(8, "55.500", 55.500e9, 330e6, "QH", 0.25), + ChannelSpec(9, "f0 = 57.290344", F0_O2, 330e6, "QH", 0.25), + ChannelSpec(10, "f0 ± 0.217", F0_O2, 78e6, "QH", 0.40, (0.217e9,)), + ChannelSpec(11, "f0 ± 0.3222 ± 0.048", F0_O2, 6e6, "QH", 0.40, (0.3222e9, 0.048e9)), + ChannelSpec(12, "f0 ± 0.3222 ± 0.022", F0_O2, 16e6, "QH", 0.60, (0.3222e9, 0.022e9)), + ChannelSpec(13, "f0 ± 0.3222 ± 0.010", F0_O2, 8e6, "QH", 0.80, (0.3222e9, 0.010e9)), + ChannelSpec(14, "f0 ± 0.3222 ± 0.0045", F0_O2, 3e6, "QH", 1.20, (0.3222e9, 0.0045e9)), + ChannelSpec(15, "89.000 ± 1.0", 89.000e9, 1000e6, "QV", 0.50, (1.0e9,)), ] - - -def lo_recipe(spec: ChannelSpec) -> str: - if not spec.lo_offsets_hz: - return "direct" - - return " -> ".join(f"split ±{offset / 1e9:.4f} GHz" for offset in spec.lo_offsets_hz) +# fmt: on def split_centers(reference_hz: float, offsets_hz: tuple[float, ...]) -> np.ndarray: - centers = np.array([reference_hz], dtype=float) + centers = np.array([reference_hz], dtype=float) - for offset in offsets_hz: - centers = np.concatenate([centers - offset, centers + offset]) + for offset in offsets_hz: + centers = np.concatenate([centers - offset, centers + offset]) - return np.sort(centers) + return np.sort(centers) def realized_channel(spec: ChannelSpec, samples_per_lobe: int = SAMPLES_PER_LOBE): - half_bw = 0.5 * spec.bandwidth_hz - local_df = np.linspace(-half_bw, half_bw, samples_per_lobe) - lobe_centers = split_centers(spec.reference_hz, spec.lo_offsets_hz) + half_bw = 0.5 * spec.bandwidth_hz + local_df = np.linspace(-half_bw, half_bw, samples_per_lobe) + lobe_centers = split_centers(spec.reference_hz, spec.lo_offsets_hz) - freq_grid = np.concatenate([center + local_df for center in lobe_centers]) - weights = np.ones(freq_grid.size, dtype=float) + freq_grid = np.concatenate([center + local_df for center in lobe_centers]) + weights = np.ones(freq_grid.size, dtype=float) - order = np.argsort(freq_grid) - freq_grid = freq_grid[order] - weights = weights[order] - weights /= weights.sum() + order = np.argsort(freq_grid) + freq_grid = freq_grid[order] + weights = weights[order] + weights /= weights.sum() - return { - "reference_hz": spec.reference_hz, - "lobe_centers_hz": lobe_centers, - "freq_grid_hz": freq_grid, - "weights": weights, - "dfreq_hz": freq_grid - spec.reference_hz, - } + return { + "reference_hz": spec.reference_hz, + "lobe_centers_hz": lobe_centers, + "freq_grid_hz": freq_grid, + "weights": weights, + "dfreq_hz": freq_grid - spec.reference_hz, + } def make_raw_sensor(spec: ChannelSpec, channel_data: dict): - raw = pa.arts.SortedGriddedField1() - raw.dataname = f"amsu-ch{spec.number}" - raw.gridnames = ["dfreq"] - raw.grids = [pa.arts.AscendingGrid(channel_data["dfreq_hz"])] - raw.data = channel_data["weights"] - return raw + raw = pa.arts.SortedGriddedField1() + raw.dataname = f"amsu-ch{spec.number}" + raw.gridnames = ["dfreq"] + raw.grids = [pa.arts.AscendingGrid(channel_data["dfreq_hz"])] + raw.data = channel_data["weights"] + return raw def compute_channel_spectral_rad(ws, pos, los, freq_grid_hz: np.ndarray) -> np.ndarray: - ws.freq_grid = pa.arts.AscendingGrid(freq_grid_hz) - ws.ray_pathGeometric(pos=pos, los=los, max_stepsize=1000.0) - ws.spectral_radClearskyEmission() - ws.spectral_radApplyUnitFromSpectralRadiance() - return np.asarray(ws.spectral_rad.value, dtype=float)[:, 0].copy() - - -def format_table(headers, rows): - widths = [len(header) for header in headers] - - for row in rows: - for i, value in enumerate(row): - widths[i] = max(widths[i], len(str(value))) - - def fmt(row): - return " | ".join(str(value).ljust(widths[i]) for i, value in enumerate(row)) - - line = "-+-".join("-" * width for width in widths) - return "\n".join([fmt(headers), line, *(fmt(row) for row in rows)]) - - -def print_channel_tables(channel_specs, realized): - spec_rows = [] - realized_rows = [] - - for spec in channel_specs: - channel_data = realized[spec.number] - centers_ghz = ", ".join(f"{center / 1e9:.6f}" for center in channel_data["lobe_centers_hz"]) - spec_rows.append( - [ - spec.number, - spec.spec_text, - f"{spec.bandwidth_hz / 1e6:.1f}", - spec.polarization, - f"{spec.nedt_k:.2f}", - lo_recipe(spec), - ] - ) - realized_rows.append( - [ - spec.number, - len(channel_data["lobe_centers_hz"]), - centers_ghz, - len(channel_data["freq_grid_hz"]), - f"{channel_data['freq_grid_hz'][0] / 1e9:.6f}", - f"{channel_data['freq_grid_hz'][-1] / 1e9:.6f}", - ] - ) - - print("\nAMSU-A-like channel sheet used in this example") - print(format_table( - ["Ch", "Spec-sheet RF description", "BW [MHz]", "Pol", "NEΔT [K]", "LO recipe"], - spec_rows, - )) - - print("\nRealized RF lobes after combining the LO offsets") - print(format_table( - ["Ch", "Lobes", "Lobe centers [GHz]", "Internal pts", "f_min [GHz]", "f_max [GHz]"], - realized_rows, - )) - - -def print_measurement_table(channel_specs, realized, manual_measurement, measurement_vec): - rows = [] - for index, spec in enumerate(channel_specs): - channel_data = realized[spec.number] - rows.append( - [ - spec.number, - f"{channel_data['reference_hz'] / 1e9:.6f}", - f"{manual_measurement[index]:.3f}", - f"{measurement_vec[index]:.3f}", - f"{1e3 * (measurement_vec[index] - manual_measurement[index]):.3f}", - ] - ) - - print("\nChannel-weighted results") - print(format_table( - ["Ch", "Representative f [GHz]", "Manual weighted Tb [K]", "measurement_vec [K]", "Δ [mK]"], - rows, - )) + ws.freq_grid = pa.arts.AscendingGrid(freq_grid_hz) + ws.ray_pathGeometric(pos=pos, los=los, max_stepsize=1000.0) + ws.spectral_radClearskyEmission() + ws.spectral_radApplyUnitFromSpectralRadiance() + return np.asarray(ws.spectral_rad.value, dtype=float)[:, 0].copy() pa.data.download() @@ -207,48 +125,48 @@ def print_measurement_table(channel_specs, realized, manual_measurement, measure los = pa.arts.Vector2([180.0, 0.0]) realized = {spec.number: realized_channel(spec) for spec in CHANNELS} -print_channel_tables(CHANNELS, realized) # First plot: evaluate spectral radiance directly on each channel's own internal grid. channel_spectra = {} manual_measurement = [] for spec in CHANNELS: - channel_data = realized[spec.number] - spectral_tb = compute_channel_spectral_rad(ws, pos, los, channel_data["freq_grid_hz"]) - channel_spectra[spec.number] = spectral_tb - manual_measurement.append(np.dot(channel_data["weights"], spectral_tb)) + channel_data = realized[spec.number] + spectral_tb = compute_channel_spectral_rad( + ws, pos, los, channel_data["freq_grid_hz"]) + channel_spectra[spec.number] = spectral_tb + manual_measurement.append(np.dot(channel_data["weights"], spectral_tb)) manual_measurement = np.asarray(manual_measurement) # Second step: build the same channel set as an ARTS measurement sensor. ws.measurement_sensorInit() for spec in CHANNELS: - ws.measurement_sensorAddRawSensor( - freq_grid=pa.arts.AscendingGrid(np.array([spec.reference_hz])), - pos=pos, - los=los, - raw_sensor_perturbation=make_raw_sensor(spec, realized[spec.number]), - normalize=1, - ) + ws.measurement_sensorAddRawSensor( + freq_grid=pa.arts.AscendingGrid(np.array([spec.reference_hz])), + pos=pos, + los=los, + raw_sensor_perturbation=make_raw_sensor(spec, realized[spec.number]), + normalize=1, + ) ws.measurement_sensor.normalize() +ws.measurement_sensor.collect() ws.measurement_vecFromSensor(kernel="High Performance") measurement_vec = np.asarray(ws.measurement_vec) -print_measurement_table(CHANNELS, realized, manual_measurement, measurement_vec) colors = plt.get_cmap('tab20')(range(15)) fig_internal, ax_internal = plt.subplots(figsize=(6, 4)) for spec in CHANNELS: - channel_data = realized[spec.number] - ax_internal.plot( - channel_data["freq_grid_hz"] / 1e9, - channel_spectra[spec.number], - marker="o", - markersize=2.5, - linewidth=1.1, - label=f"Ch {spec.number}", - color = colors[spec.number - 1], - ) + channel_data = realized[spec.number] + ax_internal.plot( + channel_data["freq_grid_hz"] / 1e9, + channel_spectra[spec.number], + marker="o", + markersize=2.5, + linewidth=1.1, + label=f"Ch {spec.number}", + color=colors[spec.number - 1], + ) ax_internal.set_xlabel("Frequency [GHz]") ax_internal.set_ylabel("Brightness temperature (internal grid) [K]") @@ -258,14 +176,15 @@ def print_measurement_table(channel_specs, realized, manual_measurement, measure fig_channels, ax_channels = plt.subplots(figsize=(6, 4)) ax_channels.errorbar( - [spec.number for spec in CHANNELS], - measurement_vec, - yerr=[spec.nedt_k for spec in CHANNELS], - fmt="o", - capsize=3, - linewidth=1.0, + [spec.number for spec in CHANNELS], + measurement_vec, + yerr=[spec.nedt_k for spec in CHANNELS], + fmt="o", + capsize=3, + linewidth=1.0, ) -ax_channels.plot([spec.number for spec in CHANNELS], measurement_vec, linewidth=1.0, alpha=0.8) +ax_channels.plot([spec.number for spec in CHANNELS], + measurement_vec, linewidth=1.0, alpha=0.8) ax_channels.set_xticks([spec.number for spec in CHANNELS]) ax_channels.set_xlabel("AMSU-A channel number") ax_channels.set_ylabel("Channel brightness [K]") @@ -273,18 +192,24 @@ def print_measurement_table(channel_specs, realized, manual_measurement, measure ax_channels.grid(True, alpha=0.3) for x, spec, y in zip([spec.number for spec in CHANNELS], CHANNELS, measurement_vec): - ax_channels.annotate( - spec.spec_text, - (x, y), - textcoords="offset points", - xytext=(0, 7), - ha="center", - fontsize=7, - rotation=90, - ) + ax_channels.annotate( + spec.spec_text, + (x, y), + textcoords="offset points", + xytext=(0, 7), + ha="center", + fontsize=7, + rotation=90, + ) fig_internal.tight_layout() fig_channels.tight_layout() if "ARTS_HEADLESS" not in os.environ: - plt.show() + plt.show() + + +assert np.allclose(ws.measurement_vec, [289.290378, 289.54484543, 284.35054685, 273.08020996, + 259.90747748, 241.3598937, 228.07526569, 217.2986938, + 207.60407201, 213.70169277, 223.87462958, 235.45802133, + 246.81775696, 257.07536243, 289.1540646]) diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 45365e5ce3..d43d79e71b 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -436,6 +436,14 @@ See :meth:`SensorObsel.normalize` for details. )", "pol"_a = Stokvec{1., 0., 0., 0.}); + a1.def( + "collect", + [](py::object& self) { + self.attr("collect_freq_grids")(); + self.attr("collect_poslos_grids")(); + }, + R"(Calls the collect_freq_grids and collect_poslos_grids methods in said order.)"); + py::class_ smi(m, "SensorMetaInfo"); generic_interface(smi); smi.def_rw( From 6b2835cd0c9bd891f2716567de457d9d0f9af777 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 11 May 2026 14:45:39 +0900 Subject: [PATCH 08/21] Fix error in upper side-band --- examples/6-satellite-sensors/amsu-a.py | 224 ++++++++---------- .../sensor/frequency_bandpass_filters.cpp | 56 ++++- src/core/sensor/frequency_range_selection.cpp | 1 + src/core/sensor/frequency_range_selection.h | 5 +- src/python_interface/py_sensor.cpp | 31 ++- .../sensor/heterodyne_frequency_response.py | 73 +++++- 6 files changed, 231 insertions(+), 159 deletions(-) diff --git a/examples/6-satellite-sensors/amsu-a.py b/examples/6-satellite-sensors/amsu-a.py index 0e0e51b691..77c15a2e0a 100644 --- a/examples/6-satellite-sensors/amsu-a.py +++ b/examples/6-satellite-sensors/amsu-a.py @@ -1,16 +1,7 @@ """ Build an AMSU-A-like microwave sensor from a simple channel sheet. -This example is intentionally script-like and intentionally approximate: - -- it keeps only O2-PWR98 and H2O-PWR98 absorption -- it uses flat-top lobes instead of a fully measured backend response -- it treats the listed bandwidths as total bandwidth, so each lobe spans - ``center +/- BW/2`` -- it uses one nadir view instead of a full cross-track scan - -The point is to show how a spec-sheet style channel list can be turned into -ARTS sensor elements, and how the LO offsets create the realized RF lobes. +This is an example of how to set up simulations for a custom sensor. """ from dataclasses import dataclass @@ -21,152 +12,126 @@ import pyarts3 as pa -F0_O2 = 57.290344e9 -SAMPLES_PER_LOBE = 11 - - @dataclass(frozen=True) class ChannelSpec: - number: int - spec_text: str - reference_hz: float - bandwidth_hz: float - polarization: str - nedt_k: float + """ A simple data structure to hold the channel specifications. """ + number: int + spec_text: str + reference_hz: float + bandwidth_hz: float + polarization: str + nedt_k: float lo_offsets_hz: tuple[float, ...] = () + + # fmt: off CHANNELS = [ - ChannelSpec(1, "23.800", 23.800e9, 270e6, "QV", 0.30), - ChannelSpec(2, "31.400", 31.400e9, 180e6, "QV", 0.30), - ChannelSpec(3, "50.300", 50.300e9, 180e6, "QV", 0.40), - ChannelSpec(4, "52.800", 52.800e9, 400e6, "QV", 0.25), - ChannelSpec(5, "53.596 ± 0.115", 53.596e9, 170e6, "QH", 0.25, (0.115e9,)), - ChannelSpec(6, "54.400", 54.400e9, 400e6, "QH", 0.25), - ChannelSpec(7, "54.940", 54.940e9, 400e6, "QV", 0.25), - ChannelSpec(8, "55.500", 55.500e9, 330e6, "QH", 0.25), - ChannelSpec(9, "f0 = 57.290344", F0_O2, 330e6, "QH", 0.25), - ChannelSpec(10, "f0 ± 0.217", F0_O2, 78e6, "QH", 0.40, (0.217e9,)), - ChannelSpec(11, "f0 ± 0.3222 ± 0.048", F0_O2, 6e6, "QH", 0.40, (0.3222e9, 0.048e9)), - ChannelSpec(12, "f0 ± 0.3222 ± 0.022", F0_O2, 16e6, "QH", 0.60, (0.3222e9, 0.022e9)), - ChannelSpec(13, "f0 ± 0.3222 ± 0.010", F0_O2, 8e6, "QH", 0.80, (0.3222e9, 0.010e9)), - ChannelSpec(14, "f0 ± 0.3222 ± 0.0045", F0_O2, 3e6, "QH", 1.20, (0.3222e9, 0.0045e9)), - ChannelSpec(15, "89.000 ± 1.0", 89.000e9, 1000e6, "QV", 0.50, (1.0e9,)), + ChannelSpec(1, "$23.8$", 23.8e9, 270e6, "Iv", 0.30), + ChannelSpec(2, "$31.4$", 31.4e9, 180e6, "Iv", 0.30), + ChannelSpec(3, "$50.3$", 50.3e9, 180e6, "Iv", 0.40), + ChannelSpec(4, "$52.8$", 52.8e9, 400e6, "Iv", 0.25), + ChannelSpec(5, "$53.596 \\pm 0.115$", 53.596e9, 170e6, "Ih", 0.25, (115e6,)), + ChannelSpec(6, "$54.4$", 54.4e9, 400e6, "Ih", 0.25), + ChannelSpec(7, "$54.94$", 54.94e9, 400e6, "Iv", 0.25), + ChannelSpec(8, "$55.5$", 55.5e9, 330e6, "Ih", 0.25), + ChannelSpec(9, "$f_0 = 57.290344$", 57.290344e9, 330e6, "Ih", 0.25), + ChannelSpec(10, "$f_0 \\pm 0.217$", 57.290344e9, 78e6, "Ih", 0.40, (217e6,)), + ChannelSpec(11, "$f_0 \\pm 0.3222 \\pm 0.048$", 57.290344e9, 6e6, "Ih", 0.40, (322.2e6, 48e6)), + ChannelSpec(12, "$f_0 \\pm 0.3222 \\pm 0.022$", 57.290344e9, 16e6, "Ih", 0.60, (322.2e6, 22e6)), + ChannelSpec(13, "$f_0 \\pm 0.3222 \\pm 0.01$", 57.290344e9, 8e6, "Ih", 0.80, (322.2e6, 10e6)), + ChannelSpec(14, "$f_0 \\pm 0.3222 \\pm 0.0045$", 57.290344e9, 3e6, "Ih", 1.20, (322.2e6, 4.5e6)), + ChannelSpec(15, "$89 \\pm 1$", 89e9, 1000e6, "Iv", 0.50, (1000e6,)), ] -# fmt: on - -def split_centers(reference_hz: float, offsets_hz: tuple[float, ...]) -> np.ndarray: - centers = np.array([reference_hz], dtype=float) - - for offset in offsets_hz: - centers = np.concatenate([centers - offset, centers + offset]) +CHANNEL_COUNT = len(CHANNELS) +SAMPLES_PER_LOBE = 11 +POS = [817e3, 0.0, 0.0] +LOS = [130.0, 0.0] +# fmt: on - return np.sort(centers) +def sensor_channels(channels, n): + """ Helper to turn the table above into channel responses """ + out = [] -def realized_channel(spec: ChannelSpec, samples_per_lobe: int = SAMPLES_PER_LOBE): - half_bw = 0.5 * spec.bandwidth_hz - local_df = np.linspace(-half_bw, half_bw, samples_per_lobe) - lobe_centers = split_centers(spec.reference_hz, spec.lo_offsets_hz) + for ch in channels: + # The range is [f_ref, 0], [f_ref, inf] + x = pa.arts.SensorHeterodyneFrequencyRange(ch.reference_hz, [0, np.inf]) - freq_grid = np.concatenate([center + local_df for center in lobe_centers]) - weights = np.ones(freq_grid.size, dtype=float) + # Zero or more mixes, which add more ranges follow. + for lo in ch.lo_offsets_hz: + x.mix(lo) - order = np.argsort(freq_grid) - freq_grid = freq_grid[order] - weights = weights[order] - weights /= weights.sum() + # The channel response is a simple boxcar with the specified bandwidth. + m = pa.arts.SensorBoxChannel(0, ch.bandwidth_hz / 2, n) + v = x.channel_response(m) - return { - "reference_hz": spec.reference_hz, - "lobe_centers_hz": lobe_centers, - "freq_grid_hz": freq_grid, - "weights": weights, - "dfreq_hz": freq_grid - spec.reference_hz, - } + out.append(v) + return out -def make_raw_sensor(spec: ChannelSpec, channel_data: dict): - raw = pa.arts.SortedGriddedField1() - raw.dataname = f"amsu-ch{spec.number}" - raw.gridnames = ["dfreq"] - raw.grids = [pa.arts.AscendingGrid(channel_data["dfreq_hz"])] - raw.data = channel_data["weights"] - return raw +def extract_channels(spectral_rad, measurement_sensor): + """ Helper to extract internal simulation grid for channels """ + out = [] -def compute_channel_spectral_rad(ws, pos, los, freq_grid_hz: np.ndarray) -> np.ndarray: - ws.freq_grid = pa.arts.AscendingGrid(freq_grid_hz) - ws.ray_pathGeometric(pos=pos, los=los, max_stepsize=1000.0) - ws.spectral_radClearskyEmission() - ws.spectral_radApplyUnitFromSpectralRadiance() - return np.asarray(ws.spectral_rad.value, dtype=float)[:, 0].copy() + for ch in measurement_sensor: + v = np.where(ch.weight_matrix.dense.flatten())[0] + out.append([np.array([ch.f_grid[i//4] for i in v]), spectral_rad.flatten()[v]]) + return out -pa.data.download() +# %% Download data and set up workspace ws = pa.workspace.Workspace() +pa.data.download() -# Keep the atmosphere intentionally simple. +# %% Use built-in spectroscopic data. ws.abs_speciesSet(species=["O2-PWR98", "H2O-PWR98"]) ws.ReadCatalogData() ws.spectral_propmat_agendaAuto() +# %% Set up a simple atmosphere and surface. ws.surf_fieldPlanet(option="Earth") -ws.surf_field[pa.arts.SurfaceKey("t")] = 290.0 +ws.surf_field[pa.arts.SurfaceKey("t")] = 300.0 ws.atm_fieldRead(toa=100e3, basename="planets/Earth/afgl/tropical/", missing_is_zero=1) -ws.spectral_rad_transform_operatorSet(option="Tb") +# %% Use a geometric path. ws.ray_path_observer_agendaSetGeometric() -# AMSU-A is a cross-track scanner, we take a single nadir view for simplicity. -pos = pa.arts.Vector3([833e3, 0.0, 0.0]) -los = pa.arts.Vector2([180.0, 0.0]) - -realized = {spec.number: realized_channel(spec) for spec in CHANNELS} - -# First plot: evaluate spectral radiance directly on each channel's own internal grid. -channel_spectra = {} -manual_measurement = [] -for spec in CHANNELS: - channel_data = realized[spec.number] - spectral_tb = compute_channel_spectral_rad( - ws, pos, los, channel_data["freq_grid_hz"]) - channel_spectra[spec.number] = spectral_tb - manual_measurement.append(np.dot(channel_data["weights"], spectral_tb)) - -manual_measurement = np.asarray(manual_measurement) - -# Second step: build the same channel set as an ARTS measurement sensor. -ws.measurement_sensorInit() -for spec in CHANNELS: - ws.measurement_sensorAddRawSensor( - freq_grid=pa.arts.AscendingGrid(np.array([spec.reference_hz])), - pos=pos, - los=los, - raw_sensor_perturbation=make_raw_sensor(spec, realized[spec.number]), - normalize=1, - ) -ws.measurement_sensor.normalize() +# %% Simple sensor setup: position, line-of-sight, polarization, and channels. +sensor = pa.arts.SensorBuilder(channels=sensor_channels(CHANNELS, SAMPLES_PER_LOBE)) +ws.measurement_sensor, ws.measurement_sensor_meta = sensor(POS, LOS) + +for i in range(len(ws.measurement_sensor)): + ws.measurement_sensor[i].normalize(CHANNELS[i].polarization) + +# %% Collect on a single grid to help plotting and speed-up calculations - this sacrifices Jacobian flexibilities ws.measurement_sensor.collect() +# %% Compute the sensor response in Planck units +ws.spectral_rad_transform_operatorSet(option="Tb") ws.measurement_vecFromSensor(kernel="High Performance") -measurement_vec = np.asarray(ws.measurement_vec) -colors = plt.get_cmap('tab20')(range(15)) +# %% Compute the internal frequency grid response for plotting +ws.freq_grid = ws.measurement_sensor[0].f_grid +ws.spectral_rad_observer_agendaExecute(obs_pos=POS, obs_los=LOS) +ws.spectral_radApplyUnitFromSpectralRadiance() + +# %% Plotting +ch = extract_channels(ws.spectral_rad, ws.measurement_sensor) +colors = plt.get_cmap('tab20')(range(CHANNEL_COUNT)) fig_internal, ax_internal = plt.subplots(figsize=(6, 4)) -for spec in CHANNELS: - channel_data = realized[spec.number] - ax_internal.plot( - channel_data["freq_grid_hz"] / 1e9, - channel_spectra[spec.number], - marker="o", - markersize=2.5, - linewidth=1.1, - label=f"Ch {spec.number}", - color=colors[spec.number - 1], - ) +for i in range(CHANNEL_COUNT): + ax_internal.plot(ch[i][0]/1e9, + ch[i][1], + "o:", + markersize=2.5, + linewidth=1.1, + label=f"Ch {CHANNELS[i].number}", + color=colors[i], + ) ax_internal.set_xlabel("Frequency [GHz]") ax_internal.set_ylabel("Brightness temperature (internal grid) [K]") @@ -177,29 +142,26 @@ def compute_channel_spectral_rad(ws, pos, los, freq_grid_hz: np.ndarray) -> np.n fig_channels, ax_channels = plt.subplots(figsize=(6, 4)) ax_channels.errorbar( [spec.number for spec in CHANNELS], - measurement_vec, + ws.measurement_vec, yerr=[spec.nedt_k for spec in CHANNELS], fmt="o", capsize=3, linewidth=1.0, ) -ax_channels.plot([spec.number for spec in CHANNELS], - measurement_vec, linewidth=1.0, alpha=0.8) ax_channels.set_xticks([spec.number for spec in CHANNELS]) ax_channels.set_xlabel("AMSU-A channel number") ax_channels.set_ylabel("Channel brightness [K]") ax_channels.set_title("AMSU-A-like channelized measurements") ax_channels.grid(True, alpha=0.3) -for x, spec, y in zip([spec.number for spec in CHANNELS], CHANNELS, measurement_vec): +for x, spec, y in zip([spec.number for spec in CHANNELS], CHANNELS, ws.measurement_vec): ax_channels.annotate( spec.spec_text, (x, y), textcoords="offset points", - xytext=(0, 7), - ha="center", - fontsize=7, - rotation=90, + xytext=(4, -2.5), + ha="left", + fontsize=6, ) fig_internal.tight_layout() @@ -208,8 +170,8 @@ def compute_channel_spectral_rad(ws, pos, los, freq_grid_hz: np.ndarray) -> np.n if "ARTS_HEADLESS" not in os.environ: plt.show() +ref = [294.813805535829, 297.18778420039365, 283.26228661223786, 262.0365042504266, 244.4836135697355, 226.2694109308727, 216.11284609241537, + 209.72414936956548, 210.3336073749979, 219.42825163097098, 230.06432799428234, 241.47556633001957, 252.6256887712745, 261.4587517221863, 292.20874260781585] -assert np.allclose(ws.measurement_vec, [289.290378, 289.54484543, 284.35054685, 273.08020996, - 259.90747748, 241.3598937, 228.07526569, 217.2986938, - 207.60407201, 213.70169277, 223.87462958, 235.45802133, - 246.81775696, 257.07536243, 289.1540646]) +assert np.allclose(ws.measurement_vec, ref), \ + f"Mismatch to reference simulations.\nReference: {ref}\nSimulations: {ws.measurement_vec:B,}" diff --git a/src/core/sensor/frequency_bandpass_filters.cpp b/src/core/sensor/frequency_bandpass_filters.cpp index af540608b4..821dd7a374 100644 --- a/src/core/sensor/frequency_bandpass_filters.cpp +++ b/src/core/sensor/frequency_bandpass_filters.cpp @@ -77,6 +77,22 @@ SortedGriddedField1 make_filter( .grid_names = std::array{"frequency"s}, .grids = std::array{AscendingGrid{grid}}}; } + +void deduplicate_zero_frequency_images( + std::vector>& samples) { + std::vector> unique_samples; + unique_samples.reserve(samples.size()); + + for (const auto& sample : samples) { + const bool duplicate = stdr::any_of(unique_samples, [&](const auto& kept) { + return is_close(kept.first, sample.first); + }); + + if (not duplicate) unique_samples.push_back(sample); + } + + samples = std::move(unique_samples); +} } // namespace Numeric BandpassFilter::operator()(Numeric f) const { @@ -106,6 +122,7 @@ FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( const auto& channel = channels[ichan]; const auto& channel_grid = channel.freq_grid(); std::vector> samples; + std::vector local_points; for (const auto& path : range.paths()) { if (channel_grid.empty()) continue; @@ -118,19 +135,44 @@ FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( if (local_low > local_high and not is_close(local_low, local_high)) continue; - std::vector local_points; add_support_points(local_points, channel_grid, local_low, local_high); for (const auto& filter : path.filters) { add_support_points( local_points, filter.grid<0>(), local_low, local_high); } - sort_unique(local_points); + } + + sort_unique(local_points); + + for (Numeric local_frequency : local_points) { + const Numeric channel_weight = + sample_filter(channel.channel, local_frequency); + if (channel_weight == 0.0) continue; + + std::vector> folded_samples; + folded_samples.reserve(range.size()); + + for (const auto& path : range.paths()) { + const Numeric path_weight = path.local_weight(local_frequency); + if (path_weight == 0.0) continue; + + folded_samples.emplace_back(path.map_to_global(local_frequency), + path_weight * channel_weight); + } + + if (folded_samples.empty()) continue; + + const Numeric fold_count = static_cast(folded_samples.size()); + for (auto& sample : folded_samples) { + sample.second /= fold_count; + } + + if (is_close(local_frequency, 0.0)) { + deduplicate_zero_frequency_images(folded_samples); + } - for (Numeric local_frequency : local_points) { - const Numeric weight = path.local_weight(local_frequency) * - sample_filter(channel.channel, local_frequency); - if (weight == 0.0) continue; - samples.emplace_back(path.map_to_global(local_frequency), weight); + for (const auto& sample : folded_samples) { + samples.push_back(sample); } } diff --git a/src/core/sensor/frequency_range_selection.cpp b/src/core/sensor/frequency_range_selection.cpp index bed7b3e3ae..bf3a575acc 100644 --- a/src/core/sensor/frequency_range_selection.cpp +++ b/src/core/sensor/frequency_range_selection.cpp @@ -37,6 +37,7 @@ constexpr Numeric response_eps = 64 * std::numeric_limits::epsilon(); Numeric scale(Numeric x) { return std::max(1.0, std::abs(x)); } bool is_close(Numeric a, Numeric b) { + if (std::isinf(a) or std::isinf(b)) return a == b; return std::abs(a - b) <= response_eps * std::max(scale(a), scale(b)); } diff --git a/src/core/sensor/frequency_range_selection.h b/src/core/sensor/frequency_range_selection.h index fa0589228d..5f1ad3d764 100644 --- a/src/core/sensor/frequency_range_selection.h +++ b/src/core/sensor/frequency_range_selection.h @@ -38,6 +38,8 @@ struct FrequencyResponsePath { struct FrequencyRange { static constexpr Numeric inf = FrequencyResponsePath::inf; + std::vector response_paths{{}}; + std::vector global_ranges{{0, inf}}; std::vector local_ranges{{0, inf}}; @@ -45,9 +47,6 @@ struct FrequencyRange { [[nodiscard]] const FrequencyResponsePath& path(Size index) const; [[nodiscard]] const std::vector& paths() const; - protected: - std::vector response_paths{{}}; - void sync_ranges(); }; diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index d43d79e71b..834d25ff6a 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -526,6 +526,7 @@ See :meth:`SensorObsel.normalize` for details. auto sch = py::class_(m, "SensorChannel"); sch.doc() = "Base class for relative spectrometer channel responses."; generic_interface(sch); + sch.def(py::init_implicit()); sch.def_prop_ro( "response", [](const sensor::Channel& self) -> const SortedGriddedField1& { @@ -618,21 +619,15 @@ See :meth:`SensorObsel.normalize` for details. }; }, "channels"_a, - "antenna"_a, - "Construct a sensor builder from channels and one antenna pattern.") - .def_prop_rw( + "antenna"_a = sensor::PencilBeamAntenna{}, + "Construct a sensor builder from channels and one antenna pattern (defaults to pencil-beam).") + .def_rw( "channels", - [](const sensor::SensorBuilder& self) { return self.channels; }, - [](sensor::SensorBuilder& self, - const std::vector& channels) { - self.channels = channels; - }, + &sensor::SensorBuilder::channels, "Spectrometer channels.\n\n.. :class:`list[~pyarts3.arts.SensorChannel]`") - .def_prop_rw( + .def_rw( "antenna", - [](const sensor::SensorBuilder& self) { return self.antenna; }, - [](sensor::SensorBuilder& self, - const sensor::AntennaPattern& antenna) { self.antenna = antenna; }, + &sensor::SensorBuilder::antenna, "Angular antenna response.\n\n.. :class:`~pyarts3.arts.SensorAntennaPattern`") .def( "__call__", @@ -655,6 +650,16 @@ See :meth:`SensorObsel.normalize` for details. Each ``pos[i]`` is combined with ``los[i]``. The returned obsels are ordered by geometry first and channel second, and the returned value is ``(measurement_sensor, measurement_sensor_meta)``.)") + .def( + "__call__", + [](const sensor::SensorBuilder& self, + const Vector3 pos, + const Vector2 los) { + return self(std::span{&pos, 1}, std::span{&los, 1}); + }, + "pos"_a, + "los"_a, + R"(Build sensor obsels and metadata from paired position and bore-LOS.)") .def( "__call__", [](const sensor::SensorBuilder& self, @@ -683,7 +688,7 @@ geometry first and channel second, and the returned value is shdfr.def( py::init(), "lo"_a, - "bandpass"_a, + "bandpass"_a = Vector2{0.0, std::numeric_limits::infinity()}, R"(Construct a heterodyne response from one ideal bandpass and one LO stage. This is shorthand for creating an empty object, applying :func:`bandpass`, and diff --git a/tests/core/sensor/heterodyne_frequency_response.py b/tests/core/sensor/heterodyne_frequency_response.py index 94e21f1d4b..a1c35b9e36 100644 --- a/tests/core/sensor/heterodyne_frequency_response.py +++ b/tests/core/sensor/heterodyne_frequency_response.py @@ -105,6 +105,48 @@ def test_lowpass_then_lo_then_box_spectrometer(): ) +def test_default_positive_range_survives_mixes(): + selector = pyarts.arts.SensorHeterodyneFrequencyRange() + selector.mix(10.0) + + assert_close( + "default positive range after one LO global_ranges", + ranges_as_lists(selector.global_ranges), + [[10.0, np.inf], [10.0, 0.0]], + ) + assert_close( + "default positive range after one LO local_ranges", + ranges_as_lists(selector.local_ranges), + [[0.0, np.inf], [0.0, 10.0]], + ) + + selector.mix(1.0) + + assert_close( + "default positive range after two LOs global_ranges", + ranges_as_lists(selector.global_ranges), + [[11.0, np.inf], [11.0, 10.0], [9.0, 0.0], [9.0, 10.0]], + ) + assert_close( + "default positive range after two LOs local_ranges", + ranges_as_lists(selector.local_ranges), + [[0.0, np.inf], [0.0, 1.0], [0.0, 9.0], [0.0, 1.0]], + ) + + response = selector.channel_response(pyarts.arts.SensorDiracChannel(0.25)) + + assert_close( + "default positive range dirac points after two LOs", + np.array(response.grids[0]), + np.array([8.75, 9.25, 10.75, 11.25]), + ) + assert_close( + "default positive range dirac weights after two LOs", + np.array(response.data), + np.full(4, 0.25), + ) + + def test_bandpass_lo_bandpass_lo_chain(): selector = pyarts.arts.SensorHeterodyneFrequencyRange() selector.bandpass(np.array([5.0, 15.0])) @@ -131,7 +173,7 @@ def test_bandpass_lo_bandpass_lo_chain(): assert_close( "bandpass -> LO -> bandpass -> LO channel weights", np.array(response.data), - np.array([1.0 / 3.0, 1.0 / 3.0, 2.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]), + np.full(5, 1.0 / 6.0), ) @@ -195,7 +237,26 @@ def test_weighted_split_about_lo(): assert_close( "weighted split channel weights", np.array(response.data), - np.array([0.6, 0.6]), + np.array([0.3, 0.3]), + ) + + +def test_zero_frequency_is_not_double_counted(): + selector = pyarts.arts.SensorHeterodyneFrequencyRange() + selector.bandpass(np.array([5.0, 15.0])) + selector.mix(10.0) + + response = selector.channel_response(pyarts.arts.SensorDiracChannel(0.0)) + + assert_close( + "zero-frequency channel point", + np.array(response.grids[0]), + np.array([10.0]), + ) + assert_close( + "zero-frequency channel weight", + np.array(response.data), + np.array([0.5]), ) @@ -240,9 +301,9 @@ def test_overlapping_sidebands_with_asymmetric_bandpass(): np.array([7.0, 13.0]), ] expected_weights = [ - np.array([db_to_lin(-10.0), db_to_lin(1.0)]), - np.array([db_to_lin(-10.0), db_to_lin(5.0)]), - np.array([db_to_lin(-10.0), db_to_lin(10.0)]), + np.array([db_to_lin(-10.0) / 2.0, db_to_lin(1.0) / 2.0]), + np.array([db_to_lin(-10.0) / 2.0, db_to_lin(5.0) / 2.0]), + np.array([db_to_lin(-10.0) / 2.0, db_to_lin(10.0) / 2.0]), ] for index, response in enumerate(responses): @@ -259,9 +320,11 @@ def test_overlapping_sidebands_with_asymmetric_bandpass(): test_lowpass_then_lo_then_box_spectrometer() +test_default_positive_range_survives_mixes() test_bandpass_lo_bandpass_lo_chain() test_ideal_split_about_lo() test_weighted_split_about_lo() +test_zero_frequency_is_not_double_counted() test_overlapping_sidebands_with_asymmetric_bandpass() print("\nAll heterodyne frequency-response checks passed.") \ No newline at end of file From 561701ead4c7c1f2e18dbec9a6f886b419947651 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 11 May 2026 14:47:04 +0900 Subject: [PATCH 09/21] Fix name --- examples/6-satellite-sensors/amsu-a.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/6-satellite-sensors/amsu-a.py b/examples/6-satellite-sensors/amsu-a.py index 77c15a2e0a..2542684519 100644 --- a/examples/6-satellite-sensors/amsu-a.py +++ b/examples/6-satellite-sensors/amsu-a.py @@ -44,10 +44,10 @@ class ChannelSpec: ChannelSpec(15, "$89 \\pm 1$", 89e9, 1000e6, "Iv", 0.50, (1000e6,)), ] -CHANNEL_COUNT = len(CHANNELS) -SAMPLES_PER_LOBE = 11 -POS = [817e3, 0.0, 0.0] -LOS = [130.0, 0.0] +CHANNEL_COUNT = len(CHANNELS) +SAMPLES_PER_CHANNEL = 11 +POS = [817e3, 0.0, 0.0] +LOS = [130.0, 0.0] # fmt: on @@ -101,7 +101,7 @@ def extract_channels(spectral_rad, measurement_sensor): ws.ray_path_observer_agendaSetGeometric() # %% Simple sensor setup: position, line-of-sight, polarization, and channels. -sensor = pa.arts.SensorBuilder(channels=sensor_channels(CHANNELS, SAMPLES_PER_LOBE)) +sensor = pa.arts.SensorBuilder(channels=sensor_channels(CHANNELS, SAMPLES_PER_CHANNEL)) ws.measurement_sensor, ws.measurement_sensor_meta = sensor(POS, LOS) for i in range(len(ws.measurement_sensor)): From 6bc8e8b32e4e8f4b7da6f351d52211365a0da13a Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 11 May 2026 16:17:08 +0900 Subject: [PATCH 10/21] Test env update --- environment-dev-linux-clang.yml | 1 + environment-dev-linux.yml | 1 + environment-dev-mac.yml | 1 + environment-dev-win.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/environment-dev-linux-clang.yml b/environment-dev-linux-clang.yml index a372b2abc6..cc9e044f98 100644 --- a/environment-dev-linux-clang.yml +++ b/environment-dev-linux-clang.yml @@ -28,6 +28,7 @@ dependencies: - ninja - numpy>=2 - openblas=*=*openmp* + - pip - pkg-config - python-build - requests diff --git a/environment-dev-linux.yml b/environment-dev-linux.yml index 8db52dc93f..83447bca42 100644 --- a/environment-dev-linux.yml +++ b/environment-dev-linux.yml @@ -22,6 +22,7 @@ dependencies: - ninja - numpy>=2 - openblas=*=*openmp* + - pip - pkg-config - python-build - requests diff --git a/environment-dev-mac.yml b/environment-dev-mac.yml index 93117c491e..2e4a4e3db8 100644 --- a/environment-dev-mac.yml +++ b/environment-dev-mac.yml @@ -25,6 +25,7 @@ dependencies: - netcdf4 - ninja - numpy>=2 + - pip - pkg-config - python-build - requests diff --git a/environment-dev-win.yml b/environment-dev-win.yml index 7186f4947d..7ea7063c80 100644 --- a/environment-dev-win.yml +++ b/environment-dev-win.yml @@ -17,6 +17,7 @@ dependencies: - ninja - numpy>=2 - openblas + - pip - pkg-config - python-build - requests From 71e6e7eeecd42878223c29ac0126b9e0853b90fe Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Tue, 12 May 2026 10:59:51 +0900 Subject: [PATCH 11/21] Make AntennaPattern the frontend --- src/core/sensor/antenna_pattern.cpp | 54 +++++++++-- src/core/sensor/antenna_pattern.h | 29 +++--- src/core/sensor/sensor_builder.cpp | 76 ++++----------- src/python_interface/py_sensor.cpp | 14 ++- src/tests/test_antenna_pattern.cc | 94 ------------------- src/tests/test_sensor_builder.cc | 28 ++++-- tests/core/sensor/antenna_pattern_response.py | 63 ------------- 7 files changed, 108 insertions(+), 250 deletions(-) delete mode 100644 tests/core/sensor/antenna_pattern_response.py diff --git a/src/core/sensor/antenna_pattern.cpp b/src/core/sensor/antenna_pattern.cpp index 8b36cbfceb..40b2a43b30 100644 --- a/src/core/sensor/antenna_pattern.cpp +++ b/src/core/sensor/antenna_pattern.cpp @@ -4,10 +4,13 @@ #include #include +#include namespace sensor { namespace { +using AntennaSamples = std::vector>; + struct AntennaBasis { Vector3 v; Vector3 h; @@ -81,6 +84,38 @@ struct AntennaBasis { return out; } + +std::shared_ptr make_poslos_grid( + const Vector3& pos, const AntennaSamples& antenna_samples) { + auto out = std::make_shared(antenna_samples.size()); + + for (Size i = 0; i < antenna_samples.size(); ++i) { + (*out)[i] = {.pos = pos, .los = antenna_samples[i].second}; + } + + return out; +} + +SparseStokvecMatrix make_weight_matrix(const AntennaSamples& antenna_samples, + const Channel& channel) { + const auto& channel_weights = channel.weights(); + + SparseStokvecMatrix out(antenna_samples.size(), channel_weights.size()); + + for (Size iposlos = 0; iposlos < antenna_samples.size(); ++iposlos) { + const auto& [antenna_weight, ignored_los] = antenna_samples[iposlos]; + static_cast(ignored_los); + + if (antenna_weight.is_zero()) continue; + + for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { + if (channel_weights[ifreq] == 0.0) continue; + out[iposlos, ifreq] = channel_weights[ifreq] * antenna_weight; + } + } + + return out; +} } // namespace PencilBeamAntenna::PencilBeamAntenna(Stokvec weight) @@ -100,17 +135,18 @@ GaussianAntenna::GaussianAntenna(ZenGrid zen_grid, azimuth_std, weight)) {} -std::vector> AntennaPattern::operator()( - Vector2 bore_los) const { +Obsel AntennaPattern::operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const { ARTS_USER_ERROR_IF(not data.ok(), "SensorAntennaPattern data shape does not match its grids") const auto& zen_grid = data.grid<0>(); const auto& azi_grid = data.grid<1>(); - std::vector> out; - out.reserve(static_cast(zen_grid.size()) * - static_cast(azi_grid.size())); + std::vector> antenna_samples; + antenna_samples.reserve(static_cast(zen_grid.size()) * + static_cast(azi_grid.size())); const auto basis = antenna_basis(bore_los); @@ -119,11 +155,15 @@ std::vector> AntennaPattern::operator()( const Vector3 local = antenna_frame_los({zen_grid[izen], azi_grid[iazi]}); const Vector3 enu = normalized(local[0] * basis.v + local[1] * basis.h + local[2] * basis.k); - out.emplace_back(data[izen, iazi], enu2los(enu)); + antenna_samples.emplace_back(data[izen, iazi], enu2los(enu)); } } - return out; + return { + std::make_shared(channel.freq_grid()), + make_poslos_grid(pos, antenna_samples), + make_weight_matrix(antenna_samples, channel), + }; } static_assert(AntennaPatternSelection); diff --git a/src/core/sensor/antenna_pattern.h b/src/core/sensor/antenna_pattern.h index d4fb10586d..989ab962bf 100644 --- a/src/core/sensor/antenna_pattern.h +++ b/src/core/sensor/antenna_pattern.h @@ -3,6 +3,11 @@ #include #include +#include + +#include "frequency_channel_selection.h" +#include "obsel.h" + namespace sensor { //! A 2D angular antenna pattern on local zenith and azimuth offsets. struct AntennaPattern; @@ -16,20 +21,13 @@ struct GaussianAntenna; //! 2D gridded field of antenna weights on local zenith and azimuth offsets. using AntennaPatternField = matpack::gridded_data_t; -//! Concept for selecting antenna patterns. -template -concept AntennaPatternSelection = std::derived_from; - struct AntennaPattern { AntennaPatternField data; // center at [0, 0] - //! Maps the local antenna pattern to global LOS values around a new bore LOS. - //! - //! Local zenith 0 points along the bore. Local azimuth 0 points toward - //! increasing global zenith, and local azimuth 90 points toward increasing - //! global azimuth. - [[nodiscard]] std::vector> operator()( - Vector2 bore_los) const; + //! Builds one sensor obsel for a channel at a sensor position and bore LOS. + [[nodiscard]] Obsel operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const; }; struct PencilBeamAntenna final : AntennaPattern { @@ -43,6 +41,15 @@ struct GaussianAntenna final : AntennaPattern { Numeric azimuth_std, Stokvec weight = {1.0, 0.0, 0.0, 0.0}); }; + +//! Concept for types that can build a sensor obsel from channel geometry. +template +concept AntennaPatternSelection = requires(const T& antenna, + const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) { + { antenna(channel, pos, bore_los) } -> std::same_as; +}; } // namespace sensor // AntennaPattern format tags and XML I/O diff --git a/src/core/sensor/sensor_builder.cpp b/src/core/sensor/sensor_builder.cpp index eeee116eec..793e02ceea 100644 --- a/src/core/sensor/sensor_builder.cpp +++ b/src/core/sensor/sensor_builder.cpp @@ -7,40 +7,6 @@ namespace sensor { namespace { -using AntennaSamples = std::vector>; - -std::shared_ptr make_poslos_grid( - const Vector3& pos, const AntennaSamples& antenna_samples) { - auto out = std::make_shared(antenna_samples.size()); - - for (Size i = 0; i < antenna_samples.size(); ++i) { - (*out)[i] = {.pos = pos, .los = antenna_samples[i].second}; - } - - return out; -} - -SparseStokvecMatrix make_weight_matrix(const AntennaSamples& antenna_samples, - const Channel& channel) { - const auto& channel_weights = channel.weights(); - - SparseStokvecMatrix out(antenna_samples.size(), channel_weights.size()); - - for (Size iposlos = 0; iposlos < antenna_samples.size(); ++iposlos) { - const auto& [antenna_weight, ignored_los] = antenna_samples[iposlos]; - static_cast(ignored_los); - - if (antenna_weight.is_zero()) continue; - - for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { - if (channel_weights[ifreq] == 0.0) continue; - out[iposlos, ifreq] = channel_weights[ifreq] * antenna_weight; - } - } - - return out; -} - SensorMetaInfo make_meta_info(Size nchannels, Size geometry_index) { SortedGriddedField1 gf; gf.data_name = std::format("sensor-builder-{}", geometry_index); @@ -78,38 +44,34 @@ std::pair SensorBuilder::operator()( std::make_shared(channel.freq_grid())); } - std::vector antenna_samples; - antenna_samples.reserve(los.size()); - for (const auto& bore_los : los) antenna_samples.push_back(antenna(bore_los)); - - std::vector> weight_cache(los.size()); - for (Size ilos = 0; ilos < los.size(); ++ilos) { - auto& cached = weight_cache[ilos]; - cached.reserve(channels.size()); - for (const auto& channel : channels) { - cached.push_back(make_weight_matrix(antenna_samples[ilos], channel)); - } - } - ArrayOfSensorObsel out; out.reserve(pos.size() * channels.size()); ArrayOfSensorMetaInfo meta; meta.reserve(pos.size()); - const auto append_geometry = - [&](const Vector3& sensor_pos, Size ilos, Size geometry_index) { - auto poslos_grid = make_poslos_grid(sensor_pos, antenna_samples[ilos]); + const auto append_geometry = [&](const Vector3& sensor_pos, + const Vector2& bore_los, + Size geometry_index) { + std::shared_ptr poslos_grid; + + for (Size ichan = 0; ichan < channels.size(); ++ichan) { + auto obsel = antenna(channels[ichan], sensor_pos, bore_los); + obsel.set_f_grid_ptr(freq_grids[ichan]); - for (Size ichan = 0; ichan < channels.size(); ++ichan) { - out.emplace_back( - freq_grids[ichan], poslos_grid, weight_cache[ilos][ichan]); - } + if (not poslos_grid) { + poslos_grid = obsel.poslos_grid_ptr(); + } else { + obsel.set_poslos_grid_ptr(poslos_grid); + } + + out.emplace_back(std::move(obsel)); + } - meta.push_back(make_meta_info(channels.size(), geometry_index)); - }; + meta.push_back(make_meta_info(channels.size(), geometry_index)); + }; - for (Size i = 0; i < pos.size(); ++i) append_geometry(pos[i], i, i); + for (Size i = 0; i < pos.size(); ++i) append_geometry(pos[i], los[i], i); return {std::move(out), std::move(meta)}; } diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 834d25ff6a..9f786adcd4 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -491,14 +491,12 @@ See :meth:`SensorObsel.normalize` for details. }, py::rv_policy::reference_internal, "Local antenna response field.\n\n.. :class:`~pyarts3.arts.SensorAntennaPatternField`") - .def( - "__call__", - &sensor::AntennaPattern::operator(), - "bore_los"_a, - R"(Map the local antenna pattern onto global LOS values around ``bore_los``. - - Returns a list of ``(weight, los)`` pairs, where ``weight`` is a :class:`Stokvec` - and ``los`` is the mapped :class:`Vector2`.)"); + .def("__call__", + &sensor::AntennaPattern::operator(), + "channel"_a, + "pos"_a, + "bore_los"_a, + R"(Map the local antenna pattern onto observation elements)"); generic_interface(sap); auto spp = py::class_( diff --git a/src/tests/test_antenna_pattern.cc b/src/tests/test_antenna_pattern.cc index 5e64e11b19..e44742b40c 100644 --- a/src/tests/test_antenna_pattern.cc +++ b/src/tests/test_antenna_pattern.cc @@ -4,22 +4,6 @@ #include namespace { -Numeric angle_error(Numeric actual, Numeric expected) { - return std::abs(std::remainder(actual - expected, 360.0)); -} - -void assert_los(Vector2 actual, - Vector2 expected, - Numeric tol, - std::string_view name) { - ARTS_USER_ERROR_IF(std::abs(actual[0] - expected[0]) > tol or - angle_error(actual[1], expected[1]) > tol, - "{} mismatch: actual {} expected {}", - name, - actual, - expected) -} - void assert_stokvec(Stokvec actual, Stokvec expected, Numeric tol, @@ -34,81 +18,6 @@ void assert_stokvec(Stokvec actual, } } -sensor::AntennaPattern pattern(ZenGrid zen_grid, AziGrid azi_grid) { - sensor::AntennaPattern out; - out.data.grid<0>() = std::move(zen_grid); - out.data.grid<1>() = std::move(azi_grid); - out.data.resize(out.data.grid<0>().size(), out.data.grid<1>().size()); - - for (Size izen = 0; izen < out.data.grid<0>().size(); ++izen) { - for (Size iazi = 0; iazi < out.data.grid<1>().size(); ++iazi) { - out.data[izen, iazi] = {Numeric(10 * izen + iazi + 1), 0.0, 0.0, 0.0}; - } - } - - return out; -} - -void test_local_wraparound_maps_across_bore() { - auto ant = pattern(ZenGrid{{1.0}}, AziGrid{{0.0, 180.0}}); - - const auto samples = ant({45.0, 30.0}); - const auto first_weight = ant.data[0, 0]; - const auto second_weight = ant.data[0, 1]; - - ARTS_USER_ERROR_IF( - samples.size() != 2, "Expected 2 antenna samples, got {}", samples.size()) - ARTS_USER_ERROR_IF(samples[0].first != first_weight, - "First Stokvec changed during LOS mapping") - ARTS_USER_ERROR_IF(samples[1].first != second_weight, - "Second Stokvec changed during LOS mapping") - - assert_los(samples[0].second, {46.0, 30.0}, 1e-12, "local [1, 0]"); - assert_los(samples[1].second, {44.0, 30.0}, 1e-12, "local [1, 180]"); -} - -void test_bore_at_zenith_keeps_defined_local_azimuth() { - auto ant = pattern(ZenGrid{{1.0}}, AziGrid{{0.0, 90.0, 180.0, 270.0}}); - - const auto samples = ant({0.0, 15.0}); - - ARTS_USER_ERROR_IF( - samples.size() != 4, "Expected 4 antenna samples, got {}", samples.size()) - - assert_los(samples[0].second, {1.0, 15.0}, 1e-12, "pole local [1, 0]"); - assert_los(samples[1].second, {1.0, 105.0}, 1e-12, "pole local [1, 90]"); - assert_los(samples[2].second, {1.0, 195.0}, 1e-12, "pole local [1, 180]"); - assert_los(samples[3].second, {1.0, 285.0}, 1e-12, "pole local [1, 270]"); -} - -void test_pencil_beam_defaults_and_custom_weight() { - const sensor::PencilBeamAntenna def{}; - - ARTS_USER_ERROR_IF( - def.data.grid<0>().size() != 1 or def.data.grid<1>().size() != 1, - "Pencil beam must be 1x1") - ARTS_USER_ERROR_IF( - def.data.grid<0>()[0] != 0.0 or def.data.grid<1>()[0] != 0.0, - "Pencil beam grid must be centered at [0, 0]") - assert_stokvec( - def.data[0, 0], {1.0, 0.0, 0.0, 0.0}, 0.0, "default pencil beam weight"); - - const auto def_samples = def({45.0, 30.0}); - ARTS_USER_ERROR_IF(def_samples.size() != 1, - "Default pencil beam should return one sample") - assert_stokvec(def_samples[0].first, - {1.0, 0.0, 0.0, 0.0}, - 0.0, - "default pencil beam mapped weight"); - assert_los( - def_samples[0].second, {45.0, 30.0}, 1e-12, "default pencil beam LOS"); - - const Stokvec custom_weight{0.5, 0.25, 0.0, 0.0}; - const sensor::PencilBeamAntenna custom{custom_weight}; - assert_stokvec( - custom.data[0, 0], custom_weight, 0.0, "custom pencil beam weight"); -} - void test_gaussian_initialization_uses_antenna_frame_offsets() { const Stokvec peak_weight{2.0, 1.0, 0.0, 0.0}; const sensor::GaussianAntenna ant{ @@ -130,9 +39,6 @@ void test_gaussian_initialization_uses_antenna_frame_offsets() { } // namespace int main() { - test_local_wraparound_maps_across_bore(); - test_bore_at_zenith_keeps_defined_local_azimuth(); - test_pencil_beam_defaults_and_custom_weight(); test_gaussian_initialization_uses_antenna_frame_offsets(); return 0; } \ No newline at end of file diff --git a/src/tests/test_sensor_builder.cc b/src/tests/test_sensor_builder.cc index d23248a300..8d359b6188 100644 --- a/src/tests/test_sensor_builder.cc +++ b/src/tests/test_sensor_builder.cc @@ -29,20 +29,19 @@ void test_sensor_builder_returns_meta_per_geometry() { const auto [obsels, meta] = builder(pos, los); - ARTS_USER_ERROR_IF(obsels.size() != 4, - "Expected 4 obsels, got {}", - obsels.size()) - ARTS_USER_ERROR_IF(meta.size() != 2, - "Expected 2 meta entries, got {}", - meta.size()) + ARTS_USER_ERROR_IF( + obsels.size() != 4, "Expected 4 obsels, got {}", obsels.size()) + ARTS_USER_ERROR_IF( + meta.size() != 2, "Expected 2 meta entries, got {}", meta.size()) ARTS_USER_ERROR_IF(meta[0].count() != 2 or meta[1].count() != 2, "Each meta block must describe one 2-channel geometry") const auto& gf0 = std::get(meta[0].data); const auto& gf1 = std::get(meta[1].data); - ARTS_USER_ERROR_IF(gf0.gridname<0>() != "channel" or gf1.gridname<0>() != "channel", - "SensorBuilder meta grid must be the channel axis") + ARTS_USER_ERROR_IF( + gf0.gridname<0>() != "channel" or gf1.gridname<0>() != "channel", + "SensorBuilder meta grid must be the channel axis") assert_close(gf0.grid<0>()[0], 0.0, 0.0, "meta[0] channel index 0"); assert_close(gf0.grid<0>()[1], 1.0, 0.0, "meta[0] channel index 1"); @@ -50,6 +49,14 @@ void test_sensor_builder_returns_meta_per_geometry() { "First geometry position mismatch") ARTS_USER_ERROR_IF(obsels[2].poslos_grid()[0].pos != pos[1], "Second geometry position mismatch") + ARTS_USER_ERROR_IF(not obsels[0].same_poslos(obsels[1]), + "Obsels from the same geometry must share poslos") + ARTS_USER_ERROR_IF(not obsels[2].same_poslos(obsels[3]), + "Obsels from the same geometry must share poslos") + ARTS_USER_ERROR_IF(not obsels[0].same_freqs(obsels[2]), + "Obsels for the same channel must share frequencies") + ARTS_USER_ERROR_IF(not obsels[1].same_freqs(obsels[3]), + "Obsels for the same channel must share frequencies") } void test_sensor_builder_rejects_mismatched_geometry_counts() { @@ -68,8 +75,9 @@ void test_sensor_builder_rejects_mismatched_geometry_counts() { threw = true; } - ARTS_USER_ERROR_IF(not threw, - "SensorBuilder must reject mismatching position and LOS counts") + ARTS_USER_ERROR_IF( + not threw, + "SensorBuilder must reject mismatching position and LOS counts") } } // namespace diff --git a/tests/core/sensor/antenna_pattern_response.py b/tests/core/sensor/antenna_pattern_response.py deleted file mode 100644 index 6f0d7d9807..0000000000 --- a/tests/core/sensor/antenna_pattern_response.py +++ /dev/null @@ -1,63 +0,0 @@ -import numpy as np -import pyarts3 as pyarts - - -def assert_close(name, got, expected, atol=1e-12): - got = np.asarray(got, dtype=float) - expected = np.asarray(expected, dtype=float) - print(f"{name}: got = {got}") - print(f"{name}: expected = {expected}") - np.testing.assert_allclose(got, expected, atol=atol, rtol=0.0) - - -def test_pencil_beam_binding(): - antenna = pyarts.arts.SensorPencilBeamAntenna() - response = antenna.response - - assert response.ok() - assert_close("pencil zenith grid", response.grids[0], np.array([0.0])) - assert_close("pencil azimuth grid", response.grids[1], np.array([0.0])) - assert_close("pencil response weight", - response.data[0, 0], np.array([1.0, 0.0, 0.0, 0.0])) - - samples = antenna(np.array([45.0, 30.0])) - assert len(samples) == 1 - weight, los = samples[0] - - assert_close("pencil mapped weight", weight, np.array([1.0, 0.0, 0.0, 0.0])) - assert_close("pencil mapped los", los, np.array([45.0, 30.0])) - - -def test_gaussian_binding_and_mapping(): - peak_weight = pyarts.arts.Stokvec([2.0, 1.0, 0.0, 0.0]) - antenna = pyarts.arts.SensorGaussianAntenna( - pyarts.arts.ZenGrid(np.array([0.0, 1.0])), - pyarts.arts.AziGrid(np.array([0.0, 180.0])), - 1.0, - 2.0, - peak_weight, - ) - response = antenna.response - - assert response.ok() - assert_close("gaussian peak weight", response.data[0, 0], np.array(peak_weight)) - - expected_offset_weight = np.exp(-0.5) * np.array(peak_weight) - assert_close("gaussian [1, 0] weight", response.data[1, 0], expected_offset_weight) - assert_close("gaussian [1, 180] weight", - response.data[1, 1], expected_offset_weight) - - samples = antenna(np.array([45.0, 30.0])) - assert len(samples) == 4 - - assert_close("gaussian bore los", samples[0][1], np.array([45.0, 30.0])) - assert_close("gaussian local [1, 0] los", samples[2][1], np.array([46.0, 30.0])) - assert_close("gaussian local [1, 180] los", samples[3][1], np.array([44.0, 30.0])) - assert_close("gaussian local [1, 0] mapped weight", - samples[2][0], expected_offset_weight) - assert_close("gaussian local [1, 180] mapped weight", - samples[3][0], expected_offset_weight) - - -test_pencil_beam_binding() -test_gaussian_binding_and_mapping() From 1fd8ff24bc3abffa172d5241246597f891a80803 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Wed, 13 May 2026 18:27:29 +0900 Subject: [PATCH 12/21] Airy but Gauss --- src/core/sensor/antenna_pattern.cpp | 202 +++++++++++++++++++++------- src/core/sensor/antenna_pattern.h | 112 ++++++++++++++- src/core/sensor/sensor_builder.cpp | 35 ++++- src/core/sensor/sensor_builder.h | 13 +- src/python_interface/py_sensor.cpp | 70 ++++++---- src/tests/test_antenna_pattern.cc | 107 +++++++++++++++ src/tests/test_sensor_builder.cc | 76 +++++++++-- 7 files changed, 529 insertions(+), 86 deletions(-) diff --git a/src/core/sensor/antenna_pattern.cpp b/src/core/sensor/antenna_pattern.cpp index 40b2a43b30..602a027e97 100644 --- a/src/core/sensor/antenna_pattern.cpp +++ b/src/core/sensor/antenna_pattern.cpp @@ -1,5 +1,6 @@ #include "antenna_pattern.h" +#include #include #include @@ -9,7 +10,8 @@ namespace sensor { namespace { -using AntennaSamples = std::vector>; +constexpr Numeric gaussian_airy_hwhm_factor = + 3.8317059702075123156 / Constant::pi; struct AntennaBasis { Vector3 v; @@ -85,88 +87,192 @@ struct AntennaBasis { return out; } -std::shared_ptr make_poslos_grid( - const Vector3& pos, const AntennaSamples& antenna_samples) { - auto out = std::make_shared(antenna_samples.size()); +[[nodiscard]] Numeric gaussian_airy_std(Numeric frequency, + Numeric aperture_diameter) { + const Numeric wavelength = Constant::speed_of_light / frequency; + const Numeric hwhm_deg = Conversion::rad2deg(gaussian_airy_hwhm_factor * + wavelength / aperture_diameter); - for (Size i = 0; i < antenna_samples.size(); ++i) { - (*out)[i] = {.pos = pos, .los = antenna_samples[i].second}; + return Conversion::hwhm2std(hwhm_deg); +} + +std::shared_ptr make_single_poslos_grid( + const Vector3& pos, const Vector2& los) { + return std::make_shared(1, PosLos{.pos = pos, .los = los}); +} +} // namespace + +PencilBeamAntenna::PencilBeamAntenna(Stokvec weight) : weight(weight) {} + +Obsel PencilBeamAntenna::operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const { + const auto& channel_weights = channel.weights(); + const auto freq_grid = + std::make_shared(channel.freq_grid()); + SparseStokvecMatrix weight_matrix(1, channel_weights.size()); + + if (not weight.is_zero()) { + for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { + if (channel_weights[ifreq] == 0.0) continue; + weight_matrix[0, ifreq] = channel_weights[ifreq] * weight; + } } - return out; + return {freq_grid, + make_single_poslos_grid(pos, bore_los), + std::move(weight_matrix)}; } -SparseStokvecMatrix make_weight_matrix(const AntennaSamples& antenna_samples, - const Channel& channel) { +std::shared_ptr PencilBeamAntenna::clone() const { + return std::make_shared(*this); +} + +Obsel GriddedAntennaPattern::operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const { + ARTS_USER_ERROR_IF( + not data.ok(), + "SensorGriddedAntennaPattern data shape does not match its grids") + + const auto& zen_grid = data.grid<0>(); + const auto& azi_grid = data.grid<1>(); const auto& channel_weights = channel.weights(); + const auto freq_grid = + std::make_shared(channel.freq_grid()); - SparseStokvecMatrix out(antenna_samples.size(), channel_weights.size()); + const Size nsamples = zen_grid.size() * azi_grid.size(); + auto poslos_grid = std::make_shared(nsamples); + SparseStokvecMatrix weight_matrix(nsamples, channel_weights.size()); - for (Size iposlos = 0; iposlos < antenna_samples.size(); ++iposlos) { - const auto& [antenna_weight, ignored_los] = antenna_samples[iposlos]; - static_cast(ignored_los); + const auto basis = antenna_basis(bore_los); + Size isample = 0; - if (antenna_weight.is_zero()) continue; + for (Size izen = 0; izen < zen_grid.size(); ++izen) { + for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { + const Vector3 local = antenna_frame_los({zen_grid[izen], azi_grid[iazi]}); + const Vector3 enu = normalized(local[0] * basis.v + local[1] * basis.h + + local[2] * basis.k); + (*poslos_grid)[isample] = {.pos = pos, .los = enu2los(enu)}; + + const auto& antenna_weight = data[izen, iazi]; + if (not antenna_weight.is_zero()) { + for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { + if (channel_weights[ifreq] == 0.0) continue; + weight_matrix[isample, ifreq] = + channel_weights[ifreq] * antenna_weight; + } + } - for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { - if (channel_weights[ifreq] == 0.0) continue; - out[iposlos, ifreq] = channel_weights[ifreq] * antenna_weight; + ++isample; } } - return out; + return {freq_grid, poslos_grid, std::move(weight_matrix)}; } -} // namespace -PencilBeamAntenna::PencilBeamAntenna(Stokvec weight) - : AntennaPattern({.data_name = "pencil beam"s, - .data = StokvecMatrix(1, 1, weight), - .grid_names = std::array{"zenith"s, "azimuth"s}, - .grids = {ZenGrid{Vector{0.0}}, AziGrid{Vector{0.0}}}}) {} +std::shared_ptr GriddedAntennaPattern::clone() const { + return std::make_shared(*this); +} GaussianAntenna::GaussianAntenna(ZenGrid zen_grid, AziGrid azi_grid, Numeric zenith_std, Numeric azimuth_std, + Stokvec weight) { + data = make_gaussian_field(std::move(zen_grid), + std::move(azi_grid), + zenith_std, + azimuth_std, + weight); +} + +GaussianAntenna::GaussianAntenna(ZenGrid zen_grid, + AziGrid azi_grid, + Numeric std, Stokvec weight) - : AntennaPattern(make_gaussian_field(std::move(zen_grid), - std::move(azi_grid), - zenith_std, - azimuth_std, - weight)) {} + : GaussianAntenna( + std::move(zen_grid), std::move(azi_grid), std, std, weight) {} -Obsel AntennaPattern::operator()(const Channel& channel, - const Vector3& pos, - const Vector2& bore_los) const { - ARTS_USER_ERROR_IF(not data.ok(), - "SensorAntennaPattern data shape does not match its grids") +std::shared_ptr GaussianAntenna::clone() const { + return std::make_shared(*this); +} - const auto& zen_grid = data.grid<0>(); - const auto& azi_grid = data.grid<1>(); +GaussianAiryAntenna::GaussianAiryAntenna(ZenGrid zen_grid, + AziGrid azi_grid, + Numeric aperture_diameter, + Stokvec weight) + : zen_grid(std::move(zen_grid)), + azi_grid(std::move(azi_grid)), + aperture_diameter(aperture_diameter), + weight(weight) {} + +Obsel GaussianAiryAntenna::operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const { + ARTS_USER_ERROR_IF(aperture_diameter <= 0.0, + "Gaussian Airy antenna aperture_diameter must be positive") - std::vector> antenna_samples; - antenna_samples.reserve(static_cast(zen_grid.size()) * - static_cast(azi_grid.size())); + const auto& channel_weights = channel.weights(); + const auto freq_grid = + std::make_shared(channel.freq_grid()); + + ARTS_USER_ERROR_IF( + freq_grid->front() <= 0.0, + "Gaussian Airy antenna requires strictly positive channel frequencies because SensorBuilder only provides the channel frequency grid") + + const Size nsamples = zen_grid.size() * azi_grid.size(); + auto poslos_grid = std::make_shared(nsamples); + SparseStokvecMatrix weight_matrix(nsamples, channel_weights.size()); const auto basis = antenna_basis(bore_los); + Size isample = 0; - for (Size izen = 0; izen < zen_grid.size(); ++izen) { - for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { - const Vector3 local = antenna_frame_los({zen_grid[izen], azi_grid[iazi]}); + for (double izen : zen_grid) { + for (double iazi : azi_grid) { + const Vector3 local = antenna_frame_los({izen, iazi}); const Vector3 enu = normalized(local[0] * basis.v + local[1] * basis.h + local[2] * basis.k); - antenna_samples.emplace_back(data[izen, iazi], enu2los(enu)); + (*poslos_grid)[isample] = {.pos = pos, .los = enu2los(enu)}; + + ++isample; } } - return { - std::make_shared(channel.freq_grid()), - make_poslos_grid(pos, antenna_samples), - make_weight_matrix(antenna_samples, channel), - }; + if (not weight.is_zero()) { + for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { + if (channel_weights[ifreq] == 0.0) continue; + + const Numeric airy_std = + gaussian_airy_std((*freq_grid)[ifreq], aperture_diameter); + const GaussianAntenna frequency_pattern{ + zen_grid, azi_grid, airy_std, weight}; + + isample = 0; + for (Size izen = 0; izen < zen_grid.size(); ++izen) { + for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { + const auto& antenna_weight = frequency_pattern.data[izen, iazi]; + if (not antenna_weight.is_zero()) { + weight_matrix[isample, ifreq] = + channel_weights[ifreq] * antenna_weight; + } + + ++isample; + } + } + } + } + + return {freq_grid, poslos_grid, std::move(weight_matrix)}; +} + +std::shared_ptr GaussianAiryAntenna::clone() const { + return std::make_shared(*this); } static_assert(AntennaPatternSelection); +static_assert(AntennaPatternSelection); static_assert(AntennaPatternSelection); static_assert(AntennaPatternSelection); +static_assert(AntennaPatternSelection); } // namespace sensor diff --git a/src/core/sensor/antenna_pattern.h b/src/core/sensor/antenna_pattern.h index 989ab962bf..812e7748e4 100644 --- a/src/core/sensor/antenna_pattern.h +++ b/src/core/sensor/antenna_pattern.h @@ -4,6 +4,7 @@ #include #include +#include #include "frequency_channel_selection.h" #include "obsel.h" @@ -12,34 +13,103 @@ namespace sensor { //! A 2D angular antenna pattern on local zenith and azimuth offsets. struct AntennaPattern; +//! A 2D gridded antenna pattern on local zenith and azimuth offsets. +struct GriddedAntennaPattern; + //! A 1x1 antenna pattern that samples only the bore line of sight. struct PencilBeamAntenna; //! A 2D Gaussian antenna pattern on local zenith and azimuth offsets. struct GaussianAntenna; +//! A Gaussianized Airy antenna pattern with frequency-dependent width. +struct GaussianAiryAntenna; + //! 2D gridded field of antenna weights on local zenith and azimuth offsets. using AntennaPatternField = matpack::gridded_data_t; struct AntennaPattern { - AntennaPatternField data; // center at [0, 0] + virtual ~AntennaPattern() = default; + AntennaPattern() = default; + AntennaPattern(const AntennaPattern&) = default; + AntennaPattern& operator=(const AntennaPattern&) = default; + AntennaPattern(AntennaPattern&&) = default; + AntennaPattern& operator=(AntennaPattern&&) = default; //! Builds one sensor obsel for a channel at a sensor position and bore LOS. + [[nodiscard]] virtual Obsel operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const = 0; + + //! Creates an owning copy preserving the dynamic antenna type. + [[nodiscard]] virtual std::shared_ptr clone() const = 0; +}; + +struct GriddedAntennaPattern : AntennaPattern { + AntennaPatternField data; // center at [0, 0] + [[nodiscard]] Obsel operator()(const Channel& channel, const Vector3& pos, - const Vector2& bore_los) const; + const Vector2& bore_los) const override; + + [[nodiscard]] std::shared_ptr clone() const override; }; struct PencilBeamAntenna final : AntennaPattern { + Stokvec weight{1.0, 0.0, 0.0, 0.0}; + PencilBeamAntenna(Stokvec weight = {1.0, 0.0, 0.0, 0.0}); + + [[nodiscard]] Obsel operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const override; + + [[nodiscard]] std::shared_ptr clone() const override; }; -struct GaussianAntenna final : AntennaPattern { +struct GaussianAntenna final : GriddedAntennaPattern { + GaussianAntenna() = default; + GaussianAntenna(const GaussianAntenna&) = default; + GaussianAntenna& operator=(const GaussianAntenna&) = default; + GaussianAntenna(GaussianAntenna&&) = default; + GaussianAntenna& operator=(GaussianAntenna&&) = default; + GaussianAntenna(ZenGrid zen_grid, AziGrid azi_grid, Numeric zenith_std, Numeric azimuth_std, Stokvec weight = {1.0, 0.0, 0.0, 0.0}); + + GaussianAntenna(ZenGrid zen_grid, + AziGrid azi_grid, + Numeric std, + Stokvec weight = {1.0, 0.0, 0.0, 0.0}); + + [[nodiscard]] std::shared_ptr clone() const override; +}; + +struct GaussianAiryAntenna final : AntennaPattern { + ZenGrid zen_grid; + AziGrid azi_grid; + Numeric aperture_diameter; + Stokvec weight; + + GaussianAiryAntenna() = default; + GaussianAiryAntenna(const GaussianAiryAntenna&) = default; + GaussianAiryAntenna& operator=(const GaussianAiryAntenna&) = default; + GaussianAiryAntenna(GaussianAiryAntenna&&) = default; + GaussianAiryAntenna& operator=(GaussianAiryAntenna&&) = default; + + GaussianAiryAntenna(ZenGrid zen_grid, + AziGrid azi_grid, + Numeric aperture_diameter, + Stokvec weight = {1.0, 0.0, 0.0, 0.0}); + + [[nodiscard]] Obsel operator()(const Channel& channel, + const Vector3& pos, + const Vector2& bore_los) const override; + + [[nodiscard]] std::shared_ptr clone() const override; }; //! Concept for types that can build a sensor obsel from channel geometry. @@ -56,7 +126,7 @@ concept AntennaPatternSelection = requires(const T& antenna, template <> struct format_tag_aggregate { - constexpr static bool value = true; + constexpr static bool value = false; }; template <> @@ -66,6 +136,23 @@ struct xml_io_stream_name { template <> struct xml_io_stream_aggregate { + static constexpr bool value = false; +}; + +// GriddedAntennaPattern format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorGriddedAntennaPattern"; +}; + +template <> +struct xml_io_stream_aggregate { static constexpr bool value = true; }; @@ -102,3 +189,20 @@ template <> struct xml_io_stream_aggregate { static constexpr bool value = true; }; + +// GaussianAiryAntenna format tags and XML I/O + +template <> +struct format_tag_aggregate { + constexpr static bool value = true; +}; + +template <> +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorGaussianAiryAntenna"; +}; + +template <> +struct xml_io_stream_aggregate { + static constexpr bool value = true; +}; diff --git a/src/core/sensor/sensor_builder.cpp b/src/core/sensor/sensor_builder.cpp index 793e02ceea..a9bed69a39 100644 --- a/src/core/sensor/sensor_builder.cpp +++ b/src/core/sensor/sensor_builder.cpp @@ -7,6 +7,11 @@ namespace sensor { namespace { +std::shared_ptr clone_antenna( + const std::shared_ptr& antenna) { + return antenna ? antenna->clone() : nullptr; +} + SensorMetaInfo make_meta_info(Size nchannels, Size geometry_index) { SortedGriddedField1 gf; gf.data_name = std::format("sensor-builder-{}", geometry_index); @@ -23,10 +28,38 @@ SensorMetaInfo make_meta_info(Size nchannels, Size geometry_index) { } } // namespace +SensorBuilder::SensorBuilder() : antenna(PencilBeamAntenna{}.clone()) {} + +SensorBuilder::SensorBuilder(std::vector channels, + const AntennaPattern& antenna) + : channels(std::move(channels)), antenna(antenna.clone()) {} + +SensorBuilder::SensorBuilder(const SensorBuilder& other) + : channels(other.channels), antenna(clone_antenna(other.antenna)) {} + +SensorBuilder& SensorBuilder::operator=(const SensorBuilder& other) { + if (this != &other) { + channels = other.channels; + antenna = clone_antenna(other.antenna); + } + + return *this; +} + +const AntennaPattern& SensorBuilder::get_antenna() const { + ARTS_USER_ERROR_IF(not antenna, "SensorBuilder requires an antenna pattern") + return *antenna; +} + +void SensorBuilder::set_antenna(const AntennaPattern& pattern) { + antenna = pattern.clone(); +} + std::pair SensorBuilder::operator()( std::span pos, std::span los) const { ARTS_USER_ERROR_IF(channels.empty(), "SensorBuilder requires at least one channel") + ARTS_USER_ERROR_IF(not antenna, "SensorBuilder requires an antenna pattern") ARTS_USER_ERROR_IF(pos.empty(), "SensorBuilder requires at least one sensor position") ARTS_USER_ERROR_IF(los.empty(), @@ -56,7 +89,7 @@ std::pair SensorBuilder::operator()( std::shared_ptr poslos_grid; for (Size ichan = 0; ichan < channels.size(); ++ichan) { - auto obsel = antenna(channels[ichan], sensor_pos, bore_los); + auto obsel = get_antenna()(channels[ichan], sensor_pos, bore_los); obsel.set_f_grid_ptr(freq_grids[ichan]); if (not poslos_grid) { diff --git a/src/core/sensor/sensor_builder.h b/src/core/sensor/sensor_builder.h index 6352b58bb9..42981e10ea 100644 --- a/src/core/sensor/sensor_builder.h +++ b/src/core/sensor/sensor_builder.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -20,7 +21,17 @@ concept SensorBuilderSelection = std::derived_from; struct SensorBuilder { std::vector channels; - AntennaPattern antenna; + std::shared_ptr antenna; + + SensorBuilder(); + SensorBuilder(std::vector channels, const AntennaPattern& antenna); + SensorBuilder(const SensorBuilder& other); + SensorBuilder(SensorBuilder&&) noexcept = default; + SensorBuilder& operator=(const SensorBuilder& other); + SensorBuilder& operator=(SensorBuilder&&) noexcept = default; + + [[nodiscard]] const AntennaPattern& get_antenna() const; + void set_antenna(const AntennaPattern& pattern); [[nodiscard]] std::pair operator()( std::span pos, std::span los) const; diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 9f786adcd4..9f5a8f5fb0 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -471,33 +471,39 @@ See :meth:`SensorObsel.normalize` for details. auto sap = py::class_(m, "SensorAntennaPattern"); sap.doc() = "Base class for angular antenna responses defined around a bore line of sight."; - sap.def(py::init<>(), "Construct an empty antenna pattern.") + sap.def("__call__", + &sensor::AntennaPattern::operator(), + "channel"_a, + "pos"_a, + "bore_los"_a, + R"(Map the antenna pattern onto one observation element.)"); + generic_interface(sap); + + auto sgp = py::class_( + m, "SensorGriddedAntennaPattern"); + sgp.doc() = + "A gridded angular antenna response on local zenith and azimuth offsets."; + sgp.def(py::init<>(), "Construct an empty gridded antenna pattern.") .def( "__init__", - [](sensor::AntennaPattern* self, + [](sensor::GriddedAntennaPattern* self, const sensor::AntennaPatternField& response) { - new (self) sensor::AntennaPattern{.data = response}; + new (self) sensor::GriddedAntennaPattern{}; + self->data = response; }, "response"_a, - "Construct an antenna pattern from a local zenith/azimuth response field.") + "Construct a gridded antenna pattern from a local zenith/azimuth response field.") .def_prop_rw( "response", - [](sensor::AntennaPattern& self) -> sensor::AntennaPatternField& { - return self.data; - }, - [](sensor::AntennaPattern& self, + [](sensor::GriddedAntennaPattern& self) + -> sensor::AntennaPatternField& { return self.data; }, + [](sensor::GriddedAntennaPattern& self, const sensor::AntennaPatternField& response) { self.data = response; }, py::rv_policy::reference_internal, - "Local antenna response field.\n\n.. :class:`~pyarts3.arts.SensorAntennaPatternField`") - .def("__call__", - &sensor::AntennaPattern::operator(), - "channel"_a, - "pos"_a, - "bore_los"_a, - R"(Map the local antenna pattern onto observation elements)"); - generic_interface(sap); + "Local antenna response field.\n\n.. :class:`~pyarts3.arts.SensorAntennaPatternField`"); + generic_interface(sgp); auto spp = py::class_( m, "SensorPencilBeamAntenna"); @@ -508,7 +514,7 @@ See :meth:`SensorObsel.normalize` for details. "Construct a pencil-beam antenna with one Stokes weight at the bore LOS."); generic_interface(spp); - auto sga = py::class_( + auto sga = py::class_( m, "SensorGaussianAntenna"); sga.doc() = "A Gaussian antenna response defined on a local zenith/azimuth grid."; @@ -521,6 +527,21 @@ See :meth:`SensorObsel.normalize` for details. "Construct a Gaussian antenna response on the supplied local grids."); generic_interface(sga); + auto sgairy = py::class_( + m, "SensorGaussianAiryAntenna"); + sgairy.doc() = + "A Gaussianized Airy antenna whose width scales with wavelength and aperture diameter."; + sgairy.def( + py::init(), + "zen_grid"_a, + "azi_grid"_a, + "aperture_diameter"_a, + "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, + "Construct a Gaussian Airy antenna on the supplied local grids." + " The channel frequency grid must be strictly positive because the" + " current builder path does not carry a separate reference frequency."); + generic_interface(sgairy); + auto sch = py::class_(m, "SensorChannel"); sch.doc() = "Base class for relative spectrometer channel responses."; generic_interface(sch); @@ -611,10 +632,7 @@ See :meth:`SensorObsel.normalize` for details. [](sensor::SensorBuilder* self, const std::vector& channels, const sensor::AntennaPattern& antenna) { - new (self) sensor::SensorBuilder{ - .channels = channels, - .antenna = antenna, - }; + new (self) sensor::SensorBuilder{channels, antenna}; }, "channels"_a, "antenna"_a = sensor::PencilBeamAntenna{}, @@ -623,9 +641,15 @@ See :meth:`SensorObsel.normalize` for details. "channels", &sensor::SensorBuilder::channels, "Spectrometer channels.\n\n.. :class:`list[~pyarts3.arts.SensorChannel]`") - .def_rw( + .def_prop_rw( "antenna", - &sensor::SensorBuilder::antenna, + [](const sensor::SensorBuilder& self) + -> const sensor::AntennaPattern& { return self.get_antenna(); }, + [](sensor::SensorBuilder& self, + const sensor::AntennaPattern& antenna) { + self.set_antenna(antenna); + }, + py::rv_policy::reference_internal, "Angular antenna response.\n\n.. :class:`~pyarts3.arts.SensorAntennaPattern`") .def( "__call__", diff --git a/src/tests/test_antenna_pattern.cc b/src/tests/test_antenna_pattern.cc index e44742b40c..6271981114 100644 --- a/src/tests/test_antenna_pattern.cc +++ b/src/tests/test_antenna_pattern.cc @@ -1,6 +1,9 @@ #include +#include + #include +#include #include namespace { @@ -36,9 +39,113 @@ void test_gaussian_initialization_uses_antenna_frame_offsets() { 1e-12, "default gaussian peak weight"); } + +Numeric gaussian_airy_expected_gain(Numeric zenith_deg, + Numeric frequency, + Numeric aperture_diameter) { + constexpr Numeric gaussian_airy_hwhm_factor = + 3.8317059702075123156 / Constant::pi; + const Numeric wavelength = Constant::speed_of_light / frequency; + const Numeric hwhm_deg = Conversion::rad2deg( + gaussian_airy_hwhm_factor * wavelength / aperture_diameter); + const Numeric ratio = zenith_deg / hwhm_deg; + + return std::exp(-std::log(2.0) * ratio * ratio); +} + +Numeric gaussian_airy_expected_std(Numeric frequency, + Numeric aperture_diameter) { + constexpr Numeric gaussian_airy_hwhm_factor = + 3.8317059702075123156 / Constant::pi; + const Numeric wavelength = Constant::speed_of_light / frequency; + const Numeric hwhm_deg = Conversion::rad2deg( + gaussian_airy_hwhm_factor * wavelength / aperture_diameter); + + return hwhm_deg / std::sqrt(2.0 * std::log(2.0)); +} + +void test_gaussian_airy_is_frequency_dependent() { + const Stokvec peak_weight{2.0, 0.0, 0.0, 0.0}; + const sensor::GaussianAiryAntenna ant{ + ZenGrid{{0.0, 0.2}}, AziGrid{{0.0}}, 1.0, peak_weight}; + const sensor::BoxChannel channel{AscendingGrid{100.0e9, 200.0e9}}; + + const auto obsel = ant(channel, {600e3, 10.0, 20.0}, {45.0, 30.0}); + + assert_stokvec(obsel.weight_matrix()[0, 0], + 0.5 * peak_weight, + 1e-12, + "gaussian airy bore low frequency"); + assert_stokvec(obsel.weight_matrix()[0, 1], + 0.5 * peak_weight, + 1e-12, + "gaussian airy bore high frequency"); + + const Numeric low_gain = gaussian_airy_expected_gain(0.2, 100.0e9, 1.0); + const Numeric high_gain = gaussian_airy_expected_gain(0.2, 200.0e9, 1.0); + + assert_stokvec(obsel.weight_matrix()[1, 0], + 0.5 * low_gain * peak_weight, + 1e-12, + "gaussian airy off-axis low frequency"); + assert_stokvec(obsel.weight_matrix()[1, 1], + 0.5 * high_gain * peak_weight, + 1e-12, + "gaussian airy off-axis high frequency"); + const auto low_weight = obsel.weight_matrix()[1, 0]; + const auto high_weight = obsel.weight_matrix()[1, 1]; + ARTS_USER_ERROR_IF(low_weight[0] <= high_weight[0], + "Higher frequency Gaussian Airy sample must be narrower") +} + +void test_gaussian_airy_matches_frequency_specific_gaussian_pattern() { + const ZenGrid zen_grid{{0.0, 0.2}}; + const AziGrid azi_grid{{0.0, 0.2}}; + const Stokvec peak_weight{2.0, 0.0, 0.0, 0.0}; + const sensor::GaussianAiryAntenna ant{zen_grid, azi_grid, 1.0, peak_weight}; + const sensor::BoxChannel channel{AscendingGrid{100.0e9, 200.0e9}}; + + const auto obsel = ant(channel, {600e3, 10.0, 20.0}, {45.0, 30.0}); + + for (Size ifreq = 0; ifreq < channel.freq_grid().size(); ++ifreq) { + const Numeric airy_std = + gaussian_airy_expected_std(channel.freq_grid()[ifreq], 1.0); + const sensor::GaussianAntenna gaussian{ + zen_grid, azi_grid, airy_std, airy_std, peak_weight}; + + Size isample = 0; + for (Size izen = 0; izen < zen_grid.size(); ++izen) { + for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { + assert_stokvec(obsel.weight_matrix()[isample, ifreq], + channel.weights()[ifreq] * gaussian.data[izen, iazi], + 1e-12, + "gaussian airy per-frequency gaussian match"); + ++isample; + } + } + } +} + +void test_gaussian_airy_rejects_nonpositive_frequencies() { + const sensor::GaussianAiryAntenna ant{ZenGrid{{0.0}}, AziGrid{{0.0}}, 1.0}; + + bool threw = false; + try { + static_cast( + ant(sensor::DiracChannel{}, {600e3, 10.0, 20.0}, {0.0, 0.0})); + } catch (const std::runtime_error&) { + threw = true; + } + + ARTS_USER_ERROR_IF(not threw, + "Gaussian Airy antenna must reject nonpositive channel frequencies") +} } // namespace int main() { test_gaussian_initialization_uses_antenna_frame_offsets(); + test_gaussian_airy_is_frequency_dependent(); + test_gaussian_airy_matches_frequency_specific_gaussian_pattern(); + test_gaussian_airy_rejects_nonpositive_frequencies(); return 0; } \ No newline at end of file diff --git a/src/tests/test_sensor_builder.cc b/src/tests/test_sensor_builder.cc index 8d359b6188..36346f9573 100644 --- a/src/tests/test_sensor_builder.cc +++ b/src/tests/test_sensor_builder.cc @@ -1,3 +1,4 @@ +#include #include #include @@ -5,6 +6,8 @@ #include #include +#include "arts_constants.h" + namespace { void assert_close(Numeric actual, Numeric expected, @@ -17,12 +20,38 @@ void assert_close(Numeric actual, expected) } +void assert_stokvec(Stokvec actual, + Stokvec expected, + Numeric tol, + std::string_view name) { + for (Size i = 0; i < 4; ++i) { + ARTS_USER_ERROR_IF(std::abs(actual[i] - expected[i]) > tol, + "{} mismatch at {}: actual {} expected {}", + name, + i, + actual, + expected) + } +} + +Numeric gaussian_airy_expected_gain(Numeric zenith_deg, + Numeric frequency, + Numeric aperture_diameter) { + constexpr Numeric gaussian_airy_hwhm_factor = + 3.8317059702075123156 / Constant::pi; + const Numeric wavelength = Constant::speed_of_light / frequency; + const Numeric hwhm_deg = Conversion::rad2deg(gaussian_airy_hwhm_factor * + wavelength / aperture_diameter); + const Numeric ratio = zenith_deg / hwhm_deg; + + return std::exp(-Constant::ln_2 * ratio * ratio); +} + void test_sensor_builder_returns_meta_per_geometry() { - sensor::SensorBuilder builder{ - .channels = {sensor::BoxChannel{AscendingGrid{100.0, 101.0}}, - sensor::DiracChannel{200.0}}, - .antenna = sensor::PencilBeamAntenna{}, - }; + sensor::SensorBuilder builder( + {sensor::BoxChannel{AscendingGrid{100.0, 101.0}}, + sensor::DiracChannel{200.0}}, + sensor::PencilBeamAntenna{}); const std::array pos{{{600e3, 10.0, 20.0}, {601e3, 11.0, 21.0}}}; const std::array los{{{20.0, 30.0}, {40.0, 50.0}}}; @@ -60,10 +89,8 @@ void test_sensor_builder_returns_meta_per_geometry() { } void test_sensor_builder_rejects_mismatched_geometry_counts() { - sensor::SensorBuilder builder{ - .channels = {sensor::DiracChannel{}}, - .antenna = sensor::PencilBeamAntenna{}, - }; + sensor::SensorBuilder builder({sensor::DiracChannel{}}, + sensor::PencilBeamAntenna{}); const std::array pos{{{600e3, 10.0, 20.0}}}; const std::array los{{{20.0, 30.0}, {40.0, 50.0}}}; @@ -79,10 +106,41 @@ void test_sensor_builder_rejects_mismatched_geometry_counts() { not threw, "SensorBuilder must reject mismatching position and LOS counts") } + +void test_sensor_builder_uses_gaussian_airy_frequency_dependence() { + const Stokvec peak_weight{2.0, 0.0, 0.0, 0.0}; + sensor::SensorBuilder builder( + {sensor::BoxChannel{AscendingGrid{100.0e9, 200.0e9}}}, + sensor::GaussianAiryAntenna{ + ZenGrid{{0.0, 0.2}}, AziGrid{{0.0}}, 1.0, peak_weight}); + + const std::array pos{{{600e3, 10.0, 20.0}}}; + const std::array los{{{45.0, 30.0}}}; + + const auto [obsels, meta] = builder(pos, los); + + ARTS_USER_ERROR_IF( + obsels.size() != 1, "Expected 1 obsel, got {}", obsels.size()) + ARTS_USER_ERROR_IF( + meta.size() != 1, "Expected 1 meta entry, got {}", meta.size()) + + const Numeric low_gain = gaussian_airy_expected_gain(0.2, 100.0e9, 1.0); + const Numeric high_gain = gaussian_airy_expected_gain(0.2, 200.0e9, 1.0); + + assert_stokvec(obsels[0].weight_matrix()[1, 0], + 0.5 * low_gain * peak_weight, + 1e-12, + "builder gaussian airy off-axis low frequency"); + assert_stokvec(obsels[0].weight_matrix()[1, 1], + 0.5 * high_gain * peak_weight, + 1e-12, + "builder gaussian airy off-axis high frequency"); +} } // namespace int main() { test_sensor_builder_returns_meta_per_geometry(); test_sensor_builder_rejects_mismatched_geometry_counts(); + test_sensor_builder_uses_gaussian_airy_frequency_dependence(); return 0; } \ No newline at end of file From 64003e1d4f2de6f0dc09c006e9678224c1494e47 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 18 May 2026 17:00:49 +0900 Subject: [PATCH 13/21] Make antenna that works --- python/src/pyarts3/plots/SensorObsel.py | 438 ++++++++++++++++++ python/src/pyarts3/plots/__init__.py | 1 + src/core/sensor/antenna_pattern.cpp | 288 ++++++++++-- src/core/sensor/antenna_pattern.h | 150 +++++- src/core/sensor/frequency_channel_selection.h | 52 +-- src/core/sensor/frequency_range_selection.h | 39 +- src/core/util/format_tags.h | 24 +- src/core/xml/xml.h | 1 + src/core/xml/xml_io_stream.h | 25 - src/core/xml/xml_io_stream_aggregate.h | 6 +- src/core/xml/xml_io_stream_inherit.h | 70 +++ src/python_interface/hpy_arts.h | 8 +- src/python_interface/py_sensor.cpp | 22 +- src/tests/test_antenna_pattern.cc | 89 +++- tests/core/sensor/30cm-antenna.py | 34 ++ 15 files changed, 1079 insertions(+), 168 deletions(-) create mode 100644 python/src/pyarts3/plots/SensorObsel.py create mode 100644 src/core/xml/xml_io_stream_inherit.h create mode 100644 tests/core/sensor/30cm-antenna.py diff --git a/python/src/pyarts3/plots/SensorObsel.py b/python/src/pyarts3/plots/SensorObsel.py new file mode 100644 index 0000000000..6103517d99 --- /dev/null +++ b/python/src/pyarts3/plots/SensorObsel.py @@ -0,0 +1,438 @@ +""" Plotting routine for SensorObsel """ + +import numpy +import matplotlib +import matplotlib.tri as mtri +import numpy as np +import pyarts3 as pyarts + +from .common import default_fig_ax, select_flat_ax + +__all__ = [ + 'plot', +] + + +def _line_plot_axes(fig, ax, nkeys): + return default_fig_ax(fig, ax, 1, nkeys, fig_kwargs={'figsize': (10 * nkeys, 10)}) + + +def _geometry_plot_axes(fig, ax, polar): + return default_fig_ax(fig, + ax, + ax_kwargs={"subplot_kw": {'polar': polar}}, + fig_kwargs={'figsize': (10, 8) if polar else (10, 6)}) + + +def _azimuth_for_plot(azi: np.ndarray, wrap_azimuth: bool) -> np.ndarray: + if wrap_azimuth: + return ((azi + 180.0) % 360.0) - 180.0 + + return azi + + +def _los_to_enu(los: np.ndarray) -> np.ndarray: + zen = np.deg2rad(los[..., 0]) + azi = np.deg2rad(los[..., 1]) + sin_zen = np.sin(zen) + return np.stack((sin_zen * np.sin(azi), + sin_zen * np.cos(azi), + np.cos(zen)), + axis=-1) + + +def _antenna_basis(bore_los: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + zen = np.deg2rad(bore_los[0]) + azi = np.deg2rad(bore_los[1]) + + cza = np.cos(zen) + sza = np.sin(zen) + caa = np.cos(azi) + saa = np.sin(azi) + + vertical = np.array([-cza * saa, -cza * caa, sza], dtype=float) + horizontal = np.array([caa, -saa, 0.0], dtype=float) + bore = np.array([sza * saa, sza * caa, cza], dtype=float) + return vertical, horizontal, bore + + +def _local_los_from_enu(enu: np.ndarray, bore_los: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + vertical, horizontal, bore = _antenna_basis(bore_los) + + local_vertical = enu @ vertical + local_horizontal = enu @ horizontal + local_bore = np.clip(enu @ bore, -1.0, 1.0) + + zen = np.rad2deg(np.arccos(local_bore)) + azi = np.rad2deg(np.arctan2(local_horizontal, -local_vertical)) + azi = np.where(zen > 0.0, azi, 0.0) + return zen, azi, local_horizontal, -local_vertical + + +def _reference_los(data: pyarts.arts.SensorObsel, ifreq: int | None) -> np.ndarray: + weights = np.asarray(data.weight_matrix, dtype=float) + iweights = weights[:, :, 0] + + if ifreq is None: + ref_weights = np.abs(iweights).sum(axis=1) + else: + nfreq = iweights.shape[1] + if ifreq < 0: + ifreq += nfreq + ref_weights = np.abs(iweights[:, ifreq]) + + los = np.asarray(data.poslos[:, 3:5], dtype=float) + enu = _los_to_enu(los) + + if np.any(ref_weights > 0.0): + ref = ref_weights @ enu + else: + ref = np.sum(enu, axis=0) + + ref_norm = np.linalg.norm(ref) + if ref_norm == 0.0: + return los[0] + + ref /= ref_norm + ref_zen = np.rad2deg(np.arccos(np.clip(ref[2], -1.0, 1.0))) + ref_azi = np.rad2deg(np.arctan2(ref[0], ref[1])) + return np.array([ref_zen, ref_azi], dtype=float) + + +def _geometry_coordinates(data: pyarts.arts.SensorObsel, + frame: str, + ifreq: int | None, + wrap_azimuth: bool) -> tuple[np.ndarray, np.ndarray, str, str, np.ndarray | None]: + poslos = np.asarray(data.poslos, dtype=float) + zen = poslos[:, 3] + azi = poslos[:, 4] + + if frame == "global": + plot_azi = _azimuth_for_plot(azi, wrap_azimuth) + return zen, plot_azi, "Azimuth angle [deg]", "Zenith angle [deg]", None + + if frame != "local": + raise ValueError("frame must be 'local' or 'global'") + + bore_los = _reference_los(data, ifreq) + enu = _los_to_enu(poslos[:, 3:5]) + local_zen, local_azi, xoff, yoff = _local_los_from_enu(enu, bore_los) + + radial = local_zen + theta = _azimuth_for_plot(local_azi, wrap_azimuth) + _, _, bore = _antenna_basis(bore_los) + projected_bore = np.clip(enu @ bore, -1.0, 1.0) + xdeg = np.rad2deg(np.arctan2(xoff, projected_bore)) + ydeg = np.rad2deg(np.arctan2(yoff, projected_bore)) + + return radial, theta, "Cross-track offset [deg]", "Along-track offset [deg]", np.stack((xdeg, ydeg), axis=-1) + + +def _geometry_values(data: pyarts.arts.SensorObsel, + pol: pyarts.arts.Stokvec, + ifreq: int | None) -> tuple[np.ndarray, str]: + if ifreq is None: + values = np.asarray(data.weight_matrix.reduce(pol, + along_poslos=False, + along_freq=True), + dtype=float) + return values, "Integrated weight" + + weights = np.asarray(data.weight_matrix, dtype=float) + nfreq = weights.shape[1] + if ifreq < -nfreq or ifreq >= nfreq: + raise IndexError(f"ifreq {ifreq} out of range for {nfreq} frequencies") + + if ifreq < 0: + ifreq += nfreq + + pol_vec = np.asarray(pol, dtype=float) + values = weights[:, ifreq, :] @ pol_vec + return values, f"Weight at f[{ifreq}] = {float(data.f_grid[ifreq]):g}" + + +def _geometry_plot_type(point_spread: bool, type: str | None) -> str: + if type is None: + return "points" + + plot_type = str(type).lower() + if plot_type == "scatter": + plot_type = "points" + + valid_types = ("points", "cloud", "contour") + if plot_type not in valid_types: + choices = ", ".join(repr(choice) for choice in valid_types) + raise ValueError(f"type must be one of {choices}") + + if plot_type != "points" and not point_spread: + raise ValueError(f"type={type!r} requires point_spread=True") + + return plot_type + + +def _triangulated_point_spread(local_xy: np.ndarray, + values: np.ndarray) -> tuple[mtri.Triangulation, np.ndarray]: + x = np.asarray(local_xy[:, 0], dtype=float) + y = np.asarray(local_xy[:, 1], dtype=float) + values = np.asarray(values, dtype=float) + + finite = np.isfinite(x) & np.isfinite(y) & np.isfinite(values) + x = x[finite] + y = y[finite] + values = values[finite] + + if x.size < 3: + raise ValueError("Interpolated point-spread plots require at least 3 geometry samples") + + coords = np.stack((x, y), axis=-1) + unique_coords, inverse = np.unique(coords, axis=0, return_inverse=True) + if unique_coords.shape[0] != coords.shape[0]: + unique_values = np.zeros(unique_coords.shape[0], dtype=float) + counts = np.zeros(unique_coords.shape[0], dtype=int) + np.add.at(unique_values, inverse, values) + np.add.at(counts, inverse, 1) + x = unique_coords[:, 0] + y = unique_coords[:, 1] + values = unique_values / counts + + if x.size < 3: + raise ValueError("Interpolated point-spread plots require at least 3 unique geometry samples") + + triangulation = mtri.Triangulation(x, y) + if triangulation.triangles.size == 0: + raise ValueError("Interpolated point-spread plots require non-collinear geometry samples") + + return triangulation, values + + +def _contour_levels(values: np.ndarray, levels: int | np.ndarray) -> np.ndarray | int: + if np.ndim(levels) != 0: + return levels + + nlevels = max(int(levels), 2) + vmin = float(np.min(values)) + vmax = float(np.max(values)) + if np.isclose(vmin, vmax): + delta = 1e-6 if vmin == 0.0 else abs(vmin) * 1e-6 + return np.array((vmin - delta, vmax + delta), dtype=float) + + return np.linspace(vmin, vmax, nlevels) + + +def _plot_geometry(data: pyarts.arts.SensorObsel, + *, + fig: matplotlib.figure.Figure | None, + ax: matplotlib.axes.Axes | list[matplotlib.axes.Axes] | numpy.ndarray[matplotlib.axes.Axes] | None, + pol: pyarts.arts.Stokvec, + polar: bool, + point_spread: bool, + type: str | None, + ifreq: int | None, + colorbar: bool, + frame: str, + wrap_azimuth: bool, + **kwargs) -> tuple[matplotlib.figure.Figure, matplotlib.axes.Axes | list[matplotlib.axes.Axes] | numpy.ndarray[matplotlib.axes.Axes]]: + fig, ax = _geometry_plot_axes(fig, ax, polar) + axis = select_flat_ax(ax, 0) + + radial, theta, xlabel, ylabel, local_xy = _geometry_coordinates(data, + frame, + ifreq, + wrap_azimuth) + plot_type = _geometry_plot_type(point_spread, type) + + if plot_type == "points": + default_kwargs = {'marker': 'o', 'linestyle': 'None'} + for key, value in default_kwargs.items(): + kwargs.setdefault(key, value) + + artist = None + if point_spread: + values, label = _geometry_values(data, pol, ifreq) + kwargs.setdefault('cmap', 'viridis') + + if plot_type == "cloud" or plot_type == "contour": + if polar: + raise ValueError("Interpolated point-spread types require polar=False") + if frame != "local" or local_xy is None: + raise ValueError("Interpolated point-spread types require frame='local'") + + triangulation, values = _triangulated_point_spread(local_xy, values) + if plot_type == "cloud": + kwargs.setdefault('shading', 'gouraud') + artist = axis.tripcolor(triangulation, values, **kwargs) + else: + levels = _contour_levels(values, kwargs.pop('levels', 32)) + artist = axis.tricontourf(triangulation, values, levels=levels, **kwargs) + elif polar: + artist = axis.scatter(np.deg2rad(theta), radial, c=values, **kwargs) + elif frame == "local": + artist = axis.scatter(local_xy[:, 0], local_xy[:, 1], c=values, **kwargs) + else: + artist = axis.scatter(theta, radial, c=values, **kwargs) + if colorbar: + fig.colorbar(artist, ax=axis, label=label) + else: + if polar: + artist = axis.plot(np.deg2rad(theta), radial, **kwargs) + elif frame == "local": + artist = axis.plot(local_xy[:, 0], local_xy[:, 1], **kwargs) + else: + artist = axis.plot(theta, radial, **kwargs) + + if polar: + axis.set_theta_zero_location("N") + axis.set_theta_direction(-1) + axis.set_rlabel_position(225) + axis.set_ylim(0.0, np.max(radial) if radial.size else 1.0) + else: + axis.set_xlabel(xlabel) + axis.set_ylabel(ylabel) + if frame == "local": + axis.set_aspect('equal', adjustable='box') + + return fig, ax + + +def plot(data: pyarts.arts.SensorObsel, + *, + fig: matplotlib.figure.Figure | None = None, + ax: matplotlib.axes.Axes | list[matplotlib.axes.Axes] | numpy.ndarray[matplotlib.axes.Axes] | None = None, + keys: str | list = "f", + pol: str | pyarts.arts.Stokvec = "I", + polar: bool = False, + point_spread: bool = False, + type: str | None = None, + ifreq: int | None = None, + colorbar: bool = True, + frame: str = "global", + wrap_azimuth: bool = False, + **kwargs) -> tuple[matplotlib.figure.Figure, matplotlib.axes.Axes | list[matplotlib.axes.Axes] | numpy.ndarray[matplotlib.axes.Axes]]: + """Plot a sensor observational element. + + By default, this follows the same line-plot convention as + :mod:`pyarts3.plots.ArrayOfSensorObsel`: the selected quantity is reduced + over the non-plotted axis and drawn against either frequency or one sensor + geometry component. + + Setting ``polar=True`` switches to a geometry view. By default, geometry + plots use the stored ``SensorObsel.poslos`` zenith and azimuth values so + the plotted coordinates match the observation element directly. + Setting ``point_spread=True`` colors the geometry samples by their reduced + weights, integrated over all frequencies unless ``ifreq`` selects one + frequency column. The geometry ``type`` selects whether those weighted + samples appear as discrete points, a smooth triangulated cloud, or filled + triangulated contours. + + .. rubric:: Example + + .. plot:: + :include-source: + + import pyarts3 as pyarts + import numpy as np + + ant = pyarts.arts.SensorGaussianAiryAntenna(np.linspace(0, 1, 11), + np.linspace(0, 330, 12), + 0.3) + ch = pyarts.arts.SensorDiracChannel(100e9) + obsel = ant(ch, [1, 0, 0], [90, 0]) + + pyarts.plots.SensorObsel.plot(obsel, polar=True, point_spread=True) + pyarts.plots.SensorObsel.plot(obsel, point_spread=True, wrap_azimuth=True) + pyarts.plots.SensorObsel.plot(obsel, point_spread=True, frame="local") + pyarts.plots.SensorObsel.plot(obsel, point_spread=True, frame="local", type="cloud") + pyarts.plots.SensorObsel.plot(obsel, point_spread=True, frame="local", type="contour") + + Parameters + ---------- + data : ~pyarts3.arts.SensorObsel + A single sensor observation element. + fig : ~matplotlib.figure.Figure, optional + The matplotlib figure to draw on. Defaults to None for new figure. + ax : ~matplotlib.axes.Axes | list[~matplotlib.axes.Axes] | ~numpy.ndarray[~matplotlib.axes.Axes] | None, optional + The matplotlib axes to draw on. Defaults to None for new axes. + keys : str | list + The keys to use for line plotting. Options are in :class:`~pyarts3.arts.SensorKeyType`. + Ignored when ``polar`` or ``point_spread`` is enabled. + pol : str | ~pyarts3.arts.Stokvec + The polarization to use for plotting. Defaults to ``"I"``. + polar : bool, optional + If True, plot the observation geometry in polar coordinates. Defaults to False. + point_spread : bool, optional + If True, color the observation geometry by weight values. Defaults to False. + type : str | None, optional + Geometry rendering type. ``"points"`` shows discrete samples, + ``"cloud"`` draws a smooth triangulated color cloud, and + ``"contour"`` draws filled triangulated contours. Interpolated types + require ``point_spread=True``, ``frame="local"``, and ``polar=False``. + Defaults to ``None``, which behaves like ``"points"``. + ifreq : int | None, optional + Frequency index to use for point-spread values. If None, integrates over frequency. + colorbar : bool, optional + If True, add a colorbar to point-spread plots. Defaults to True. + frame : str, optional + Geometry frame for ``polar`` and ``point_spread`` plots. ``"global"`` + uses the stored zenith/azimuth coordinates from ``SensorObsel.poslos``, + while ``"local"`` uses a bore-centered frame inferred from the weighted + LOS samples. Defaults to ``"global"``. + wrap_azimuth : bool, optional + If True, wrap azimuths to ``[-180, 180)`` before geometry plotting. + Defaults to False. + **kwargs : keyword arguments + Additional keyword arguments to pass to the plotting functions. + + Returns + ------- + fig : + As input if input. Otherwise the created Figure. + ax : + As input if input. Otherwise the created Axes. + """ + + pol = pyarts.arts.Stokvec(pol) + + if type is not None and not (polar or point_spread): + _geometry_plot_type(point_spread, type) + + if polar or point_spread: + return _plot_geometry(data, + fig=fig, + ax=ax, + pol=pol, + polar=polar, + point_spread=point_spread, + type=type, + ifreq=ifreq, + colorbar=colorbar, + frame=frame, + wrap_azimuth=wrap_azimuth, + **kwargs) + + keys = [keys] if isinstance(keys, str) else keys + nkeys = len(keys) + if nkeys == 0: + return fig, ax + + fig, ax = _line_plot_axes(fig, ax, nkeys) + + key_map = { + pyarts.arts.SensorKeyType.f: None, + pyarts.arts.SensorKeyType.alt: 0, + pyarts.arts.SensorKeyType.lat: 1, + pyarts.arts.SensorKeyType.lon: 2, + pyarts.arts.SensorKeyType.zen: 3, + pyarts.arts.SensorKeyType.azi: 4, + } + + for isub, raw_key in enumerate(keys): + key = pyarts.arts.SensorKeyType(raw_key) + idx = key_map[key] + + values = data.weight_matrix.reduce(pol, + along_poslos=idx is None, + along_freq=idx is not None) + x = data.f_grid if idx is None else data.poslos[:, idx] + select_flat_ax(ax, isub).plot(x, values, **kwargs) + + return fig, ax \ No newline at end of file diff --git a/python/src/pyarts3/plots/__init__.py b/python/src/pyarts3/plots/__init__.py index 6b1e5b4287..73c7648e98 100644 --- a/python/src/pyarts3/plots/__init__.py +++ b/python/src/pyarts3/plots/__init__.py @@ -54,6 +54,7 @@ from . import SubsurfaceField from . import Sun from . import SurfaceField +from . import SensorObsel from . import Vector from . import ZenGrid diff --git a/src/core/sensor/antenna_pattern.cpp b/src/core/sensor/antenna_pattern.cpp index 602a027e97..d25ac164cd 100644 --- a/src/core/sensor/antenna_pattern.cpp +++ b/src/core/sensor/antenna_pattern.cpp @@ -5,7 +5,9 @@ #include #include +#include #include +#include namespace sensor { @@ -19,6 +21,21 @@ struct AntennaBasis { Vector3 k; }; +struct AntennaGeometrySample { + Size sample_index{}; + Numeric ant_zen{}; + Numeric ant_azi{}; + bool has_response{}; + bool is_representative{}; +}; + +struct AntennaGeometryLayout { + std::shared_ptr poslos_grid; + std::vector samples; +}; + +constexpr Numeric azimuth_degenerate_tol = 1e-12; + [[nodiscard]] AntennaBasis antenna_basis(Vector2 bore_los) { using Conversion::cosd, Conversion::sind; @@ -45,6 +62,65 @@ struct AntennaBasis { return {-sza * caa, sza * saa, cza}; } +[[nodiscard]] bool antenna_azimuth_is_degenerate(const Vector3& local) { + return std::hypot(local[0], local[1]) <= azimuth_degenerate_tol; +} + +template +[[nodiscard]] AntennaGeometryLayout make_antenna_geometry_layout( + const ZenGridT& zen_grid, + const AziGridT& azi_grid, + const Vector3& pos, + const Vector2& bore_los) { + const auto basis = antenna_basis(bore_los); + + std::vector poslos; + poslos.reserve(zen_grid.size() * azi_grid.size()); + + std::vector samples; + samples.reserve(zen_grid.size() * azi_grid.size()); + + using Conversion::atan2d; + + for (Numeric izen : zen_grid) { + Size degenerate_sample_index = std::numeric_limits::max(); + + for (Numeric iazi : azi_grid) { + const Vector3 local = antenna_frame_los({izen, iazi}); + const bool degenerate = antenna_azimuth_is_degenerate(local); + + Size sample_index = degenerate_sample_index; + bool is_representative = false; + if (sample_index == std::numeric_limits::max()) { + const Vector3 enu = normalized(local[0] * basis.v + local[1] * basis.h + + local[2] * basis.k); + sample_index = poslos.size(); + poslos.push_back({.pos = pos, .los = enu2los(enu)}); + is_representative = true; + + if (degenerate) degenerate_sample_index = sample_index; + } + + auto& sample = samples.emplace_back(AntennaGeometrySample{ + .sample_index = sample_index, + .has_response = local[2] > 0.0, + .is_representative = is_representative}); + + if (sample.has_response) { + sample.ant_zen = atan2d(local[0], local[2]); + sample.ant_azi = atan2d(local[1], local[2]); + } + } + } + + auto poslos_grid = std::make_shared(poslos.size()); + for (Size isample = 0; isample < poslos.size(); ++isample) { + (*poslos_grid)[isample] = poslos[isample]; + } + + return {std::move(poslos_grid), std::move(samples)}; +} + [[nodiscard]] AntennaPatternField make_gaussian_field(ZenGrid zen_grid, AziGrid azi_grid, Numeric zenith_std, @@ -96,6 +172,12 @@ struct AntennaBasis { return Conversion::hwhm2std(hwhm_deg); } +[[nodiscard]] Numeric gaussian_airy_response(Numeric ant_zen, + Numeric ant_azi, + Numeric inv_std_sq) { + return std::exp(-0.5 * (ant_zen * ant_zen + ant_azi * ant_azi) * inv_std_sq); +} + std::shared_ptr make_single_poslos_grid( const Vector3& pos, const Vector2& los) { return std::make_shared(1, PosLos{.pos = pos, .los = los}); @@ -141,34 +223,30 @@ Obsel GriddedAntennaPattern::operator()(const Channel& channel, const auto freq_grid = std::make_shared(channel.freq_grid()); - const Size nsamples = zen_grid.size() * azi_grid.size(); - auto poslos_grid = std::make_shared(nsamples); - SparseStokvecMatrix weight_matrix(nsamples, channel_weights.size()); + auto geometry = + make_antenna_geometry_layout(zen_grid, azi_grid, pos, bore_los); + SparseStokvecMatrix weight_matrix(geometry.poslos_grid->size(), + channel_weights.size()); - const auto basis = antenna_basis(bore_los); - Size isample = 0; + Size igrid = 0; for (Size izen = 0; izen < zen_grid.size(); ++izen) { for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { - const Vector3 local = antenna_frame_los({zen_grid[izen], azi_grid[iazi]}); - const Vector3 enu = normalized(local[0] * basis.v + local[1] * basis.h + - local[2] * basis.k); - (*poslos_grid)[isample] = {.pos = pos, .los = enu2los(enu)}; - const auto& antenna_weight = data[izen, iazi]; if (not antenna_weight.is_zero()) { + const Size sample_index = geometry.samples[igrid].sample_index; for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { if (channel_weights[ifreq] == 0.0) continue; - weight_matrix[isample, ifreq] = + weight_matrix[sample_index, ifreq] += channel_weights[ifreq] * antenna_weight; } } - ++isample; + ++igrid; } } - return {freq_grid, poslos_grid, std::move(weight_matrix)}; + return {freq_grid, std::move(geometry.poslos_grid), std::move(weight_matrix)}; } std::shared_ptr GriddedAntennaPattern::clone() const { @@ -221,49 +299,75 @@ Obsel GaussianAiryAntenna::operator()(const Channel& channel, freq_grid->front() <= 0.0, "Gaussian Airy antenna requires strictly positive channel frequencies because SensorBuilder only provides the channel frequency grid") - const Size nsamples = zen_grid.size() * azi_grid.size(); - auto poslos_grid = std::make_shared(nsamples); - SparseStokvecMatrix weight_matrix(nsamples, channel_weights.size()); - - const auto basis = antenna_basis(bore_los); - Size isample = 0; - - for (double izen : zen_grid) { - for (double iazi : azi_grid) { - const Vector3 local = antenna_frame_los({izen, iazi}); - const Vector3 enu = normalized(local[0] * basis.v + local[1] * basis.h + - local[2] * basis.k); - (*poslos_grid)[isample] = {.pos = pos, .los = enu2los(enu)}; - - ++isample; - } - } + auto geometry = + make_antenna_geometry_layout(zen_grid, azi_grid, pos, bore_los); + SparseStokvecMatrix weight_matrix(geometry.poslos_grid->size(), + channel_weights.size()); if (not weight.is_zero()) { + Vector inv_std_sq(channel_weights.size(), 0.0); + Vector normalization(channel_weights.size(), 0.0); + Vector retained_scale(channel_weights.size(), 0.0); + for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { if (channel_weights[ifreq] == 0.0) continue; const Numeric airy_std = gaussian_airy_std((*freq_grid)[ifreq], aperture_diameter); - const GaussianAntenna frequency_pattern{ - zen_grid, azi_grid, airy_std, weight}; - - isample = 0; - for (Size izen = 0; izen < zen_grid.size(); ++izen) { - for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { - const auto& antenna_weight = frequency_pattern.data[izen, iazi]; - if (not antenna_weight.is_zero()) { - weight_matrix[isample, ifreq] = - channel_weights[ifreq] * antenna_weight; - } - - ++isample; - } + inv_std_sq[ifreq] = 1.0 / (airy_std * airy_std); + + Numeric retained_mass = 0.0; + for (const auto& sample : geometry.samples) { + if (not sample.has_response) continue; + normalization[ifreq] += gaussian_airy_response( + sample.ant_zen, sample.ant_azi, inv_std_sq[ifreq]); + } + + if (normalization[ifreq] == 0.0) continue; + + for (const auto& sample : geometry.samples) { + if (not sample.has_response) continue; + if (not sample.is_representative) continue; + + const Numeric single_weight = + channel_weights[ifreq] * gaussian_airy_response( + sample.ant_zen, + sample.ant_azi, + inv_std_sq[ifreq]) / + normalization[ifreq]; + + retained_mass += single_weight; + } + + if (retained_mass > 0.0) { + retained_scale[ifreq] = channel_weights[ifreq] / retained_mass; + } + } + + // Keep sparse insertions row-major so they append instead of shifting. + for (const auto& sample : geometry.samples) { + if (not sample.has_response) continue; + if (not sample.is_representative) continue; + + for (Size ifreq = 0; ifreq < channel_weights.size(); ++ifreq) { + if (normalization[ifreq] == 0.0) continue; + if (retained_scale[ifreq] == 0.0) continue; + + const Numeric single_weight = + channel_weights[ifreq] * gaussian_airy_response( + sample.ant_zen, + sample.ant_azi, + inv_std_sq[ifreq]) / + normalization[ifreq]; + if (single_weight == 0.0) continue; + + weight_matrix[sample.sample_index, ifreq] += + (retained_scale[ifreq] * single_weight) * weight; } } } - return {freq_grid, poslos_grid, std::move(weight_matrix)}; + return {freq_grid, std::move(geometry.poslos_grid), std::move(weight_matrix)}; } std::shared_ptr GaussianAiryAntenna::clone() const { @@ -276,3 +380,97 @@ static_assert(AntennaPatternSelection); static_assert(AntennaPatternSelection); static_assert(AntennaPatternSelection); } // namespace sensor + +void xml_io_stream::write( + std::ostream& os, + const sensor::GriddedAntennaPattern& n, + bofstream* pbofs, + std::string_view name) { + XMLTag tag(xml_io_stream_name_v, "name", name); + tag.write_to_stream(os); + + xml_write_to_stream(os, n.data, pbofs); + + tag.write_to_end_stream(os); +} + +void xml_io_stream::read( + std::istream& is, sensor::GriddedAntennaPattern& n, bifstream* pbifs) { + XMLTag tag{}; + tag.read_from_stream(is); + tag.check_name(xml_io_stream_name_v); + + xml_read_from_stream(is, n.data, pbifs); + + tag.read_from_stream(is); + tag.check_end_name(xml_io_stream_name_v); +} + +void xml_io_stream::write( + std::ostream& os, + const sensor::GaussianAiryAntenna& n, + bofstream* pbofs, + std::string_view name) { + XMLTag tag(xml_io_stream_name_v, "name", name); + tag.write_to_stream(os); + xml_write_to_stream(os, n.aperture_diameter, pbofs); + xml_write_to_stream(os, n.zen_grid, pbofs); + xml_write_to_stream(os, n.azi_grid, pbofs); + xml_write_to_stream(os, n.weight, pbofs); + tag.write_to_end_stream(os); +} + +void xml_io_stream::read( + std::istream& is, sensor::GaussianAiryAntenna& n, bifstream* pbifs) { + XMLTag tag{}; + tag.read_from_stream(is); + tag.check_name(xml_io_stream_name_v); + + xml_read_from_stream(is, n.aperture_diameter, pbifs); + xml_read_from_stream(is, n.zen_grid, pbifs); + xml_read_from_stream(is, n.azi_grid, pbifs); + xml_read_from_stream(is, n.weight, pbifs); + + tag.read_from_stream(is); + tag.check_end_name(xml_io_stream_name_v); +} + +void xml_io_stream::write( + std::ostream& os, + const sensor::PencilBeamAntenna& n, + bofstream* pbofs, + std::string_view name) { + XMLTag tag(xml_io_stream_name_v, "name", name); + tag.write_to_stream(os); + xml_write_to_stream(os, n.weight, pbofs); + tag.write_to_end_stream(os); +} + +void xml_io_stream::read( + std::istream& is, sensor::PencilBeamAntenna& n, bifstream* pbifs) { + XMLTag tag{}; + tag.read_from_stream(is); + tag.check_name(xml_io_stream_name_v); + xml_read_from_stream(is, n.weight, pbifs); + tag.read_from_stream(is); + tag.check_end_name(xml_io_stream_name_v); +} + +void xml_io_stream::write(std::ostream& os, + const sensor::AntennaPattern&, + bofstream*, + std::string_view name) { + XMLTag tag(xml_io_stream_name_v, "name", name); + tag.write_to_stream(os); + tag.write_to_end_stream(os); +} + +void xml_io_stream::read(std::istream& is, + sensor::AntennaPattern&, + bifstream*) { + XMLTag tag{}; + tag.read_from_stream(is); + tag.check_name(xml_io_stream_name_v); + tag.read_from_stream(is); + tag.check_end_name(xml_io_stream_name_v); +} diff --git a/src/core/sensor/antenna_pattern.h b/src/core/sensor/antenna_pattern.h index 812e7748e4..3edf62cf3e 100644 --- a/src/core/sensor/antenna_pattern.h +++ b/src/core/sensor/antenna_pattern.h @@ -1,7 +1,9 @@ #pragma once +#include #include #include +#include #include #include @@ -125,8 +127,22 @@ concept AntennaPatternSelection = requires(const T& antenna, // AntennaPattern format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = false; +struct std::formatter { + format_tags tags; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const sensor::AntennaPattern&, + FmtContext& ctx) const { + return tags.format(ctx, "SensorAntennaPattern"sv); + } }; template <> @@ -135,15 +151,39 @@ struct xml_io_stream_name { }; template <> -struct xml_io_stream_aggregate { - static constexpr bool value = false; +struct xml_io_stream { + static constexpr std::string_view type_name = + xml_io_stream_name_v; + + static void write(std::ostream& os, + const sensor::AntennaPattern& n, + bofstream* pbofs = nullptr, + std::string_view name = ""sv); + + static void read(std::istream& is, + sensor::AntennaPattern& n, + bifstream* pbifs = nullptr); }; // GriddedAntennaPattern format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = true; +struct std::formatter { + format_tags tags; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const sensor::GriddedAntennaPattern& v, + FmtContext& ctx) const { + return tags.format(ctx, v.data); + } }; template <> @@ -152,15 +192,39 @@ struct xml_io_stream_name { }; template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; +struct xml_io_stream { + static constexpr std::string_view type_name = + xml_io_stream_name_v; + + static void write(std::ostream& os, + const sensor::GriddedAntennaPattern& n, + bofstream* pbofs = nullptr, + std::string_view name = ""sv); + + static void read(std::istream& is, + sensor::GriddedAntennaPattern& n, + bifstream* pbifs = nullptr); }; // PencilBeamAntenna format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = true; +struct std::formatter { + format_tags tags; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const sensor::PencilBeamAntenna& v, + FmtContext& ctx) const { + return tags.format(ctx, v.weight); + } }; template <> @@ -169,16 +233,26 @@ struct xml_io_stream_name { }; template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; +struct xml_io_stream { + static constexpr std::string_view type_name = + xml_io_stream_name_v; + + static void write(std::ostream& os, + const sensor::PencilBeamAntenna& n, + bofstream* pbofs = nullptr, + std::string_view name = ""sv); + + static void read(std::istream& is, + sensor::PencilBeamAntenna& n, + bifstream* pbifs = nullptr); }; // GaussianAntenna format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = true; -}; +struct std::formatter + : format_tag_inherit {}; template <> struct xml_io_stream_name { @@ -186,15 +260,37 @@ struct xml_io_stream_name { }; template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; -}; +struct xml_io_stream + : xml_io_stream_inherit {}; // GaussianAiryAntenna format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = true; +struct std::formatter { + format_tags tags; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + [[nodiscard]] constexpr auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const sensor::GaussianAiryAntenna& v, + FmtContext& ctx) const { + auto sep = tags.sep(); + return tags.format(ctx, + v.zen_grid, + sep, + v.azi_grid, + sep, + v.aperture_diameter, + sep, + v.weight); + } }; template <> @@ -203,6 +299,16 @@ struct xml_io_stream_name { }; template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; +struct xml_io_stream { + static constexpr std::string_view type_name = + xml_io_stream_name_v; + + static void write(std::ostream& os, + const sensor::GaussianAiryAntenna& n, + bofstream* pbofs = nullptr, + std::string_view name = ""sv); + + static void read(std::istream& is, + sensor::GaussianAiryAntenna& n, + bifstream* pbifs = nullptr); }; diff --git a/src/core/sensor/frequency_channel_selection.h b/src/core/sensor/frequency_channel_selection.h index cb93d1317b..10c15ac86d 100644 --- a/src/core/sensor/frequency_channel_selection.h +++ b/src/core/sensor/frequency_channel_selection.h @@ -1,10 +1,9 @@ #pragma once +#include #include #include -#include "matpack_mdspan_helpers_gridded_data_t.h" - namespace sensor { //! Free-form channel struct. Others inherit from this. struct Channel; @@ -34,6 +33,7 @@ struct BoxChannel final : Channel { BoxChannel(Numeric lower, Numeric upper, Size N); // [lower, upper] BoxChannel(Numeric hw, Size N); // [-hw, hw] BoxChannel(AscendingGrid f); // f + BoxChannel() = default; // [0, 0] }; struct DiracChannel final : Channel { @@ -46,6 +46,9 @@ struct GaussianChannel final : Channel { GaussianChannel(Numeric f0, Numeric std, Size N, Size M); // f0 +- M*std GaussianChannel(AscendingGrid f, Numeric std); // f, f0 = 0 GaussianChannel(Numeric std, Size N, Size M); // +-M*std, f0 = 0 + GaussianChannel() = default; // f0 = 0, std = 0 + + [[nodiscard]] AscendingGrid center_grid() const; }; } // namespace sensor @@ -69,50 +72,29 @@ struct xml_io_stream_aggregate { // BoxChannel format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = true; -}; - -template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorBoxChannel"; -}; +struct std::formatter + : format_tag_inherit {}; template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; -}; +struct xml_io_stream + : xml_io_stream_inherit {}; // DiracChannel format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = true; -}; +struct std::formatter + : format_tag_inherit {}; template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorDiracChannel"; -}; - -template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; -}; +struct xml_io_stream + : xml_io_stream_inherit {}; // GaussianChannel format tags and XML I/O template <> -struct format_tag_aggregate { - constexpr static bool value = true; -}; +struct std::formatter + : format_tag_inherit {}; template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorGaussianChannel"; -}; - -template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; -}; +struct xml_io_stream + : xml_io_stream_inherit {}; diff --git a/src/core/sensor/frequency_range_selection.h b/src/core/sensor/frequency_range_selection.h index 5f1ad3d764..7e284d0fce 100644 --- a/src/core/sensor/frequency_range_selection.h +++ b/src/core/sensor/frequency_range_selection.h @@ -70,53 +70,48 @@ struct HeterodyneFrequencyRange final : FrequencyRange { }; } // namespace sensor -// FrequencyRange format tags and XML I/O +// FrequencyResponsePath format tags and XML I/O template <> -struct format_tag_aggregate { +struct format_tag_aggregate { constexpr static bool value = true; }; template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorFrequencyRange"; +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorFrequencyResponsePath"; }; template <> -struct xml_io_stream_aggregate { +struct xml_io_stream_aggregate { static constexpr bool value = true; }; -// HeterodyneFrequencyRange format tags and XML I/O +// FrequencyRange format tags and XML I/O template <> -struct format_tag_aggregate { +struct format_tag_aggregate { constexpr static bool value = true; }; template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorHeterodyneFrequencyRange"; +struct xml_io_stream_name { + static constexpr std::string_view name = "SensorFrequencyRange"; }; template <> -struct xml_io_stream_aggregate { +struct xml_io_stream_aggregate { static constexpr bool value = true; }; -// FrequencyResponsePath format tags and XML I/O - -template <> -struct format_tag_aggregate { - constexpr static bool value = true; -}; +// HeterodyneFrequencyRange format tags and XML I/O template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorFrequencyResponsePath"; -}; +struct std::formatter + : format_tag_inherit {}; template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; -}; +struct xml_io_stream + : xml_io_stream_inherit {}; diff --git a/src/core/util/format_tags.h b/src/core/util/format_tags.h index 345df37e8f..1d92d39eb1 100644 --- a/src/core/util/format_tags.h +++ b/src/core/util/format_tags.h @@ -616,10 +616,13 @@ template constexpr bool format_tag_aggregate_v = format_tag_aggregate::value; template -concept format_tag_aggratable = format_tag_aggregate_v and arts_aggregate; +concept format_tag_aggratable = format_tag_aggregate_v; template struct std::formatter { + static_assert(arts_aggregate, + "format_tag_aggratable types must be arts_aggregate"); + format_tags tags; [[nodiscard]] constexpr auto& inner_fmt() { return *this; } @@ -636,3 +639,22 @@ struct std::formatter { return tags.format(ctx, as_tuple(v)); } }; + +template U> +struct format_tag_inherit { + format_tags tags; + + [[nodiscard]] constexpr auto& inner_fmt() { return *this; } + + [[nodiscard]] constexpr const auto& inner_fmt() const { return *this; } + + constexpr std::format_parse_context::iterator parse( + std::format_parse_context& ctx) { + return parse_format_tags(tags, ctx); + } + + template + FmtContext::iterator format(const U& v, FmtContext& ctx) const { + return tags.format(ctx, reinterpret_cast(v)); + } +}; diff --git a/src/core/xml/xml.h b/src/core/xml/xml.h index 9b73d00949..9e16178134 100644 --- a/src/core/xml/xml.h +++ b/src/core/xml/xml.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include diff --git a/src/core/xml/xml_io_stream.h b/src/core/xml/xml_io_stream.h index 52e172656a..ff3509c06e 100644 --- a/src/core/xml/xml_io_stream.h +++ b/src/core/xml/xml_io_stream.h @@ -103,28 +103,3 @@ template concept xml_io_parseable = requires(std::span b, std::istream& is) { xml_io_stream::parse(b, is); }; - -//! Explicitly inherit and cast between spans -template T> -struct xml_io_stream_inherit : xml_io_stream { - static void get(std::span b, bifstream* pbifs) - requires(xml_io_binary) - { - xml_io_stream::get(std::span{reinterpret_cast(b.data()), b.size()}, - pbifs); - } - - static void put(std::span b, bofstream* pbofs) - requires(xml_io_binary) - { - xml_io_stream::put( - std::span{reinterpret_cast(b.data()), b.size()}, pbofs); - } - - static void parse(std::span b, std::istream& is) - requires(xml_io_parseable) - { - xml_io_stream::parse(std::span{reinterpret_cast(b.data()), b.size()}, - is); - } -}; diff --git a/src/core/xml/xml_io_stream_aggregate.h b/src/core/xml/xml_io_stream_aggregate.h index 4797bf4dd1..ab27216fb7 100644 --- a/src/core/xml/xml_io_stream_aggregate.h +++ b/src/core/xml/xml_io_stream_aggregate.h @@ -14,13 +14,15 @@ template constexpr bool xml_io_stream_aggregate_v = xml_io_stream_aggregate::value; template -concept xml_io_aggregratable = - xml_io_stream_aggregate_v and arts_aggregate; +concept xml_io_aggregratable = xml_io_stream_aggregate_v; template struct xml_io_stream { constexpr static std::string_view type_name = xml_io_stream_name_v; + static_assert(arts_aggregate, + "xml_io_aggregratable types must be arts_aggregate"); + using _lambda = decltype([](auto& v) { return as_tuple(v); }); using ctup_t = std::invoke_result_t<_lambda, const T&>; using mtup_t = std::invoke_result_t<_lambda, T&>; diff --git a/src/core/xml/xml_io_stream_inherit.h b/src/core/xml/xml_io_stream_inherit.h new file mode 100644 index 0000000000..5574013732 --- /dev/null +++ b/src/core/xml/xml_io_stream_inherit.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include + +#include "xml_io_base.h" +#include "xml_io_stream.h" + +using namespace std::literals; + +template U> + requires(not std::same_as) +struct xml_io_stream_inherit { + static constexpr std::string_view type_name = xml_io_stream_name_v; + + struct _no_name_ {}; + static_assert(type_name != xml_io_stream_name_v<_no_name_>, + "xml_io_stream_inherit requires that the base type has a name"); + + static void write(std::ostream& os, + const U& n, + bofstream* pbofs = nullptr, + std::string_view name = ""sv) { + if constexpr (type_name != xml_io_stream_name_v) { + XMLTag tag(type_name, "name", name); + tag.write_to_stream(os); + xml_io_stream::write(os, reinterpret_cast(n), pbofs); + tag.write_to_end_stream(os); + } else { + xml_io_stream::write(os, reinterpret_cast(n), pbofs, name); + } + } + + static void read(std::istream& is, U& n, bifstream* pbifs = nullptr) { + if constexpr (type_name != xml_io_stream_name_v) { + XMLTag tag{}; + tag.read_from_stream(is); + tag.check_name(type_name); + } + + xml_io_stream::read(is, reinterpret_cast(n), pbifs); + + if constexpr (type_name != xml_io_stream_name_v) { + XMLTag tag{}; + tag.read_from_stream(is); + tag.check_end_name(type_name); + } + } + + static void get(std::span b, bifstream* pbifs) + requires(xml_io_binary) + { + xml_io_stream::get(std::span{reinterpret_cast(b.data()), b.size()}, + pbifs); + } + + static void put(std::span b, bofstream* pbofs) + requires(xml_io_binary) + { + xml_io_stream::put( + std::span{reinterpret_cast(b.data()), b.size()}, pbofs); + } + + static void parse(std::span b, std::istream& is) + requires(xml_io_parseable) + { + xml_io_stream::parse(std::span{reinterpret_cast(b.data()), b.size()}, + is); + } +}; diff --git a/src/python_interface/hpy_arts.h b/src/python_interface/hpy_arts.h index c307ef89d6..34e87aa68d 100644 --- a/src/python_interface/hpy_arts.h +++ b/src/python_interface/hpy_arts.h @@ -17,8 +17,8 @@ namespace Python { namespace py = nanobind; using namespace py::literals; -template -void xml_interface(py::class_& c) { +template +void xml_interface(py::class_& c) { c.def( "savexml", [](const T& x, @@ -260,8 +260,8 @@ void value_holder_interface(py::class_>& c) { } } -template -void str_interface(py::class_& c) { +template +void str_interface(py::class_& c) { c.def("__format__", [](const T& x, std::string fmt) { if constexpr (std::formattable) { fmt = std::format("{}{}{}", "{:"sv, fmt, "}"sv); diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 9f5a8f5fb0..14103f2b7a 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -23,6 +23,7 @@ #include "hpy_matpack.h" #include "hpy_numpy.h" #include "hpy_vector.h" +#include "xml_io_stream.h" namespace Python { void py_sensor(py::module_& m) try { @@ -477,7 +478,7 @@ See :meth:`SensorObsel.normalize` for details. "pos"_a, "bore_los"_a, R"(Map the antenna pattern onto one observation element.)"); - generic_interface(sap); + // generic_interface(sap); -- this should break things. The class is somewhat abstract auto sgp = py::class_( m, "SensorGriddedAntennaPattern"); @@ -533,14 +534,31 @@ See :meth:`SensorObsel.normalize` for details. "A Gaussianized Airy antenna whose width scales with wavelength and aperture diameter."; sgairy.def( py::init(), - "zen_grid"_a, + "dzen_grid"_a, "azi_grid"_a, "aperture_diameter"_a, "weight"_a = Stokvec{1.0, 0.0, 0.0, 0.0}, "Construct a Gaussian Airy antenna on the supplied local grids." " The channel frequency grid must be strictly positive because the" " current builder path does not carry a separate reference frequency."); + sgairy.def_rw( + "aperture_diameter", + &sensor::GaussianAiryAntenna::aperture_diameter, + "Aperture diameter in the same length units as the zenith grid.\n\n.. :class:`Numeric`"); + sgairy.def_rw( + "weight", + &sensor::GaussianAiryAntenna::weight, + "Stokes weights of the antenna response.\n\n.. :class:`Stokvec`"); + sgairy.def_rw( + "zen_grid", + &sensor::GaussianAiryAntenna::zen_grid, + "Local zenith grid of the antenna response.\n\n.. :class:`ZenGrid`"); + sgairy.def_rw( + "azi_grid", + &sensor::GaussianAiryAntenna::azi_grid, + "Local azimuth grid of the antenna response.\n\n.. :class:`AziGrid`"); generic_interface(sgairy); + static_assert(arts_xml_ioable); auto sch = py::class_(m, "SensorChannel"); sch.doc() = "Base class for relative spectrometer channel responses."; diff --git a/src/tests/test_antenna_pattern.cc b/src/tests/test_antenna_pattern.cc index 6271981114..9ea5f46cae 100644 --- a/src/tests/test_antenna_pattern.cc +++ b/src/tests/test_antenna_pattern.cc @@ -21,6 +21,14 @@ void assert_stokvec(Stokvec actual, } } +Stokvec sum_column_weights(const sensor::Obsel& obsel, Size ifreq) { + Stokvec sum{0.0, 0.0, 0.0, 0.0}; + for (Size isample = 0; isample < obsel.poslos_grid().size(); ++isample) { + sum += obsel.weight_matrix()[isample, ifreq]; + } + return sum; +} + void test_gaussian_initialization_uses_antenna_frame_offsets() { const Stokvec peak_weight{2.0, 1.0, 0.0, 0.0}; const sensor::GaussianAntenna ant{ @@ -72,26 +80,35 @@ void test_gaussian_airy_is_frequency_dependent() { const auto obsel = ant(channel, {600e3, 10.0, 20.0}, {45.0, 30.0}); - assert_stokvec(obsel.weight_matrix()[0, 0], - 0.5 * peak_weight, + const Numeric low_gain = gaussian_airy_expected_gain(0.2, 100.0e9, 1.0); + const Numeric high_gain = gaussian_airy_expected_gain(0.2, 200.0e9, 1.0); + + const Numeric low_norm = 1.0 + low_gain; + const Numeric high_norm = 1.0 + high_gain; + + assert_stokvec(obsel.weight_matrix()[0, 0], + (0.5 / low_norm) * peak_weight, 1e-12, "gaussian airy bore low frequency"); assert_stokvec(obsel.weight_matrix()[0, 1], - 0.5 * peak_weight, + (0.5 / high_norm) * peak_weight, 1e-12, "gaussian airy bore high frequency"); - const Numeric low_gain = gaussian_airy_expected_gain(0.2, 100.0e9, 1.0); - const Numeric high_gain = gaussian_airy_expected_gain(0.2, 200.0e9, 1.0); - assert_stokvec(obsel.weight_matrix()[1, 0], - 0.5 * low_gain * peak_weight, + (0.5 * low_gain / low_norm) * peak_weight, 1e-12, "gaussian airy off-axis low frequency"); assert_stokvec(obsel.weight_matrix()[1, 1], - 0.5 * high_gain * peak_weight, + (0.5 * high_gain / high_norm) * peak_weight, 1e-12, "gaussian airy off-axis high frequency"); + + assert_stokvec( + sum_column_weights(obsel, 0), 0.5 * peak_weight, 1e-12, "gaussian airy normalized low frequency"); + assert_stokvec( + sum_column_weights(obsel, 1), 0.5 * peak_weight, 1e-12, "gaussian airy normalized high frequency"); + const auto low_weight = obsel.weight_matrix()[1, 0]; const auto high_weight = obsel.weight_matrix()[1, 1]; ARTS_USER_ERROR_IF(low_weight[0] <= high_weight[0], @@ -99,7 +116,7 @@ void test_gaussian_airy_is_frequency_dependent() { } void test_gaussian_airy_matches_frequency_specific_gaussian_pattern() { - const ZenGrid zen_grid{{0.0, 0.2}}; + const ZenGrid zen_grid{{0.1, 0.2}}; const AziGrid azi_grid{{0.0, 0.2}}; const Stokvec peak_weight{2.0, 0.0, 0.0, 0.0}; const sensor::GaussianAiryAntenna ant{zen_grid, azi_grid, 1.0, peak_weight}; @@ -113,19 +130,70 @@ void test_gaussian_airy_matches_frequency_specific_gaussian_pattern() { const sensor::GaussianAntenna gaussian{ zen_grid, azi_grid, airy_std, airy_std, peak_weight}; + Numeric normalization = 0.0; + for (Size izen = 0; izen < zen_grid.size(); ++izen) { + for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { + normalization += gaussian.data[izen, iazi][0] / peak_weight[0]; + } + } + Size isample = 0; for (Size izen = 0; izen < zen_grid.size(); ++izen) { for (Size iazi = 0; iazi < azi_grid.size(); ++iazi) { assert_stokvec(obsel.weight_matrix()[isample, ifreq], - channel.weights()[ifreq] * gaussian.data[izen, iazi], + (channel.weights()[ifreq] / normalization) * + gaussian.data[izen, iazi], 1e-12, "gaussian airy per-frequency gaussian match"); ++isample; } } + + assert_stokvec(sum_column_weights(obsel, ifreq), + channel.weights()[ifreq] * peak_weight, + 1e-12, + "gaussian airy per-frequency column normalization"); } } + void test_degenerate_azimuth_ring_is_collapsed() { + const sensor::GaussianAntenna gaussian{ + ZenGrid{{0.0, 1.0}}, AziGrid{{0.0, 120.0, 240.0}}, 1.0, 1.0}; + const auto gaussian_obsel = + gaussian(sensor::DiracChannel{100.0e9}, {600e3, 10.0, 20.0}, {45.0, 30.0}); + + ARTS_USER_ERROR_IF(gaussian_obsel.poslos_grid().size() != 4, + "Expected zero-zenith azimuth collapse to leave 4 LOS samples, got {}", + gaussian_obsel.poslos_grid().size()) + assert_stokvec(gaussian_obsel.weight_matrix()[0, 0], + {3.0, 0.0, 0.0, 0.0}, + 1e-12, + "collapsed gaussian zero-zenith ring weight"); + + const sensor::GaussianAiryAntenna airy{ + ZenGrid{{0.0, 0.2}}, AziGrid{{0.0, 120.0, 240.0}}, 1.0}; + const auto airy_obsel = + airy(sensor::DiracChannel{100.0e9}, {600e3, 10.0, 20.0}, {45.0, 30.0}); + const Numeric gain = gaussian_airy_expected_gain(0.2, 100.0e9, 1.0); + const Numeric retained_scale = 3.0 * (1.0 + gain) / (1.0 + 3.0 * gain); + + ARTS_USER_ERROR_IF(airy_obsel.poslos_grid().size() != 4, + "Expected Gaussian Airy zero-zenith azimuth collapse to leave 4 LOS samples, got {}", + airy_obsel.poslos_grid().size()) + assert_stokvec(airy_obsel.weight_matrix()[0, 0], + {retained_scale / (3.0 * (1.0 + gain)), 0.0, 0.0, 0.0}, + 1e-6, + "collapsed gaussian airy center is renormalized with retained points"); + assert_stokvec(airy_obsel.weight_matrix()[1, 0], + {retained_scale * gain / (3.0 * (1.0 + gain)), 0.0, 0.0, 0.0}, + 1e-6, + "collapsed gaussian airy off-axis weight is renormalized uniformly"); + assert_stokvec(sum_column_weights(airy_obsel, 0), + {1.0, 0.0, 0.0, 0.0}, + 1e-6, + "collapsed gaussian airy column renormalizes to unity"); + } + void test_gaussian_airy_rejects_nonpositive_frequencies() { const sensor::GaussianAiryAntenna ant{ZenGrid{{0.0}}, AziGrid{{0.0}}, 1.0}; @@ -146,6 +214,7 @@ int main() { test_gaussian_initialization_uses_antenna_frame_offsets(); test_gaussian_airy_is_frequency_dependent(); test_gaussian_airy_matches_frequency_specific_gaussian_pattern(); + test_degenerate_azimuth_ring_is_collapsed(); test_gaussian_airy_rejects_nonpositive_frequencies(); return 0; } \ No newline at end of file diff --git a/tests/core/sensor/30cm-antenna.py b/tests/core/sensor/30cm-antenna.py new file mode 100644 index 0000000000..4221e94f94 --- /dev/null +++ b/tests/core/sensor/30cm-antenna.py @@ -0,0 +1,34 @@ +import pyarts3 as pa +import numpy as np + +NZA = 11 +NAZ = 15 + +zen_grid = np.geomspace(1, 3, NZA) - 1 +azi_grid = np.linspace(0, 360, NAZ + 1)[:-1] + +ant1 = pa.arts.SensorGaussianAiryAntenna(zen_grid, azi_grid, 30e-2, "Iv") +ant2 = pa.arts.SensorGaussianAiryAntenna(zen_grid + 1e-3, azi_grid, 30e-2, "I") +ch = pa.arts.SensorBoxChannel(1e9, 1e12, 1001) + +obsel1 = ant1(ch, [1, 0, 0], [45, 30]) +obsel2 = ant2(ch, [1, 0, 0], [45, 30]) + +poslos1 = np.asarray(obsel1.poslos) +poslos2 = np.asarray(obsel2.poslos) +weights1 = np.asarray(obsel1.weight_matrix.dense) +weights2 = np.asarray(obsel2.weight_matrix.dense) + +assert np.allclose(weights1.sum(), 2.0), \ + "Should be normalized to 2 (since polarized)" +assert np.allclose(weights2.sum(), 1.0), \ + "Should be normalized to 1 (since non-polarized)" +assert len(poslos1) == (NZA * NAZ - NAZ + 1), \ + "Should have one poslos per non-zero DZA, but only one for zero DZA - has zero DZA" +assert len(poslos2) == NZA * NAZ, \ + "Should have one poslos per non-zero DZA, but only one for zero DZA - has no zero DZA" + +ant = pa.arts.SensorGaussianAiryAntenna(zen_grid, azi_grid, 30e-2, "I") + +pa.plots.plot(obsel1, point_spread=True) +pa.plots.plot(obsel2, point_spread=True, frame='local', type='contour') From 5db48573c78b6d791abbedcde238eb04dcd9bef8 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Mon, 18 May 2026 17:00:59 +0900 Subject: [PATCH 14/21] Fix tst --- tests/core/sensor/30cm-antenna.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/core/sensor/30cm-antenna.py b/tests/core/sensor/30cm-antenna.py index 4221e94f94..72d626664a 100644 --- a/tests/core/sensor/30cm-antenna.py +++ b/tests/core/sensor/30cm-antenna.py @@ -1,8 +1,10 @@ import pyarts3 as pa import numpy as np -NZA = 11 -NAZ = 15 +NZA = 20 +NAZ = 30 +POS = [1, 0, 0] +LOS = [45, 30] zen_grid = np.geomspace(1, 3, NZA) - 1 azi_grid = np.linspace(0, 360, NAZ + 1)[:-1] @@ -11,8 +13,8 @@ ant2 = pa.arts.SensorGaussianAiryAntenna(zen_grid + 1e-3, azi_grid, 30e-2, "I") ch = pa.arts.SensorBoxChannel(1e9, 1e12, 1001) -obsel1 = ant1(ch, [1, 0, 0], [45, 30]) -obsel2 = ant2(ch, [1, 0, 0], [45, 30]) +obsel1 = ant1(ch, POS, LOS) +obsel2 = ant2(ch, POS, LOS) poslos1 = np.asarray(obsel1.poslos) poslos2 = np.asarray(obsel2.poslos) @@ -31,4 +33,4 @@ ant = pa.arts.SensorGaussianAiryAntenna(zen_grid, azi_grid, 30e-2, "I") pa.plots.plot(obsel1, point_spread=True) -pa.plots.plot(obsel2, point_spread=True, frame='local', type='contour') +pa.plots.plot(obsel2, point_spread=True, frame='local', type='contour', levels=10) From 3bdbb28d8adfba0e02aaadf0030cd859b4ede964 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Tue, 19 May 2026 11:15:25 +0900 Subject: [PATCH 15/21] test --- src/tests/test_sensor_builder.cc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tests/test_sensor_builder.cc b/src/tests/test_sensor_builder.cc index 36346f9573..1c3c703256 100644 --- a/src/tests/test_sensor_builder.cc +++ b/src/tests/test_sensor_builder.cc @@ -126,13 +126,15 @@ void test_sensor_builder_uses_gaussian_airy_frequency_dependence() { const Numeric low_gain = gaussian_airy_expected_gain(0.2, 100.0e9, 1.0); const Numeric high_gain = gaussian_airy_expected_gain(0.2, 200.0e9, 1.0); + const Numeric low_norm = 1.0 + low_gain; + const Numeric high_norm = 1.0 + high_gain; assert_stokvec(obsels[0].weight_matrix()[1, 0], - 0.5 * low_gain * peak_weight, + 0.5 * low_gain * peak_weight / low_norm, 1e-12, "builder gaussian airy off-axis low frequency"); assert_stokvec(obsels[0].weight_matrix()[1, 1], - 0.5 * high_gain * peak_weight, + 0.5 * high_gain * peak_weight / high_norm, 1e-12, "builder gaussian airy off-axis high frequency"); } From 2a32e5010f787c4f8e03256de841ace19547fbb1 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Tue, 19 May 2026 17:04:07 +0900 Subject: [PATCH 16/21] Aggregrate fix? --- src/core/util/aggregate_helper.h | 105 ++++++++++++++++++++++++------- 1 file changed, 84 insertions(+), 21 deletions(-) diff --git a/src/core/util/aggregate_helper.h b/src/core/util/aggregate_helper.h index c68cc0961b..c4d272d321 100644 --- a/src/core/util/aggregate_helper.h +++ b/src/core/util/aggregate_helper.h @@ -110,104 +110,167 @@ concept aggregate_20 = aggregate_19 and requires { {}); }; -auto as_tuple(aggregate_0 auto&) { return std::tuple<>(); } +template +concept aggregate_0_exact = aggregate_0 and not aggregate_1; + +template +concept aggregate_1_exact = aggregate_1 and not aggregate_2; + +template +concept aggregate_2_exact = aggregate_2 and not aggregate_3; + +template +concept aggregate_3_exact = aggregate_3 and not aggregate_4; + +template +concept aggregate_4_exact = aggregate_4 and not aggregate_5; + +template +concept aggregate_5_exact = aggregate_5 and not aggregate_6; + +template +concept aggregate_6_exact = aggregate_6 and not aggregate_7; + +template +concept aggregate_7_exact = aggregate_7 and not aggregate_8; + +template +concept aggregate_8_exact = aggregate_8 and not aggregate_9; + +template +concept aggregate_9_exact = aggregate_9 and not aggregate_10; + +template +concept aggregate_10_exact = aggregate_10 and not aggregate_11; + +template +concept aggregate_11_exact = aggregate_11 and not aggregate_12; + +template +concept aggregate_12_exact = aggregate_12 and not aggregate_13; + +template +concept aggregate_13_exact = aggregate_13 and not aggregate_14; + +template +concept aggregate_14_exact = aggregate_14 and not aggregate_15; + +template +concept aggregate_15_exact = aggregate_15 and not aggregate_16; + +template +concept aggregate_16_exact = aggregate_16 and not aggregate_17; + +template +concept aggregate_17_exact = aggregate_17 and not aggregate_18; + +template +concept aggregate_18_exact = aggregate_18 and not aggregate_19; + +template +concept aggregate_19_exact = aggregate_19 and not aggregate_20; + +template +concept aggregate_20_exact = aggregate_20; + +auto as_tuple(aggregate_0_exact auto&) { return std::tuple<>(); } -auto as_tuple(aggregate_1 auto& x) { +auto as_tuple(aggregate_1_exact auto& x) { auto&& [a] = x; return std::tie(a); } -auto as_tuple(aggregate_2 auto& x) { +auto as_tuple(aggregate_2_exact auto& x) { auto&& [a, b] = x; return std::tie(a, b); } -auto as_tuple(aggregate_3 auto& x) { +auto as_tuple(aggregate_3_exact auto& x) { auto&& [a, b, c] = x; return std::tie(a, b, c); } -auto as_tuple(aggregate_4 auto& x) { +auto as_tuple(aggregate_4_exact auto& x) { auto&& [a, b, c, d] = x; return std::tie(a, b, c, d); } -auto as_tuple(aggregate_5 auto& x) { +auto as_tuple(aggregate_5_exact auto& x) { auto&& [a, b, c, d, e] = x; return std::tie(a, b, c, d, e); } -auto as_tuple(aggregate_6 auto& x) { +auto as_tuple(aggregate_6_exact auto& x) { auto&& [a, b, c, d, e, f] = x; return std::tie(a, b, c, d, e, f); } -auto as_tuple(aggregate_7 auto& x) { +auto as_tuple(aggregate_7_exact auto& x) { auto&& [a, b, c, d, e, f, g] = x; return std::tie(a, b, c, d, e, f, g); } -auto as_tuple(aggregate_8 auto& x) { +auto as_tuple(aggregate_8_exact auto& x) { auto&& [a, b, c, d, e, f, g, h] = x; return std::tie(a, b, c, d, e, f, g, h); } -auto as_tuple(aggregate_9 auto& x) { +auto as_tuple(aggregate_9_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i] = x; return std::tie(a, b, c, d, e, f, g, h, i); } -auto as_tuple(aggregate_10 auto& x) { +auto as_tuple(aggregate_10_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j] = x; return std::tie(a, b, c, d, e, f, g, h, i, j); } -auto as_tuple(aggregate_11 auto& x) { +auto as_tuple(aggregate_11_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k); } -auto as_tuple(aggregate_12 auto& x) { +auto as_tuple(aggregate_12_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l); } -auto as_tuple(aggregate_13 auto& x) { +auto as_tuple(aggregate_13_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m); } -auto as_tuple(aggregate_14 auto& x) { +auto as_tuple(aggregate_14_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n); } -auto as_tuple(aggregate_15 auto& x) { +auto as_tuple(aggregate_15_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o); } -auto as_tuple(aggregate_16 auto& x) { +auto as_tuple(aggregate_16_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p); } -auto as_tuple(aggregate_17 auto& x) { +auto as_tuple(aggregate_17_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q); } -auto as_tuple(aggregate_18 auto& x) { +auto as_tuple(aggregate_18_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r); } -auto as_tuple(aggregate_19 auto& x) { +auto as_tuple(aggregate_19_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s); } -auto as_tuple(aggregate_20 auto& x) { +auto as_tuple(aggregate_20_exact auto& x) { auto&& [a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t] = x; return std::tie(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t); } From 122fdac603cad2b4d5198062bfacd3bc6b5b76ae Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Tue, 19 May 2026 18:31:07 +0900 Subject: [PATCH 17/21] Aggregrate fix? --- src/core/util/aggregate_helper.h | 262 ++++++++++++++++++++++++++----- 1 file changed, 223 insertions(+), 39 deletions(-) diff --git a/src/core/util/aggregate_helper.h b/src/core/util/aggregate_helper.h index c4d272d321..2e797a1aef 100644 --- a/src/core/util/aggregate_helper.h +++ b/src/core/util/aggregate_helper.h @@ -3,111 +3,295 @@ #include #include +struct aggregate_init { + template + constexpr operator T() const noexcept; +}; + template concept aggregate_0 = std::is_aggregate_v and requires { T{}; }; template -concept aggregate_1 = aggregate_0 and requires { T({}); }; +concept aggregate_1 = aggregate_0 and requires { T{aggregate_init{}}; }; template -concept aggregate_2 = aggregate_1 and requires { T({}, {}); }; +concept aggregate_2 = aggregate_1 and requires { + T{aggregate_init{}, aggregate_init{}}; +}; template -concept aggregate_3 = aggregate_2 and requires { T({}, {}, {}); }; +concept aggregate_3 = aggregate_2 and requires { + T{aggregate_init{}, aggregate_init{}, aggregate_init{}}; +}; template -concept aggregate_4 = aggregate_3 and requires { T({}, {}, {}, {}); }; +concept aggregate_4 = aggregate_3 and requires { + T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}}; +}; template -concept aggregate_5 = aggregate_4 and requires { T({}, {}, {}, {}, {}); }; +concept aggregate_5 = aggregate_4 and requires { + T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}}; +}; template concept aggregate_6 = - aggregate_5 and requires { T({}, {}, {}, {}, {}, {}); }; + aggregate_5 and requires { + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; + }; template concept aggregate_7 = - aggregate_6 and requires { T({}, {}, {}, {}, {}, {}, {}); }; + aggregate_6 and requires { + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; + }; template concept aggregate_8 = - aggregate_7 and requires { T({}, {}, {}, {}, {}, {}, {}, {}); }; + aggregate_7 and requires { + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; + }; template concept aggregate_9 = - aggregate_8 and requires { T({}, {}, {}, {}, {}, {}, {}, {}, {}); }; + aggregate_8 and requires { + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; + }; template concept aggregate_10 = - aggregate_9 and requires { T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}); }; + aggregate_9 and requires { + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; + }; template concept aggregate_11 = aggregate_10 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_12 = aggregate_11 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_13 = aggregate_12 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_14 = aggregate_13 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_15 = aggregate_14 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_16 = aggregate_15 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_17 = aggregate_16 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_18 = aggregate_17 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_19 = aggregate_18 and requires { - T({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template concept aggregate_20 = aggregate_19 and requires { - T({}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}, - {}); + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template From 891fc075676c28d7ffd2740f56152ab24363739b Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Wed, 20 May 2026 11:49:15 +0900 Subject: [PATCH 18/21] Hide some names --- src/core/util/aggregate_helper.h | 153 ++++++++++++++----------------- 1 file changed, 69 insertions(+), 84 deletions(-) diff --git a/src/core/util/aggregate_helper.h b/src/core/util/aggregate_helper.h index 2e797a1aef..801201a82e 100644 --- a/src/core/util/aggregate_helper.h +++ b/src/core/util/aggregate_helper.h @@ -3,6 +3,7 @@ #include #include +namespace { struct aggregate_init { template constexpr operator T() const noexcept; @@ -15,9 +16,8 @@ template concept aggregate_1 = aggregate_0 and requires { T{aggregate_init{}}; }; template -concept aggregate_2 = aggregate_1 and requires { - T{aggregate_init{}, aggregate_init{}}; -}; +concept aggregate_2 = + aggregate_1 and requires { T{aggregate_init{}, aggregate_init{}}; }; template concept aggregate_3 = aggregate_2 and requires { @@ -31,80 +31,37 @@ concept aggregate_4 = aggregate_3 and requires { template concept aggregate_5 = aggregate_4 and requires { - T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}}; + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; }; template -concept aggregate_6 = - aggregate_5 and requires { - T{aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}}; - }; - -template -concept aggregate_7 = - aggregate_6 and requires { - T{aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}}; - }; - -template -concept aggregate_8 = - aggregate_7 and requires { - T{aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}}; - }; - -template -concept aggregate_9 = - aggregate_8 and requires { - T{aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}}; - }; - -template -concept aggregate_10 = - aggregate_9 and requires { - T{aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}, - aggregate_init{}}; - }; +concept aggregate_6 = aggregate_5 and requires { + T{aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; +}; template -concept aggregate_11 = aggregate_10 and requires { +concept aggregate_7 = aggregate_6 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; +}; + +template +concept aggregate_8 = aggregate_7 and requires { + T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -115,10 +72,21 @@ concept aggregate_11 = aggregate_10 and requires { }; template -concept aggregate_12 = aggregate_11 and requires { +concept aggregate_9 = aggregate_8 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; +}; + +template +concept aggregate_10 = aggregate_9 and requires { + T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -131,9 +99,23 @@ concept aggregate_12 = aggregate_11 and requires { }; template -concept aggregate_13 = aggregate_12 and requires { +concept aggregate_11 = aggregate_10 and requires { T{aggregate_init{}, aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}, + aggregate_init{}}; +}; + +template +concept aggregate_12 = aggregate_11 and requires { + T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -148,7 +130,7 @@ concept aggregate_13 = aggregate_12 and requires { }; template -concept aggregate_14 = aggregate_13 and requires { +concept aggregate_13 = aggregate_12 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -161,12 +143,11 @@ concept aggregate_14 = aggregate_13 and requires { aggregate_init{}, aggregate_init{}, aggregate_init{}, - aggregate_init{}, aggregate_init{}}; }; template -concept aggregate_15 = aggregate_14 and requires { +concept aggregate_14 = aggregate_13 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -180,12 +161,11 @@ concept aggregate_15 = aggregate_14 and requires { aggregate_init{}, aggregate_init{}, aggregate_init{}, - aggregate_init{}, aggregate_init{}}; }; template -concept aggregate_16 = aggregate_15 and requires { +concept aggregate_15 = aggregate_14 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -200,12 +180,11 @@ concept aggregate_16 = aggregate_15 and requires { aggregate_init{}, aggregate_init{}, aggregate_init{}, - aggregate_init{}, aggregate_init{}}; }; template -concept aggregate_17 = aggregate_16 and requires { +concept aggregate_16 = aggregate_15 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -221,12 +200,11 @@ concept aggregate_17 = aggregate_16 and requires { aggregate_init{}, aggregate_init{}, aggregate_init{}, - aggregate_init{}, aggregate_init{}}; }; template -concept aggregate_18 = aggregate_17 and requires { +concept aggregate_17 = aggregate_16 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -243,12 +221,11 @@ concept aggregate_18 = aggregate_17 and requires { aggregate_init{}, aggregate_init{}, aggregate_init{}, - aggregate_init{}, aggregate_init{}}; }; template -concept aggregate_19 = aggregate_18 and requires { +concept aggregate_18 = aggregate_17 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -266,12 +243,11 @@ concept aggregate_19 = aggregate_18 and requires { aggregate_init{}, aggregate_init{}, aggregate_init{}, - aggregate_init{}, aggregate_init{}}; }; template -concept aggregate_20 = aggregate_19 and requires { +concept aggregate_19 = aggregate_18 and requires { T{aggregate_init{}, aggregate_init{}, aggregate_init{}, @@ -290,10 +266,18 @@ concept aggregate_20 = aggregate_19 and requires { aggregate_init{}, aggregate_init{}, aggregate_init{}, - aggregate_init{}, aggregate_init{}}; }; +template +concept aggregate_20 = aggregate_19 and requires { + T{aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, + aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, + aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, + aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}, + aggregate_init{}, aggregate_init{}, aggregate_init{}, aggregate_init{}}; +}; + template concept aggregate_0_exact = aggregate_0 and not aggregate_1; @@ -356,6 +340,7 @@ concept aggregate_19_exact = aggregate_19 and not aggregate_20; template concept aggregate_20_exact = aggregate_20; +} // namespace auto as_tuple(aggregate_0_exact auto&) { return std::tuple<>(); } From 758c80253241053d8db4c710b4cb55b74231e797 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Thu, 21 May 2026 17:07:14 +0900 Subject: [PATCH 19/21] Full sensor --- .../sensor/frequency_bandpass_filters.cpp | 217 ++++++++++++++---- src/core/sensor/frequency_bandpass_filters.h | 2 +- .../sensor/frequency_channel_selection.cpp | 93 +++++++- src/core/sensor/frequency_channel_selection.h | 48 +++- src/core/sensor/obsel.cpp | 43 +++- src/core/sensor/obsel.h | 4 +- src/core/sensor/sensor_builder.cpp | 52 +++-- src/core/sensor/sensor_builder.h | 17 +- src/python_interface/py_sensor.cpp | 159 ++++++++----- src/tests/test_sensor_builder.cc | 8 +- .../sensor/600ghz-upper-sideband-sensor.py | 56 +++++ 11 files changed, 553 insertions(+), 146 deletions(-) create mode 100644 tests/core/sensor/600ghz-upper-sideband-sensor.py diff --git a/src/core/sensor/frequency_bandpass_filters.cpp b/src/core/sensor/frequency_bandpass_filters.cpp index 821dd7a374..d02e8045e4 100644 --- a/src/core/sensor/frequency_bandpass_filters.cpp +++ b/src/core/sensor/frequency_bandpass_filters.cpp @@ -1,10 +1,13 @@ #include "frequency_bandpass_filters.h" +#include + #include #include #include #include #include +#include namespace sensor { namespace { @@ -55,9 +58,7 @@ void add_support_points(std::vector& points, void sort_unique(std::vector& points) { stdr::sort(points); - points.erase(std::unique(points.begin(), - points.end(), - [](Numeric a, Numeric b) { return is_close(a, b); }), + points.erase(std::unique(points.begin(), points.end(), &is_close), points.end()); } @@ -93,29 +94,104 @@ void deduplicate_zero_frequency_images( samples = std::move(unique_samples); } -} // namespace -Numeric BandpassFilter::operator()(Numeric f) const { - Numeric weight = 0.0; - for (const auto& filter : filters) weight += sample_filter(filter, f); - return weight; +using InterpolationPoint = + std::variant, + lagrange_interp::lag_t<1, lagrange_interp::identity>>; + +struct FoldedLocalPoint { + InterpolationPoint interpolation; + std::vector> folded_samples; +}; + +InterpolationPoint make_interpolation_point(const AscendingGrid& grid, + Numeric f) { + return lagrange_interp::variant_lag(grid, f); } -Vector BandpassFilter::operator()(ConstVectorView f) const { - Vector out(f.size(), 0.0); +Numeric interpolate_channel_weight(const Vector& weights, + const InterpolationPoint& point) { + return std::visit( + [&weights](const auto& lag) { + return lagrange_interp::interp(weights, lag); + }, + point); +} - for (Size i = 0; i < out.size(); i++) out[i] = (*this)(f[i]); +std::vector collect_local_points(const FrequencyRange& range, + const AscendingGrid& channel_grid) { + std::vector local_points; - return out; + for (const auto& path : range.paths()) { + if (channel_grid.empty()) continue; + + const Numeric local_low = + std::max(path.local_range[0], channel_grid.front()); + const Numeric local_high = + std::min(path.local_range[1], channel_grid.back()); + + if (local_low > local_high and not is_close(local_low, local_high)) { + continue; + } + + add_support_points(local_points, channel_grid, local_low, local_high); + for (const auto& filter : path.filters) { + add_support_points(local_points, filter.grid<0>(), local_low, local_high); + } + } + + sort_unique(local_points); + return local_points; } -FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( - const FrequencyRange& range, const std::vector& channels) - : FrequencyRangeBandpassFilter( - range, std::span{channels.data(), channels.size()}) {} +std::vector> fold_unit_samples( + const FrequencyRange& range, Numeric local_frequency) { + std::vector> folded_samples; + folded_samples.reserve(range.size()); -FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( + for (const auto& path : range.paths()) { + const Numeric path_weight = path.local_weight(local_frequency); + if (path_weight == 0.0) continue; + + folded_samples.emplace_back(path.map_to_global(local_frequency), + path_weight); + } + + if (folded_samples.empty()) return folded_samples; + + const Numeric fold_count = static_cast(folded_samples.size()); + for (auto& sample : folded_samples) { + sample.second /= fold_count; + } + + if (is_close(local_frequency, 0.0)) { + deduplicate_zero_frequency_images(folded_samples); + } + + return folded_samples; +} + +void combine_samples(std::vector>& samples) { + std::sort(samples.begin(), samples.end(), [](const auto& a, const auto& b) { + return a.first < b.first; + }); + + std::vector> combined; + combined.reserve(samples.size()); + for (const auto& sample : samples) { + if (combined.empty() or not is_close(combined.back().first, sample.first)) { + combined.push_back(sample); + } else { + combined.back().second += sample.second; + } + } + + samples = std::move(combined); +} + +std::vector build_filters( const FrequencyRange& range, const std::span& channels) { + std::vector filters; filters.reserve(channels.size()); for (Size ichan = 0; ichan < channels.size(); ichan++) { @@ -149,50 +225,93 @@ FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( sample_filter(channel.channel, local_frequency); if (channel_weight == 0.0) continue; - std::vector> folded_samples; - folded_samples.reserve(range.size()); - - for (const auto& path : range.paths()) { - const Numeric path_weight = path.local_weight(local_frequency); - if (path_weight == 0.0) continue; + auto folded_samples = fold_unit_samples(range, local_frequency); + if (folded_samples.empty()) continue; - folded_samples.emplace_back(path.map_to_global(local_frequency), - path_weight * channel_weight); + for (const auto& sample : folded_samples) { + samples.emplace_back(sample.first, sample.second * channel_weight); } + } - if (folded_samples.empty()) continue; + combine_samples(samples); + filters.push_back( + make_filter(samples, std::format("channel-response-{}", ichan))); + } - const Numeric fold_count = static_cast(folded_samples.size()); - for (auto& sample : folded_samples) { - sample.second /= fold_count; - } + return filters; +} - if (is_close(local_frequency, 0.0)) { - deduplicate_zero_frequency_images(folded_samples); - } +std::vector build_synced_filters( + const FrequencyRange& range, const Spectrometer& spectrometer) { + const auto& channels = spectrometer.channels; + if (channels.empty()) return {}; - for (const auto& sample : folded_samples) { - samples.push_back(sample); - } - } + if (not spectrometer.is_synced()) { + return build_filters( + range, std::span{channels.data(), channels.size()}); + } - std::sort(samples.begin(), samples.end(), [](const auto& a, const auto& b) { - return a.first < b.first; - }); + const auto& channel_grid = channels.front().freq_grid(); + const auto local_points = collect_local_points(range, channel_grid); + + std::vector folded_points; + folded_points.reserve(local_points.size()); + for (Numeric local_frequency : local_points) { + auto folded_samples = fold_unit_samples(range, local_frequency); + if (folded_samples.empty()) continue; - std::vector> combined; - combined.reserve(samples.size()); - for (const auto& sample : samples) { - if (combined.empty() or - not is_close(combined.back().first, sample.first)) { - combined.push_back(sample); - } else { - combined.back().second += sample.second; + folded_points.push_back( + {make_interpolation_point(channel_grid, local_frequency), + std::move(folded_samples)}); + } + + std::vector filters; + filters.reserve(channels.size()); + + for (Size ichan = 0; ichan < channels.size(); ++ichan) { + const auto& weights = channels[ichan].weights(); + std::vector> samples; + + for (const auto& point : folded_points) { + const Numeric channel_weight = + interpolate_channel_weight(weights, point.interpolation); + if (channel_weight == 0.0) continue; + + for (const auto& sample : point.folded_samples) { + samples.emplace_back(sample.first, sample.second * channel_weight); } } + combine_samples(samples); filters.push_back( - make_filter(combined, std::format("channel-response-{}", ichan))); + make_filter(samples, std::format("channel-response-{}", ichan))); } + + return filters; +} +} // namespace + +Numeric BandpassFilter::operator()(Numeric f) const { + Numeric weight = 0.0; + for (const auto& filter : filters) weight += sample_filter(filter, f); + return weight; +} + +Vector BandpassFilter::operator()(ConstVectorView f) const { + Vector out(f.size(), 0.0); + + for (Size i = 0; i < out.size(); i++) out[i] = (*this)(f[i]); + + return out; +} + +FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( + const FrequencyRange& range, const std::span& channels) { + filters = build_filters(range, channels); +} + +FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( + const FrequencyRange& range, const Spectrometer& spectrometer) { + filters = build_synced_filters(range, spectrometer); } } // namespace sensor diff --git a/src/core/sensor/frequency_bandpass_filters.h b/src/core/sensor/frequency_bandpass_filters.h index 3f2b0df1e1..b8559d7be8 100644 --- a/src/core/sensor/frequency_bandpass_filters.h +++ b/src/core/sensor/frequency_bandpass_filters.h @@ -29,7 +29,7 @@ struct FrequencyRangeBandpassFilter final : BandpassFilter { FrequencyRangeBandpassFilter(const FrequencyRange& range, const std::span& channels); FrequencyRangeBandpassFilter(const FrequencyRange& range, - const std::vector& channels); + const Spectrometer& spectrometer); }; } // namespace sensor diff --git a/src/core/sensor/frequency_channel_selection.cpp b/src/core/sensor/frequency_channel_selection.cpp index 01ec2f9305..5e28582a2f 100644 --- a/src/core/sensor/frequency_channel_selection.cpp +++ b/src/core/sensor/frequency_channel_selection.cpp @@ -2,16 +2,19 @@ #include +#include #include +#include #include +#include + +#include "compare.h" namespace sensor { const AscendingGrid& Channel::freq_grid() const { return channel.grid<0>(); } const Vector& Channel::weights() const { return channel.data; } -bool Channel::is_always_relative() const { return freq_grid().front() <= 0; } - DiracChannel::DiracChannel(Numeric f) : Channel{.channel = { .data_name = "dirac"s, @@ -73,4 +76,90 @@ static_assert(FrequencyChannelSelection); static_assert(FrequencyChannelSelection); static_assert(FrequencyChannelSelection); static_assert(FrequencyChannelSelection); + +void Spectrometer::sync_frequency_grids() { + std::vector f; + f.reserve(std::transform_reduce(channels.begin(), + channels.end(), + Size{}, + std::plus<>{}, + [](const Channel& c) { return c.size(); })); + + for (const auto& channel : channels) { + const auto& freqs = channel.freq_grid(); + f.insert(f.end(), freqs.begin(), freqs.end()); + } + + stdr::sort(f); + f.erase(std::unique(f.begin(), f.end()), f.end()); + + const auto fs = AscendingGrid(std::move(f)); + Vector ws(fs.size(), 0); + + for (Channel& channel : channels) { + const auto& ch_fs = channel.freq_grid(); + const auto& ch_ws = channel.weights(); + const Size n = ch_fs.size(); + + const auto fs_ptr_first = stdr::lower_bound(fs, ch_fs.front()); + const Size OFFSET = std::distance(fs.begin(), fs_ptr_first); + const auto ws_ptr_first = ws.begin() + OFFSET; + + auto fs_ptr = fs_ptr_first; + auto ws_ptr = ws_ptr_first; + for (Size i = 0; i < n; i++) { + while (*fs_ptr < ch_fs[i]) { + ++fs_ptr; + ++ws_ptr; + } + *ws_ptr = ch_ws[i]; + } + + channel.channel.grid<0>() = fs; + channel.channel.data = ws; + + fs_ptr = fs_ptr_first; + ws_ptr = ws_ptr_first; + for (Size i = 0; i < n; i++) { + while (*fs_ptr < ch_fs[i]) { + ++fs_ptr; + ++ws_ptr; + } + *ws_ptr = 0.0; + } + } +} + +bool Spectrometer::is_synced() const { + return channels.empty() or stdr::all_of(channels, + Cmp::eq(channels.front().freq_grid()), + &Channel::freq_grid); +} + +Spectrometer::Spectrometer(const Channel& base_channel, + const AscendingGrid& freq_offsets) + : channels(freq_offsets.size(), base_channel) { + for (Size i = 0; i < channels.size(); ++i) { + Vector f = channels[i].channel.grid<0>(); + f += freq_offsets[i]; + channels[i].channel.grid<0>() = f; + } + + sync_frequency_grids(); +} } // namespace sensor + +void xml_io_stream::write( + std::ostream& os, + const sensor::Spectrometer& spec, + bofstream* pbofs, + std::string_view name) { + xml_io_stream>::write( + os, spec.channels, pbofs, name); +} + +void xml_io_stream::read(std::istream& is, + sensor::Spectrometer& spec, + bifstream* pbifs) { + xml_io_stream>::read(is, spec.channels, pbifs); +} diff --git a/src/core/sensor/frequency_channel_selection.h b/src/core/sensor/frequency_channel_selection.h index 10c15ac86d..cc58dc716e 100644 --- a/src/core/sensor/frequency_channel_selection.h +++ b/src/core/sensor/frequency_channel_selection.h @@ -26,7 +26,7 @@ struct Channel { [[nodiscard]] const AscendingGrid& freq_grid() const; [[nodiscard]] const Vector& weights() const; - [[nodiscard]] bool is_always_relative() const; + [[nodiscard]] Size size() const { return channel.data.size(); } }; struct BoxChannel final : Channel { @@ -50,6 +50,37 @@ struct GaussianChannel final : Channel { [[nodiscard]] AscendingGrid center_grid() const; }; + +struct Spectrometer { + std::vector channels; + + private: + void sync_frequency_grids(); + + public: + [[nodiscard]] bool is_synced() const; + + /** Make a simple spectrometer + + The spectrometer will have the same channel response for each position + but will be shifted by the frequencies in freq_offsets. + + So if base_channel is f: [-1, 1], w: [0.5, 0.5], and freq_offset is [2, 3, 4], + the resulting channels will be [[1, 2], [2, 3], [3, 4]] with the weights [0.5, 0.5] for each channel. + + But, since this is supposed to simulate a spectrometer, the true frequency grid of each channel + will be [1, 2, 3, 4] for all channels but the weights will be [0.5, 0.5, 0, 0], [0, 0.5, 0.5, 0], and + [0, 0, 0.5, 0.5] for each channel respectively. + + @param[in] base_channel The base channel + @param[in] freq_offsets The frequency offsets for each channel +*/ + Spectrometer(const Channel& base_channel, const AscendingGrid& freq_offsets); + + Spectrometer(std::vector channels) : channels(std::move(channels)) {} + + operator const std::vector&() const { return channels; } +}; } // namespace sensor // Channel format tags and XML I/O @@ -98,3 +129,18 @@ struct std::formatter template <> struct xml_io_stream : xml_io_stream_inherit {}; + +// Spectrometer format tags and XML I/O + +template <> +struct std::formatter + : std::formatter> {}; + +template <> +struct xml_io_stream { + static void write(std::ostream&, + const sensor::Spectrometer&, + bofstream* = nullptr, + std::string_view = ""sv); + static void read(std::istream&, sensor::Spectrometer&, bifstream* = nullptr); +}; diff --git a/src/core/sensor/obsel.cpp b/src/core/sensor/obsel.cpp index 5836656f4a..b489ec4848 100644 --- a/src/core/sensor/obsel.cpp +++ b/src/core/sensor/obsel.cpp @@ -1,11 +1,13 @@ #include "obsel.h" +#include #include #include #include #include #include +#include #include bool SensorKey::operator==(const SensorKey& other) const { @@ -516,7 +518,7 @@ void make_exclusive(std::span obsels) { for (const auto& w : elem.weight_matrix()) { const Index iposlos = stdr::distance(stdr::begin(gs), stdr::find(gs, old_gs[w.irow])); - const Index ifreq = stdr::distance(stdr::begin(fs), + const Index ifreq = stdr::distance(stdr::begin(fs), stdr::lower_bound(fs, old_fs[w.icol])); ws[iposlos, ifreq] = w.data; } @@ -525,6 +527,41 @@ void make_exclusive(std::span obsels) { } } +void collect_frequency_grids(std::span obsels) { + if (obsels.empty()) return; + + std::set> freq_set; + for (const auto& obsel : obsels) freq_set.insert(obsel.f_grid_ptr()); + + if (freq_set.size() == 1) return; + + Size total_size = 0; + for (const auto& freqs : freq_set) total_size += freqs->size(); + + std::vector fs; + fs.reserve(total_size); + for (const auto& freqs : freq_set) fs.append_range(*freqs); + + stdr::sort(fs); + fs.erase(std::unique(fs.begin(), fs.end()), fs.end()); + + const auto freq_ptr = std::make_shared(std::move(fs)); + +#pragma omp parallel for if (not arts_omp_in_parallel()) + for (auto& obsel : obsels) { + sensor::SparseStokvecMatrix w(obsel.poslos_grid().size(), freq_ptr->size()); + + for (const auto& we : obsel.weight_matrix()) { + const Index ifreq = + stdr::distance(freq_ptr->begin(), + stdr::lower_bound(*freq_ptr, obsel.f_grid()[we.icol])); + w[we.irow, ifreq] = we.data; + } + + obsel = {freq_ptr, obsel.poslos_grid_ptr(), std::move(w)}; + } +} + namespace { void set_frq(const SensorObsel& v, ArrayOfSensorObsel& sensor, @@ -538,7 +575,7 @@ void set_frq(const SensorObsel& v, x.begin(), x.end(), [](auto& x) { return x; }); // Must copy, as we may change the shared_ptr later - const auto fs = v.f_grid_ptr(); + const auto& fs = v.f_grid_ptr(); for (auto& elem : sensor) { if (elem.f_grid_ptr() == fs) { @@ -574,7 +611,7 @@ void set_poslos(const SensorObsel& v, const auto xs = std::make_shared(std::move(xsv)); // Must copy, as we may change the shared_ptr later - const auto ps = v.poslos_grid_ptr(); + const auto& ps = v.poslos_grid_ptr(); for (auto& elem : sensor) { if (elem.poslos_grid_ptr() == ps) { diff --git a/src/core/sensor/obsel.h b/src/core/sensor/obsel.h index f11a4c0e43..8674588ad9 100644 --- a/src/core/sensor/obsel.h +++ b/src/core/sensor/obsel.h @@ -7,8 +7,6 @@ #include #include -#include -#include #include "matpack_mdspan_helpers_grid_t.h" @@ -265,6 +263,8 @@ void make_exhaustive(std::span obsels); */ void make_exclusive(std::span obsels); +void collect_frequency_grids(std::span obsels); + std::vector unique_frequency_grids( const std::span& obsels); diff --git a/src/core/sensor/sensor_builder.cpp b/src/core/sensor/sensor_builder.cpp index a9bed69a39..2b0806fd7f 100644 --- a/src/core/sensor/sensor_builder.cpp +++ b/src/core/sensor/sensor_builder.cpp @@ -1,6 +1,7 @@ #include "sensor_builder.h" #include +#include #include #include @@ -26,35 +27,52 @@ SensorMetaInfo make_meta_info(Size nchannels, Size geometry_index) { return SensorMetaInfo{std::move(gf)}; } + +std::vector make_channels(const Spectrometer& spectrometer, + const FrequencyRange& backend) { + auto filters = FrequencyRangeBandpassFilter(backend, spectrometer).filters; + + std::vector channels; + channels.reserve(filters.size()); + + for (auto& filter : filters) { + channels.push_back(Channel{.channel = std::move(filter)}); + } + + return channels; +} } // namespace SensorBuilder::SensorBuilder() : antenna(PencilBeamAntenna{}.clone()) {} SensorBuilder::SensorBuilder(std::vector channels, - const AntennaPattern& antenna) - : channels(std::move(channels)), antenna(antenna.clone()) {} + std::shared_ptr antenna) + : channels(std::move(channels)), antenna(std::move(antenna)) {} + +SensorBuilder::SensorBuilder(const Spectrometer& spectrometer, + std::shared_ptr antenna) + : SensorBuilder(std::vector{spectrometer.channels}, + std::move(antenna)) { + preserve_common_frequency_grid = spectrometer.is_synced(); +} -SensorBuilder::SensorBuilder(const SensorBuilder& other) - : channels(other.channels), antenna(clone_antenna(other.antenna)) {} +SensorBuilder::SensorBuilder(const Spectrometer& spectrometer, + const FrequencyRange& backend, + std::shared_ptr antenna) + : SensorBuilder(make_channels(spectrometer, backend), std::move(antenna)) { + preserve_common_frequency_grid = spectrometer.is_synced(); +} SensorBuilder& SensorBuilder::operator=(const SensorBuilder& other) { if (this != &other) { - channels = other.channels; - antenna = clone_antenna(other.antenna); + channels = other.channels; + antenna = clone_antenna(other.antenna); + preserve_common_frequency_grid = other.preserve_common_frequency_grid; } return *this; } -const AntennaPattern& SensorBuilder::get_antenna() const { - ARTS_USER_ERROR_IF(not antenna, "SensorBuilder requires an antenna pattern") - return *antenna; -} - -void SensorBuilder::set_antenna(const AntennaPattern& pattern) { - antenna = pattern.clone(); -} - std::pair SensorBuilder::operator()( std::span pos, std::span los) const { ARTS_USER_ERROR_IF(channels.empty(), @@ -89,7 +107,7 @@ std::pair SensorBuilder::operator()( std::shared_ptr poslos_grid; for (Size ichan = 0; ichan < channels.size(); ++ichan) { - auto obsel = get_antenna()(channels[ichan], sensor_pos, bore_los); + auto obsel = antenna->operator()(channels[ichan], sensor_pos, bore_los); obsel.set_f_grid_ptr(freq_grids[ichan]); if (not poslos_grid) { @@ -106,6 +124,8 @@ std::pair SensorBuilder::operator()( for (Size i = 0; i < pos.size(); ++i) append_geometry(pos[i], los[i], i); + if (preserve_common_frequency_grid) collect_frequency_grids(out); + return {std::move(out), std::move(meta)}; } diff --git a/src/core/sensor/sensor_builder.h b/src/core/sensor/sensor_builder.h index 42981e10ea..b376934c3b 100644 --- a/src/core/sensor/sensor_builder.h +++ b/src/core/sensor/sensor_builder.h @@ -14,6 +14,7 @@ namespace sensor { //! Combines channels with an antenna pattern and bore geometries into obsels. struct SensorBuilder; +struct FrequencyRange; //! Concept for selecting sensor builders. template @@ -22,17 +23,21 @@ concept SensorBuilderSelection = std::derived_from; struct SensorBuilder { std::vector channels; std::shared_ptr antenna; + bool preserve_common_frequency_grid{false}; SensorBuilder(); - SensorBuilder(std::vector channels, const AntennaPattern& antenna); - SensorBuilder(const SensorBuilder& other); - SensorBuilder(SensorBuilder&&) noexcept = default; + SensorBuilder(std::vector channels, + std::shared_ptr antenna); + SensorBuilder(const Spectrometer& spectrometer, + std::shared_ptr antenna); + SensorBuilder(const Spectrometer& spectrometer, + const FrequencyRange& backend, + std::shared_ptr antenna); + SensorBuilder(const SensorBuilder& other) = default; + SensorBuilder(SensorBuilder&&) noexcept = default; SensorBuilder& operator=(const SensorBuilder& other); SensorBuilder& operator=(SensorBuilder&&) noexcept = default; - [[nodiscard]] const AntennaPattern& get_antenna() const; - void set_antenna(const AntennaPattern& pattern); - [[nodiscard]] std::pair operator()( std::span pos, std::span los) const; }; diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 14103f2b7a..6551e2fbed 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -309,49 +309,7 @@ Numeric, Vector, or Matrix a1.def( "collect_freq_grids", - [](ArrayOfSensorObsel& x) { - if (x.empty()) return; - - const auto tmp = [&x]() { - std::set> freq_set; - for (const auto& obsel : x) freq_set.insert(obsel.f_grid_ptr()); - if (freq_set.size() == 1) - return std::make_pair(true, *freq_set.begin()); - - std::vector fs; - fs.reserve(std::accumulate( - freq_set.begin(), - freq_set.end(), - Size(0), - [](Size a, const auto& b) { return a + b->size(); })); - for (auto& freqs : freq_set) fs.append_range(*freqs); - - stdr::sort(fs); - fs.erase(std::unique(fs.begin(), fs.end()), fs.end()); - return std::make_pair( - false, std::make_shared(std::move(fs))); - }(); - - //! Clang bug workaround - const auto& early = tmp.first; - const auto& freq_ptr = tmp.second; - - if (early) return; - -#pragma omp parallel for if (not arts_omp_in_parallel()) - for (Size i = 0; i < x.size(); i++) { - sensor::SparseStokvecMatrix w(x[i].poslos_grid().size(), - freq_ptr->size()); - for (const auto& we : x[i].weight_matrix()) { - const Index ifreq = stdr::distance( - freq_ptr->begin(), - stdr::lower_bound(*freq_ptr, x[i].f_grid()[we.icol])); - w[we.irow, ifreq] = we.data; - } - - x[i] = {freq_ptr, x[i].poslos_grid_ptr(), std::move(w)}; - } - }, + [](ArrayOfSensorObsel& x) { collect_frequency_grids(x); }, R"(Collect all frequency grids in a single grid. .. tip:: @@ -478,7 +436,7 @@ See :meth:`SensorObsel.normalize` for details. "pos"_a, "bore_los"_a, R"(Map the antenna pattern onto one observation element.)"); - // generic_interface(sap); -- this should break things. The class is somewhat abstract + // generic_interface(sap); -- this should break things. The class is abstract auto sgp = py::class_( m, "SensorGriddedAntennaPattern"); @@ -494,14 +452,9 @@ See :meth:`SensorObsel.normalize` for details. }, "response"_a, "Construct a gridded antenna pattern from a local zenith/azimuth response field.") - .def_prop_rw( + .def_rw( "response", - [](sensor::GriddedAntennaPattern& self) - -> sensor::AntennaPatternField& { return self.data; }, - [](sensor::GriddedAntennaPattern& self, - const sensor::AntennaPatternField& response) { - self.data = response; - }, + &sensor::GriddedAntennaPattern::data, py::rv_policy::reference_internal, "Local antenna response field.\n\n.. :class:`~pyarts3.arts.SensorAntennaPatternField`"); generic_interface(sgp); @@ -580,10 +533,7 @@ See :meth:`SensorObsel.normalize` for details. &sensor::Channel::weights, py::rv_policy::reference_internal, "Channel weights on the relative frequency grid." - "\n\n.. :class:`Vector`") - .def("is_always_relative", - &sensor::Channel::is_always_relative, - "Whether the channel grid is anchored at or below zero."); + "\n\n.. :class:`Vector`"); auto sbox = py::class_(m, "SensorBoxChannel"); @@ -641,6 +591,48 @@ See :meth:`SensorObsel.normalize` for details. "Construct a zero-centered Gaussian channel on ``+/- m * std``."); generic_interface(sgauss); + auto sspec = py::class_(m, "SensorSpectrometer"); + sspec.doc() = + "A spectrometer made from channels arranged on one shared relative-frequency grid."; + sspec + .def( + py::init(), + "base_channel"_a, + "freq_offsets"_a, + "Construct a spectrometer by shifting one base channel across the supplied relative-frequency offsets.") + .def(py::init>(), + "channels"_a, + "Construct a spectrometer from explicit channels.") + .def_prop_ro( + "channels", + [](const sensor::Spectrometer& self) + -> const std::vector& { return self.channels; }, + py::rv_policy::reference_internal, + "Spectrometer channels.\n\n.. :class:`list[~pyarts3.arts.SensorChannel]`") + .def( + "is_synced", + &sensor::Spectrometer::is_synced, + "Return whether all channels share the same relative-frequency grid.") + .def( + "__len__", + [](const sensor::Spectrometer& self) { return self.channels.size(); }) + .def( + "__getitem__", + [](const sensor::Spectrometer& self, + Size i) -> const sensor::Channel& { return self.channels.at(i); }, + py::rv_policy::reference_internal) + .def( + "__iter__", + [](const sensor::Spectrometer& self) { + return py::make_iterator(py::type(), + "sensor-spectrometer-iterator", + self.channels.begin(), + self.channels.end()); + }, + py::rv_policy::reference_internal, + "Iterate over the spectrometer channels."); + generic_interface(sspec); + auto ssb = py::class_(m, "SensorBuilder"); ssb.doc() = "Combines channels and an antenna pattern into sensor obsels for paired position/LOS samples."; @@ -650,11 +642,34 @@ See :meth:`SensorObsel.normalize` for details. [](sensor::SensorBuilder* self, const std::vector& channels, const sensor::AntennaPattern& antenna) { - new (self) sensor::SensorBuilder{channels, antenna}; + new (self) sensor::SensorBuilder{channels, antenna.clone()}; }, "channels"_a, "antenna"_a = sensor::PencilBeamAntenna{}, "Construct a sensor builder from channels and one antenna pattern (defaults to pencil-beam).") + .def( + "__init__", + [](sensor::SensorBuilder* self, + const sensor::Spectrometer& spectrometer, + const sensor::AntennaPattern& antenna) { + new (self) sensor::SensorBuilder{spectrometer, antenna.clone()}; + }, + "spectrometer"_a, + "antenna"_a = sensor::PencilBeamAntenna{}, + "Construct a sensor builder from a spectrometer and one antenna pattern (defaults to pencil-beam).") + .def( + "__init__", + [](sensor::SensorBuilder* self, + const sensor::AntennaPattern& antenna, + const sensor::Spectrometer& spectrometer, + const sensor::HeterodyneFrequencyRange& backend) { + new (self) + sensor::SensorBuilder{spectrometer, backend, antenna.clone()}; + }, + "antenna"_a, + "spectrometer"_a, + "backend"_a, + "Construct a sensor builder from an antenna, a spectrometer, and a backend frequency response.") .def_rw( "channels", &sensor::SensorBuilder::channels, @@ -662,10 +677,14 @@ See :meth:`SensorObsel.normalize` for details. .def_prop_rw( "antenna", [](const sensor::SensorBuilder& self) - -> const sensor::AntennaPattern& { return self.get_antenna(); }, + -> std::shared_ptr { + if (not self.antenna) + throw std::runtime_error("SensorBuilder has no antenna pattern"); + return self.antenna->clone(); + }, [](sensor::SensorBuilder& self, const sensor::AntennaPattern& antenna) { - self.set_antenna(antenna); + self.antenna = antenna.clone(); }, py::rv_policy::reference_internal, "Angular antenna response.\n\n.. :class:`~pyarts3.arts.SensorAntennaPattern`") @@ -804,24 +823,40 @@ geometry first and channel second, and the returned value is [](const sensor::HeterodyneFrequencyRange& self, const sensor::Channel& channel) { return sensor::FrequencyRangeBandpassFilter( - self, std::vector{channel}) + self, std::span{&channel, 1}) .filters.front(); }, "channel"_a, R"(Compute the real-frequency response for one spectrometer channel. - The returned gridded field is aggregated across all active mixer paths.)") + The returned gridded field is aggregated across all active mixer paths.)") .def( "channel_responses", [](const sensor::HeterodyneFrequencyRange& self, const std::vector& channels) { - return sensor::FrequencyRangeBandpassFilter(self, channels).filters; + return sensor::FrequencyRangeBandpassFilter( + self, + std::span{channels.data(), + channels.size()}) + .filters; }, "channels"_a, R"(Compute the real-frequency response for multiple spectrometer channels. + Each returned gridded field is aggregated across all active mixer paths for the + matching input channel.)") + .def( + "channel_responses", + [](const sensor::HeterodyneFrequencyRange& self, + const sensor::Spectrometer& spectrometer) { + return sensor::FrequencyRangeBandpassFilter(self, spectrometer) + .filters; + }, + "spectrometer"_a, + R"(Compute the real-frequency response for all channels in a spectrometer. + Each returned gridded field is aggregated across all active mixer paths for the - matching input channel.)"); + matching spectrometer channel.)"); shdfr.doc() = "A staged heterodyne mixer and filter response builder."; generic_interface(shdfr); } catch (std::exception& e) { diff --git a/src/tests/test_sensor_builder.cc b/src/tests/test_sensor_builder.cc index 1c3c703256..d13cc4d9ca 100644 --- a/src/tests/test_sensor_builder.cc +++ b/src/tests/test_sensor_builder.cc @@ -51,7 +51,7 @@ void test_sensor_builder_returns_meta_per_geometry() { sensor::SensorBuilder builder( {sensor::BoxChannel{AscendingGrid{100.0, 101.0}}, sensor::DiracChannel{200.0}}, - sensor::PencilBeamAntenna{}); + std::make_shared()); const std::array pos{{{600e3, 10.0, 20.0}, {601e3, 11.0, 21.0}}}; const std::array los{{{20.0, 30.0}, {40.0, 50.0}}}; @@ -90,7 +90,7 @@ void test_sensor_builder_returns_meta_per_geometry() { void test_sensor_builder_rejects_mismatched_geometry_counts() { sensor::SensorBuilder builder({sensor::DiracChannel{}}, - sensor::PencilBeamAntenna{}); + std::make_shared()); const std::array pos{{{600e3, 10.0, 20.0}}}; const std::array los{{{20.0, 30.0}, {40.0, 50.0}}}; @@ -111,8 +111,8 @@ void test_sensor_builder_uses_gaussian_airy_frequency_dependence() { const Stokvec peak_weight{2.0, 0.0, 0.0, 0.0}; sensor::SensorBuilder builder( {sensor::BoxChannel{AscendingGrid{100.0e9, 200.0e9}}}, - sensor::GaussianAiryAntenna{ - ZenGrid{{0.0, 0.2}}, AziGrid{{0.0}}, 1.0, peak_weight}); + std::make_shared( + ZenGrid{{0.0, 0.2}}, AziGrid{{0.0}}, 1.0, peak_weight)); const std::array pos{{{600e3, 10.0, 20.0}}}; const std::array los{{{45.0, 30.0}}}; diff --git a/tests/core/sensor/600ghz-upper-sideband-sensor.py b/tests/core/sensor/600ghz-upper-sideband-sensor.py new file mode 100644 index 0000000000..2e3f6fd2c8 --- /dev/null +++ b/tests/core/sensor/600ghz-upper-sideband-sensor.py @@ -0,0 +1,56 @@ +import numpy as np +import pyarts3 as pyarts +import time + +start_time = time.time() + +NCHANNELS = 10_000 +IF_LOW = 5.5e9 +IF_HIGH = 6.5e9 +LO = 600e9 +APERTURE_DIAMETER = 30e-2 +POS = [600e3, 0.0, 0.0] +LOS = [45.0, 0.0] +ZEN = np.linspace(0.0, 0.3, 7) +AZI = np.linspace(0.0, 360.0, 9)[:-1] + + +if_offsets = pyarts.arts.AscendingGrid(np.linspace(IF_LOW, IF_HIGH, NCHANNELS)) +print(f"if_offsets: {time.time() - start_time:.2f} seconds") + +spectrometer = pyarts.arts.SensorSpectrometer( + pyarts.arts.SensorDiracChannel(), + if_offsets, +) +print(f"spectrometer: {time.time() - start_time:.2f} seconds") + +backend = pyarts.arts.SensorHeterodyneFrequencyRange( + LO, + np.array([LO + IF_LOW, LO + IF_HIGH]), +) +print(f"backend: {time.time() - start_time:.2f} seconds") + +antenna = pyarts.arts.SensorGaussianAiryAntenna( + ZEN, + AZI, + APERTURE_DIAMETER, + "I", +) +print(f"antenna: {time.time() - start_time:.2f} seconds") + +sensor = pyarts.arts.SensorBuilder(antenna, spectrometer, backend) +print(f"sensor: {time.time() - start_time:.2f} seconds") + +ws = pyarts.Workspace() + +ws.measurement_sensor, ws.measurement_sensor_meta = sensor(POS, LOS) + +assert len(ws.measurement_sensor.unique_freq_grids()) == 1 +assert len(ws.measurement_sensor[0].f_grid) == NCHANNELS + +np.testing.assert_allclose( + np.asarray(ws.measurement_sensor[0].f_grid)[[0, -1]], + np.array([LO + IF_LOW, LO + IF_HIGH]), + atol=0.0, + rtol=0.0, +) From 32baa7f9b4155d784a78bb63f973f205bcb09364 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Fri, 22 May 2026 10:34:59 +0900 Subject: [PATCH 20/21] Update --- src/core/sensor/obsel.cpp | 10 +++--- src/tests/test_sensor_builder.cc | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/core/sensor/obsel.cpp b/src/core/sensor/obsel.cpp index b489ec4848..1c1cedc554 100644 --- a/src/core/sensor/obsel.cpp +++ b/src/core/sensor/obsel.cpp @@ -574,8 +574,8 @@ void set_frq(const SensorObsel& v, const auto xs = std::make_shared( x.begin(), x.end(), [](auto& x) { return x; }); - // Must copy, as we may change the shared_ptr later - const auto& fs = v.f_grid_ptr(); + // Copy the shared_ptr value before updating matching obsels. + const auto fs = v.f_grid_ptr(); for (auto& elem : sensor) { if (elem.f_grid_ptr() == fs) { @@ -610,12 +610,12 @@ void set_poslos(const SensorObsel& v, const auto xs = std::make_shared(std::move(xsv)); - // Must copy, as we may change the shared_ptr later - const auto& ps = v.poslos_grid_ptr(); + // Copy the shared_ptr value before updating matching obsels. + const auto ps = v.poslos_grid_ptr(); for (auto& elem : sensor) { if (elem.poslos_grid_ptr() == ps) { - elem.set_poslos_grid_ptr(ps); // may change here + elem.set_poslos_grid_ptr(xs); // may change here } } } diff --git a/src/tests/test_sensor_builder.cc b/src/tests/test_sensor_builder.cc index d13cc4d9ca..34e5965ba4 100644 --- a/src/tests/test_sensor_builder.cc +++ b/src/tests/test_sensor_builder.cc @@ -1,4 +1,5 @@ #include +#include #include #include @@ -138,11 +139,62 @@ void test_sensor_builder_uses_gaussian_airy_frequency_dependence() { 1e-12, "builder gaussian airy off-axis high frequency"); } + +void test_unflatten_updates_shared_poslos_grids() { + auto freq_grid = std::make_shared(AscendingGrid{100.0}); + + SensorPosLosVector poslos_grid(1); + poslos_grid[0] = {.pos = {600e3, 10.0, 20.0}, .los = {30.0, 40.0}}; + auto shared_poslos = + std::make_shared(std::move(poslos_grid)); + + sensor::SparseStokvecMatrix weights(1, 1); + weights[0, 0] = {1.0, 0.0, 0.0, 0.0}; + + ArrayOfSensorObsel obsels(2); + obsels[0] = {freq_grid, shared_poslos, weights}; + obsels[1] = {freq_grid, shared_poslos, weights}; + + const auto original_poslos = obsels[0].poslos_grid_ptr(); + const Vector zeniths{15.0}; + + unflatten(obsels, zeniths, obsels[0], SensorKeyType::zen); + + ARTS_USER_ERROR_IF(obsels[0].poslos_grid_ptr() == original_poslos, + "unflatten must replace the shared poslos grid") + ARTS_USER_ERROR_IF(not obsels[0].same_poslos(obsels[1]), + "obsels sharing a geometry must still share it after unflatten") + assert_close(obsels[0].poslos_grid()[0].los[0], + 15.0, + 0.0, + "updated zenith for first obsel"); + assert_close(obsels[1].poslos_grid()[0].los[0], + 15.0, + 0.0, + "updated zenith for second obsel"); + assert_close(obsels[0].poslos_grid()[0].los[1], + 40.0, + 0.0, + "azimuth must remain unchanged"); + + const auto original_freq = obsels[0].f_grid_ptr(); + const Vector freqs{101.0}; + + unflatten(obsels, freqs, obsels[0], SensorKeyType::freq); + + ARTS_USER_ERROR_IF(obsels[0].f_grid_ptr() == original_freq, + "unflatten must replace the shared frequency grid") + ARTS_USER_ERROR_IF(not obsels[0].same_freqs(obsels[1]), + "obsels sharing frequencies must still share them after unflatten") + assert_close(obsels[0].f_grid()[0], 101.0, 0.0, "updated frequency for first obsel"); + assert_close(obsels[1].f_grid()[0], 101.0, 0.0, "updated frequency for second obsel"); +} } // namespace int main() { test_sensor_builder_returns_meta_per_geometry(); test_sensor_builder_rejects_mismatched_geometry_counts(); test_sensor_builder_uses_gaussian_airy_frequency_dependence(); + test_unflatten_updates_shared_poslos_grids(); return 0; } \ No newline at end of file From 7de0a5382ad15b8c2d3eca55860f133d3a418a37 Mon Sep 17 00:00:00 2001 From: Richard Larsson Date: Fri, 22 May 2026 13:55:44 +0900 Subject: [PATCH 21/21] Simplify --- .../sensor/frequency_bandpass_filters.cpp | 52 +++++++--------- src/core/sensor/frequency_bandpass_filters.h | 59 ++----------------- src/core/sensor/sensor_builder.cpp | 17 +----- src/python_interface/py_sensor.cpp | 32 ++++++---- 4 files changed, 49 insertions(+), 111 deletions(-) diff --git a/src/core/sensor/frequency_bandpass_filters.cpp b/src/core/sensor/frequency_bandpass_filters.cpp index d02e8045e4..74cb591578 100644 --- a/src/core/sensor/frequency_bandpass_filters.cpp +++ b/src/core/sensor/frequency_bandpass_filters.cpp @@ -189,10 +189,10 @@ void combine_samples(std::vector>& samples) { samples = std::move(combined); } -std::vector build_filters( +std::vector build_channels( const FrequencyRange& range, const std::span& channels) { - std::vector filters; - filters.reserve(channels.size()); + std::vector out; + out.reserve(channels.size()); for (Size ichan = 0; ichan < channels.size(); ichan++) { const auto& channel = channels[ichan]; @@ -234,20 +234,22 @@ std::vector build_filters( } combine_samples(samples); - filters.push_back( - make_filter(samples, std::format("channel-response-{}", ichan))); + out.push_back( + Channel{.channel = make_filter(samples, + std::format("channel-response-{}", + ichan))}); } - return filters; + return out; } -std::vector build_synced_filters( +std::vector build_synced_channels( const FrequencyRange& range, const Spectrometer& spectrometer) { const auto& channels = spectrometer.channels; if (channels.empty()) return {}; if (not spectrometer.is_synced()) { - return build_filters( + return build_channels( range, std::span{channels.data(), channels.size()}); } @@ -265,8 +267,8 @@ std::vector build_synced_filters( std::move(folded_samples)}); } - std::vector filters; - filters.reserve(channels.size()); + std::vector out; + out.reserve(channels.size()); for (Size ichan = 0; ichan < channels.size(); ++ichan) { const auto& weights = channels[ichan].weights(); @@ -283,35 +285,23 @@ std::vector build_synced_filters( } combine_samples(samples); - filters.push_back( - make_filter(samples, std::format("channel-response-{}", ichan))); + out.push_back( + Channel{.channel = make_filter(samples, + std::format("channel-response-{}", + ichan))}); } - return filters; -} -} // namespace - -Numeric BandpassFilter::operator()(Numeric f) const { - Numeric weight = 0.0; - for (const auto& filter : filters) weight += sample_filter(filter, f); - return weight; -} - -Vector BandpassFilter::operator()(ConstVectorView f) const { - Vector out(f.size(), 0.0); - - for (Size i = 0; i < out.size(); i++) out[i] = (*this)(f[i]); - return out; } +} // namespace -FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( +std::vector make_bandpass_channels( const FrequencyRange& range, const std::span& channels) { - filters = build_filters(range, channels); + return build_channels(range, channels); } -FrequencyRangeBandpassFilter::FrequencyRangeBandpassFilter( +std::vector make_bandpass_channels( const FrequencyRange& range, const Spectrometer& spectrometer) { - filters = build_synced_filters(range, spectrometer); + return build_synced_channels(range, spectrometer); } } // namespace sensor diff --git a/src/core/sensor/frequency_bandpass_filters.h b/src/core/sensor/frequency_bandpass_filters.h index b8559d7be8..1035aa60ae 100644 --- a/src/core/sensor/frequency_bandpass_filters.h +++ b/src/core/sensor/frequency_bandpass_filters.h @@ -8,61 +8,10 @@ #include "frequency_range_selection.h" namespace sensor { -//! Real frequency bandpass filter. Others inherit from this. -struct BandpassFilter; +[[nodiscard]] std::vector make_bandpass_channels( + const FrequencyRange& range, const std::span& channels); -//! Sets the bandpass filter from weights on derived frequeny ranges. -struct FrequencyRangeBandpassFilter; - -//! Concept that creates a valid frequency bandpass filter for a given set of channels and frequency ranges. -template -concept FrequencyBandpassFilter = std::derived_from; - -struct BandpassFilter { - std::vector filters; - - [[nodiscard]] Numeric operator()(Numeric f) const; - [[nodiscard]] Vector operator()(ConstVectorView f) const; -}; - -struct FrequencyRangeBandpassFilter final : BandpassFilter { - FrequencyRangeBandpassFilter(const FrequencyRange& range, - const std::span& channels); - FrequencyRangeBandpassFilter(const FrequencyRange& range, - const Spectrometer& spectrometer); -}; +[[nodiscard]] std::vector make_bandpass_channels( + const FrequencyRange& range, const Spectrometer& spectrometer); } // namespace sensor -// BandpassFilter format tags and XML I/O - -template <> -struct format_tag_aggregate { - constexpr static bool value = true; -}; - -template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorBandpassFilter"; -}; - -template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; -}; - -// FrequencyRangeBandpassFilter format tags and XML I/O - -template <> -struct format_tag_aggregate { - constexpr static bool value = true; -}; - -template <> -struct xml_io_stream_name { - static constexpr std::string_view name = "SensorFrequencyRangeBandpassFilter"; -}; - -template <> -struct xml_io_stream_aggregate { - static constexpr bool value = true; -}; diff --git a/src/core/sensor/sensor_builder.cpp b/src/core/sensor/sensor_builder.cpp index 2b0806fd7f..f1404b1a3a 100644 --- a/src/core/sensor/sensor_builder.cpp +++ b/src/core/sensor/sensor_builder.cpp @@ -27,20 +27,6 @@ SensorMetaInfo make_meta_info(Size nchannels, Size geometry_index) { return SensorMetaInfo{std::move(gf)}; } - -std::vector make_channels(const Spectrometer& spectrometer, - const FrequencyRange& backend) { - auto filters = FrequencyRangeBandpassFilter(backend, spectrometer).filters; - - std::vector channels; - channels.reserve(filters.size()); - - for (auto& filter : filters) { - channels.push_back(Channel{.channel = std::move(filter)}); - } - - return channels; -} } // namespace SensorBuilder::SensorBuilder() : antenna(PencilBeamAntenna{}.clone()) {} @@ -59,7 +45,8 @@ SensorBuilder::SensorBuilder(const Spectrometer& spectrometer, SensorBuilder::SensorBuilder(const Spectrometer& spectrometer, const FrequencyRange& backend, std::shared_ptr antenna) - : SensorBuilder(make_channels(spectrometer, backend), std::move(antenna)) { + : SensorBuilder(make_bandpass_channels(backend, spectrometer), + std::move(antenna)) { preserve_common_frequency_grid = spectrometer.is_synced(); } diff --git a/src/python_interface/py_sensor.cpp b/src/python_interface/py_sensor.cpp index 6551e2fbed..605e3399b2 100644 --- a/src/python_interface/py_sensor.cpp +++ b/src/python_interface/py_sensor.cpp @@ -822,9 +822,10 @@ geometry first and channel second, and the returned value is "channel_response", [](const sensor::HeterodyneFrequencyRange& self, const sensor::Channel& channel) { - return sensor::FrequencyRangeBandpassFilter( - self, std::span{&channel, 1}) - .filters.front(); + return sensor::make_bandpass_channels( + self, std::span{&channel, 1}) + .front() + .channel; }, "channel"_a, R"(Compute the real-frequency response for one spectrometer channel. @@ -834,11 +835,16 @@ geometry first and channel second, and the returned value is "channel_responses", [](const sensor::HeterodyneFrequencyRange& self, const std::vector& channels) { - return sensor::FrequencyRangeBandpassFilter( - self, - std::span{channels.data(), - channels.size()}) - .filters; + auto response_channels = sensor::make_bandpass_channels( + self, + std::span{channels.data(), + channels.size()}); + std::vector out; + out.reserve(response_channels.size()); + for (auto& response : response_channels) { + out.push_back(std::move(response.channel)); + } + return out; }, "channels"_a, R"(Compute the real-frequency response for multiple spectrometer channels. @@ -849,8 +855,14 @@ geometry first and channel second, and the returned value is "channel_responses", [](const sensor::HeterodyneFrequencyRange& self, const sensor::Spectrometer& spectrometer) { - return sensor::FrequencyRangeBandpassFilter(self, spectrometer) - .filters; + auto response_channels = + sensor::make_bandpass_channels(self, spectrometer); + std::vector out; + out.reserve(response_channels.size()); + for (auto& response : response_channels) { + out.push_back(std::move(response.channel)); + } + return out; }, "spectrometer"_a, R"(Compute the real-frequency response for all channels in a spectrometer.