From b2fd8b4d446ce8a7597605928d3aed78c24e5cec Mon Sep 17 00:00:00 2001 From: John Hodge Date: Sat, 9 Aug 2025 10:44:08 -0700 Subject: [PATCH 1/3] Add near-to-far field post-processing and pattern plotter --- examples/README.md | 11 ++++++ examples/patch_antenna_pattern.csv | 38 +++++++++++++++++++ include/vectorem/post/ntf.hpp | 39 +++++++++++++++++++ src/CMakeLists.txt | 1 + src/post/ntf.cpp | 61 ++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 5 +++ tests/test_ntf.cpp | 18 +++++++++ tools/plot_pattern.py | 32 ++++++++++++++++ 8 files changed, 205 insertions(+) create mode 100644 examples/patch_antenna_pattern.csv create mode 100644 include/vectorem/post/ntf.hpp create mode 100644 src/post/ntf.cpp create mode 100644 tests/test_ntf.cpp create mode 100755 tools/plot_pattern.py diff --git a/examples/README.md b/examples/README.md index a49f173..9836d31 100644 --- a/examples/README.md +++ b/examples/README.md @@ -45,3 +45,14 @@ Simulate a straight WR-90 rectangular waveguide and sweep S-parameters. Sample CSV and Touchstone outputs are included in this repository for reference; regenerate them with the commands above to verify the results locally. + +## patch_antenna_pattern + +`patch_antenna_pattern.csv` shows a sample 2D far-field pattern exported +from a patch antenna simulation. Visualize it with the helper script: + +```bash +python ../tools/plot_pattern.py patch_antenna_pattern.csv +``` + +This produces a polar plot of the gain pattern in the $xz$-plane. diff --git a/examples/patch_antenna_pattern.csv b/examples/patch_antenna_pattern.csv new file mode 100644 index 0000000..8708216 --- /dev/null +++ b/examples/patch_antenna_pattern.csv @@ -0,0 +1,38 @@ +theta_deg,phi_deg,eth_mag,eph_mag +0,0,1.0,0 +5,0,0.9961946980917455,0 +10,0,0.984807753012208,0 +15,0,0.9659258262890683,0 +20,0,0.9396926207859084,0 +25,0,0.9063077870366499,0 +30,0,0.8660254037844387,0 +35,0,0.8191520442889918,0 +40,0,0.766044443118978,0 +45,0,0.7071067811865476,0 +50,0,0.6427876096865394,0 +55,0,0.5735764363510462,0 +60,0,0.5000000000000001,0 +65,0,0.42261826174069944,0 +70,0,0.3420201433256688,0 +75,0,0.25881904510252074,0 +80,0,0.17364817766693041,0 +85,0,0.08715574274765814,0 +90,0,6.123233995736766e-17,0 +95,0,0.0,0 +100,0,0.0,0 +105,0,0.0,0 +110,0,0.0,0 +115,0,0.0,0 +120,0,0.0,0 +125,0,0.0,0 +130,0,0.0,0 +135,0,0.0,0 +140,0,0.0,0 +145,0,0.0,0 +150,0,0.0,0 +155,0,0.0,0 +160,0,0.0,0 +165,0,0.0,0 +170,0,0.0,0 +175,0,0.0,0 +180,0,0.0,0 diff --git a/include/vectorem/post/ntf.hpp b/include/vectorem/post/ntf.hpp new file mode 100644 index 0000000..66dafff --- /dev/null +++ b/include/vectorem/post/ntf.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#include + +namespace vectorem { + +struct FFPoint2D { + double theta_deg; ///< Polar angle in degrees + std::complex e_theta; ///< \f$E_\theta\f$ far-field component + std::complex e_phi; ///< \f$E_\phi\f$ far-field component +}; + +/** + * Compute far-field pattern using a discretized Stratton--Chu integral over a + * Huygens surface. The surface is represented by sample points with outward + * normals, tangential electric and magnetic fields, and associated patch + * areas. + * + * The returned pattern is sampled for a fixed azimuthal angle (phi) and a list + * of polar angles (theta). + */ +std::vector stratton_chu_2d( + const std::vector &r, + const std::vector &n, + const std::vector &E, + const std::vector &H, + const std::vector &theta_rad, + double phi_rad, double k0); + +/** Write a 2D pattern to CSV for visualization. */ +void write_pattern_csv(const std::string &path, double phi_deg, + const std::vector &pat); + +} // namespace vectorem + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9529085..78b9124 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(vectorem bc.cpp ports/port_eigensolve.cpp io/touchstone.cpp + post/ntf.cpp sweep.cpp mor/vector_fit.cpp ) diff --git a/src/post/ntf.cpp b/src/post/ntf.cpp new file mode 100644 index 0000000..5ea6d11 --- /dev/null +++ b/src/post/ntf.cpp @@ -0,0 +1,61 @@ +#include "vectorem/post/ntf.hpp" + +#include +#include + +namespace vectorem { + +static constexpr double Z0 = 376.730313668; // free-space impedance [ohm] + +std::vector stratton_chu_2d( + const std::vector &r, + const std::vector &n, + const std::vector &E, + const std::vector &H, + const std::vector &theta_rad, + double phi_rad, double k0) { + std::vector out; + out.reserve(theta_rad.size()); + + for (double th : theta_rad) { + Eigen::Vector3d rhat(std::sin(th) * std::cos(phi_rad), + std::sin(th) * std::sin(phi_rad), std::cos(th)); + Eigen::Vector3d th_hat(std::cos(th) * std::cos(phi_rad), + std::cos(th) * std::sin(phi_rad), -std::sin(th)); + Eigen::Vector3d ph_hat(-std::sin(phi_rad), std::cos(phi_rad), 0.0); + + Eigen::Vector3cd Efar = Eigen::Vector3cd::Zero(); + const std::complex j(0.0, 1.0); + + for (size_t i = 0; i < r.size(); ++i) { + Eigen::Vector3cd J = n[i].cross(H[i]); + Eigen::Vector3cd M = -n[i].cross(E[i]); + // assume unit area for each sample; user should provide with repeated points + std::complex phase = std::exp(-j * k0 * rhat.dot(r[i])); + Eigen::Vector3cd term = j * k0 * (rhat.cross(M)).cross(rhat) - + Z0 * rhat.cross(J); + Efar += term * phase; + } + + FFPoint2D p; + p.theta_deg = th * 180.0 / M_PI; + p.e_theta = Efar.dot(th_hat); + p.e_phi = Efar.dot(ph_hat); + out.push_back(p); + } + return out; +} + +void write_pattern_csv(const std::string &path, double phi_deg, + const std::vector &pat) { + std::ofstream f(path); + f << "theta_deg,phi_deg,eth_mag,eph_mag\n"; + for (const auto &p : pat) { + double eth = std::abs(p.e_theta); + double eph = std::abs(p.e_phi); + f << p.theta_deg << ',' << phi_deg << ',' << eth << ',' << eph << "\n"; + } +} + +} // namespace vectorem + diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 992830b..fb6d623 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -27,3 +27,8 @@ add_executable(test_vector_fit test_vector_fit.cpp) target_link_libraries(test_vector_fit PRIVATE vectorem) add_test(NAME test_vector_fit COMMAND test_vector_fit) set_tests_properties(test_vector_fit PROPERTIES WORKING_DIRECTORY ${PROJECT_SOURCE_DIR} LABELS smoke) + +add_executable(test_ntf test_ntf.cpp) +target_link_libraries(test_ntf PRIVATE vectorem) +add_test(NAME test_ntf COMMAND test_ntf) +set_tests_properties(test_ntf PROPERTIES WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}) diff --git a/tests/test_ntf.cpp b/tests/test_ntf.cpp new file mode 100644 index 0000000..4272b28 --- /dev/null +++ b/tests/test_ntf.cpp @@ -0,0 +1,18 @@ +#include +#include + +#include "vectorem/post/ntf.hpp" + +using namespace vectorem; + +int main() { + std::vector r = {Eigen::Vector3d::Zero()}; + std::vector n = {Eigen::Vector3d::UnitZ()}; + std::vector E = {Eigen::Vector3cd::Zero()}; + std::vector H = {Eigen::Vector3cd::Zero()}; + std::vector theta = {0.0, M_PI / 2}; + auto pat = stratton_chu_2d(r, n, E, H, theta, 0.0, 2 * M_PI); + assert(pat.size() == 2); + assert(std::abs(pat[0].e_theta) < 1e-12); + return 0; +} diff --git a/tools/plot_pattern.py b/tools/plot_pattern.py new file mode 100755 index 0000000..fa2f339 --- /dev/null +++ b/tools/plot_pattern.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import csv +import math +import sys +import matplotlib.pyplot as plt + + +def main(path): + theta = [] + gain = [] + with open(path) as f: + reader = csv.DictReader(f) + for row in reader: + theta.append(math.radians(float(row["theta_deg"]))) + # simple magnitude -> dB + eth = float(row.get("eth_mag", 0.0)) + eph = float(row.get("eph_mag", 0.0)) + mag = math.sqrt(eth ** 2 + eph ** 2) + gain.append(20 * math.log10(mag) if mag > 0 else -120) + ax = plt.subplot(111, projection="polar") + ax.plot(theta, gain) + ax.set_theta_zero_location("N") + ax.set_theta_direction(-1) + ax.set_title("Far-field pattern") + plt.show() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: plot_pattern.py pattern.csv") + sys.exit(1) + main(sys.argv[1]) From 010c7a9a58157a7fc61325d4c62b3e2621770bc2 Mon Sep 17 00:00:00 2001 From: John Hodge Date: Sat, 9 Aug 2025 10:51:34 -0700 Subject: [PATCH 2/3] test: cover nonzero near-to-far field --- tests/test_ntf.cpp | 48 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/tests/test_ntf.cpp b/tests/test_ntf.cpp index 4272b28..1e96138 100644 --- a/tests/test_ntf.cpp +++ b/tests/test_ntf.cpp @@ -1,18 +1,50 @@ #include #include +#include #include "vectorem/post/ntf.hpp" using namespace vectorem; int main() { - std::vector r = {Eigen::Vector3d::Zero()}; - std::vector n = {Eigen::Vector3d::UnitZ()}; - std::vector E = {Eigen::Vector3cd::Zero()}; - std::vector H = {Eigen::Vector3cd::Zero()}; - std::vector theta = {0.0, M_PI / 2}; - auto pat = stratton_chu_2d(r, n, E, H, theta, 0.0, 2 * M_PI); - assert(pat.size() == 2); - assert(std::abs(pat[0].e_theta) < 1e-12); + // Zero-current sanity check + { + std::vector r = {Eigen::Vector3d::Zero()}; + std::vector n = {Eigen::Vector3d::UnitZ()}; + std::vector E = {Eigen::Vector3cd::Zero()}; + std::vector H = {Eigen::Vector3cd::Zero()}; + std::vector theta = {0.0, M_PI / 2}; + auto pat = stratton_chu_2d(r, n, E, H, theta, 0.0, 2 * M_PI); + assert(pat.size() == 2); + assert(std::abs(pat[0].e_theta) < 1e-12); + } + + // Two-point surface with nonzero E and H; expect phi-polarized far field + { + std::vector r = {Eigen::Vector3d(0.5, 0.0, 0.0), + Eigen::Vector3d(-0.5, 0.0, 0.0)}; + std::vector n(2, Eigen::Vector3d::UnitZ()); + std::vector E(2, Eigen::Vector3cd::UnitX()); + std::vector H(2, Eigen::Vector3cd::UnitY()); + std::vector theta = {0.0, M_PI / 2, M_PI}; + auto pat = stratton_chu_2d(r, n, E, H, theta, 0.0, 2 * M_PI); + assert(pat.size() == 3); + + // Far field should be purely phi-polarized + for (const auto &p : pat) { + assert(std::abs(p.e_theta) < 1e-12); + } + + double mag0 = std::abs(pat[0].e_phi); + double mag1 = std::abs(pat[1].e_phi); + double mag2 = std::abs(pat[2].e_phi); + + // Non-zero far field with symmetry about theta=pi/2 + assert(mag0 > 1e-3 && mag1 > 1e-3); + assert(std::abs(mag0 - mag2) / mag0 < 1e-12); + // Broadside maximum at theta=0 greater than at theta=pi/2 + assert(mag0 > 10 * mag1); + } + return 0; } From 9f3aa77d069faf3ad7a1ef1722e641fe5bd96ec6 Mon Sep 17 00:00:00 2001 From: John Hodge Date: Sat, 9 Aug 2025 10:51:48 -0700 Subject: [PATCH 3/3] Support patch areas in Stratton-Chu integration --- include/vectorem/post/ntf.hpp | 1 + src/post/ntf.cpp | 4 ++-- tests/test_ntf.cpp | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/include/vectorem/post/ntf.hpp b/include/vectorem/post/ntf.hpp index 66dafff..114ae25 100644 --- a/include/vectorem/post/ntf.hpp +++ b/include/vectorem/post/ntf.hpp @@ -28,6 +28,7 @@ std::vector stratton_chu_2d( const std::vector &n, const std::vector &E, const std::vector &H, + const std::vector &area, const std::vector &theta_rad, double phi_rad, double k0); diff --git a/src/post/ntf.cpp b/src/post/ntf.cpp index 5ea6d11..f6e884e 100644 --- a/src/post/ntf.cpp +++ b/src/post/ntf.cpp @@ -12,6 +12,7 @@ std::vector stratton_chu_2d( const std::vector &n, const std::vector &E, const std::vector &H, + const std::vector &area, const std::vector &theta_rad, double phi_rad, double k0) { std::vector out; @@ -30,11 +31,10 @@ std::vector stratton_chu_2d( for (size_t i = 0; i < r.size(); ++i) { Eigen::Vector3cd J = n[i].cross(H[i]); Eigen::Vector3cd M = -n[i].cross(E[i]); - // assume unit area for each sample; user should provide with repeated points std::complex phase = std::exp(-j * k0 * rhat.dot(r[i])); Eigen::Vector3cd term = j * k0 * (rhat.cross(M)).cross(rhat) - Z0 * rhat.cross(J); - Efar += term * phase; + Efar += term * phase * area[i]; } FFPoint2D p; diff --git a/tests/test_ntf.cpp b/tests/test_ntf.cpp index 1e96138..7a62094 100644 --- a/tests/test_ntf.cpp +++ b/tests/test_ntf.cpp @@ -13,8 +13,9 @@ int main() { std::vector n = {Eigen::Vector3d::UnitZ()}; std::vector E = {Eigen::Vector3cd::Zero()}; std::vector H = {Eigen::Vector3cd::Zero()}; + std::vector area = {1.0}; std::vector theta = {0.0, M_PI / 2}; - auto pat = stratton_chu_2d(r, n, E, H, theta, 0.0, 2 * M_PI); + auto pat = stratton_chu_2d(r, n, E, H, area, theta, 0.0, 2 * M_PI); assert(pat.size() == 2); assert(std::abs(pat[0].e_theta) < 1e-12); } @@ -26,8 +27,9 @@ int main() { std::vector n(2, Eigen::Vector3d::UnitZ()); std::vector E(2, Eigen::Vector3cd::UnitX()); std::vector H(2, Eigen::Vector3cd::UnitY()); + std::vector area = {1.0, 1.0}; std::vector theta = {0.0, M_PI / 2, M_PI}; - auto pat = stratton_chu_2d(r, n, E, H, theta, 0.0, 2 * M_PI); + auto pat = stratton_chu_2d(r, n, E, H, area, theta, 0.0, 2 * M_PI); assert(pat.size() == 3); // Far field should be purely phi-polarized