diff --git a/CHANGELOG.md b/CHANGELOG.md index ea70a9c0..8e50d640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +### Bug Fixes + +* **`zero-current` now maps to signed NEC `GE -1`:** the stable public ABI value remains `2` (`NEC_GROUND_CONNECTION_ZERO_CURRENT`), while the native geometry receives `-1`. Previously `GE -1` was documented but effectively treated as no ground: `build_connections()` guarded every ground check with `ignd > 0`, so zero-current geometries skipped the plane checks the manual mandates. Both signed ground modes now reject segments that extend below the ground plane or lie in it (a segment may still end on the plane), and a non-none connection without a ground model (`GN` card) fails at `simulate()` instead of silently running free space — NEC-2 Part 3: "A positive or negative value of I1 does not cause a ground to be included in the calculation... The ground parameters must be specified on a program control card following the geometry cards." The `GE -1` connection semantics are unchanged from the Fortran: a ground-touching end stays a free end, so the current goes to zero there, and the low-horizontal-wire escape hatch (height < 1e-3 × segment length) still applies. `testharness/data/discone.nec` gained the missing `GN 1`; `test_example1` now completes the geometry with 0, matching its free-space deck. + +### Compatibility + +* The stateful C++ enum is now `nec_model_ground_connection`, avoiding a name + collision with upstream's C `nec_ground_connection`. Native C++ callers must + update the type name and rebuild. C/WASM entry points and values are unchanged. + ## 0.8.0 - 2026-09-27 ### Added diff --git a/src/c_geometry.cpp b/src/c_geometry.cpp index 4d91143a..a3213c97 100644 --- a/src/c_geometry.cpp +++ b/src/c_geometry.cpp @@ -49,6 +49,7 @@ c_geometry::c_geometry() mp = 0; // m is the number of patches m_ipsym = 0; + m_gpflag = 0; n_plus_2m = 0; n_plus_3m = 0; @@ -469,6 +470,7 @@ void c_geometry::parse_geometry_error(const geometry_parse_state& st) */ void c_geometry::geometry_complete(nec_context* in_context, int gpflag) { + m_gpflag = gpflag; if (0 == np + mp) throw nec_exception("Geometry has no wires or patches."); @@ -2060,21 +2062,26 @@ void c_geometry::build_connections( int ignd ) /* determine connection data for end 1 of segment. */ bool segment_on_ground = false; - if ( ignd != 0) { + bool end1_in_plane = false; + if ( ignd != 0) { /* both signed ground modes validate against the plane */ if ( zi1 <= -slen) { nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); nex.append(iz); nex.append("EXTENDS BELOW GROUND"); throw nex; } - - if ( ignd > 0 && zi1 <= slen) { - icon1[i]= iz; - z[i]=0.; - segment_on_ground = true; + + if ( zi1 <= slen) { + end1_in_plane = true; + if ( ignd > 0) { + /* image mode: the end is connected to its image below the plane */ + icon1[i]= iz; + z[i]=0.; + segment_on_ground = true; + } } /* if ( zi1 <= slen) */ } /* if ( ignd != 0) */ - + if ( false == segment_on_ground ) { int ic= i; bool contact_found = false; @@ -2101,36 +2108,43 @@ void c_geometry::build_connections( int ignd ) if ( ((iz > 0) || (icon1[i] <= PCHCON)) && (false == contact_found) ) icon1[i]=0; - + } /* if ( ! jump ) */ - + /* determine connection data for end 2 of segment. */ - if ( ignd != 0 ) { + if ( ignd != 0) { /* both signed ground modes validate against the plane */ if ( zi2 <= -slen) { nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); nex.append(iz); nex.append("EXTENDS BELOW GROUND"); throw nex; } - - if ( ignd > 0 && zi2 <= slen) { - if ( icon1[i] == iz ) { + + if ( zi2 <= slen) { + bool lies_in_plane = false; + if ( end1_in_plane ) { + /* Image mode snaps ends within the contact threshold onto the + plane, so both ends in the threshold band lie in it. Zero- + current mode keeps the NEC-2 Part 3 escape hatch for horizontal + wires closer than 1e-3 x segment length, so only a segment + with both ends exactly on the plane lies in it. */ + lies_in_plane = (ignd > 0) || ((zi1 == 0.0) && (zi2 == 0.0)); + } + + if ( lies_in_plane ) { nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); nex.append(iz); nex.append("LIES IN GROUND PLANE"); throw nex; } - - icon2[i]= iz; - z2[i]=0.; - continue; - } /* if ( zi2 <= slen) */ - if ( ignd < 0 && zi1 == 0.0 && zi2 == 0.0 ) { - nec_exception nex("GEOMETRY DATA ERROR--SEGMENT "); - nex.append(iz); - nex.append("LIES IN GROUND PLANE"); - throw nex; - } + + if ( ignd > 0) { + /* image mode: the end is connected to its image below the plane */ + icon2[i]= iz; + z2[i]=0.; + continue; + } + } /* if ( zi2 <= slen) */ } /* if ( ignd != 0) */ // re-initialize these vectors! diff --git a/src/c_geometry.h b/src/c_geometry.h index f9c8534d..68166259 100644 --- a/src/c_geometry.h +++ b/src/c_geometry.h @@ -174,6 +174,11 @@ class c_geometry * \exception nec_exception* If there is an error with the geometry. */ void geometry_complete(nec_context* m_context, int gpflag); + + /*! \brief The native GE ground flag (0, 1 or -1) the geometry was + * completed with. + */ + int ground_connection() const { return m_gpflag; } @@ -222,6 +227,7 @@ class c_geometry int64_t m, mp; // The number of patches int m_ipsym; + int m_gpflag; // Native GE ground flag (0, 1 or -1) from geometry_complete() real_array t1x, t1y, t1z, t2x, t2y, t2z; // t1, t2 basis co-ordinates? real_array px, py, pz, pbi, psalp; // patch data diff --git a/src/c_geometry_tb.cpp b/src/c_geometry_tb.cpp index 0cb0f612..7488844e 100644 --- a/src/c_geometry_tb.cpp +++ b/src/c_geometry_tb.cpp @@ -517,3 +517,23 @@ TEST_CASE("WP-S1 symmetry validates tag and size arithmetic before allocation", REQUIRE(geometry.np == 0); } } + +TEST_CASE( "nec_geometry_complete maps public value 2 to native GE -1", "[ground][zero_current][c_api]") { + // The stable public ABI value for the zero-current mode is 2 + // (NEC_GROUND_CONNECTION_ZERO_CURRENT); the native geometry must receive + // the signed NEC value -1, so the below-plane and in-plane rejections of + // GE -1 apply and the recorded connection is -1. + nec_context* nec = nec_create(); + + // A segment extending below the plane: rejected under public value 2. + HANDLE_NEC(nec_wire(nec, 1, 5, 0.0, 0.0, -0.5, 0.0, 0.0, 0.5, 0.001, 1.0, 1.0)); + REQUIRE( nec_geometry_complete(nec, NEC_GROUND_CONNECTION_ZERO_CURRENT) != 0 ); + nec_delete(nec); + + // A valid zero-current geometry records the native flag -1. + nec = nec_create(); + HANDLE_NEC(nec_wire(nec, 1, 5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.001, 1.0, 1.0)); + HANDLE_NEC(nec_geometry_complete(nec, NEC_GROUND_CONNECTION_ZERO_CURRENT)); + REQUIRE( nec->get_geometry()->ground_connection() == -1 ); + nec_delete(nec); +} diff --git a/src/current_quadrature_fixtures.h b/src/current_quadrature_fixtures.h index 6e770895..7dc9f5b2 100644 --- a/src/current_quadrature_fixtures.h +++ b/src/current_quadrature_fixtures.h @@ -88,7 +88,7 @@ inline void build_stateful( nec_stateful_model& model, const std::vector& wires, const std::vector& ports, - nec_ground_connection connection = nec_ground_connection::none, + nec_model_ground_connection connection = nec_model_ground_connection::none, nec_ground_kind ground = nec_ground_kind::free_space, nec_float frequency_mhz = kFrequencyMHz) { diff --git a/src/current_quadrature_wp0_tb.cpp b/src/current_quadrature_wp0_tb.cpp index 0ef5b58e..aeddfcd2 100644 --- a/src/current_quadrature_wp0_tb.cpp +++ b/src/current_quadrature_wp0_tb.cpp @@ -1,6 +1,8 @@ #include #include +// Keep the legacy C header compatible with the stateful C++ headers in this order. +#include "libnecpp.h" #include "c_geometry.h" #include "current_quadrature_fixtures.h" #include "electromag.h" @@ -173,7 +175,7 @@ TEST_CASE("WP0 current-quadrature rooted monopole uses interpolate ground", nec_stateful_model model; build_stateful( model, monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect); + nec_model_ground_connection::interpolate, nec_ground_kind::perfect); const nec_port_solution solution = model.solve_port_voltages_detailed({ nec_complex(1.0, 0.0) }); REQUIRE(solution.currents[0].real() > 0.0); @@ -373,4 +375,3 @@ TEST_CASE("WP0 current-quadrature native baseline JSON is recorded", << "}\n"; REQUIRE(output.good()); } - diff --git a/src/current_quadrature_wp1_tb.cpp b/src/current_quadrature_wp1_tb.cpp index f62203af..980ee25d 100644 --- a/src/current_quadrature_wp1_tb.cpp +++ b/src/current_quadrature_wp1_tb.cpp @@ -264,7 +264,7 @@ TEST_CASE("WP1 current distribution rooted monopole stays physical-only", nec_stateful_model model; build_stateful( model, monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect); + nec_model_ground_connection::interpolate, nec_ground_kind::perfect); const nec_port_solution solution = model.solve_port_voltages_detailed({ nec_complex(1.0, 0.0) }); @@ -391,7 +391,7 @@ TEST_CASE("WP1 current distribution converts snapshot units to metres at 150 MHz constexpr nec_float frequency_mhz = 150.0; nec_stateful_model model; build_stateful(model, dipole_wires(), {{1, 6}}, - nec_ground_connection::none, nec_ground_kind::free_space, frequency_mhz); + nec_model_ground_connection::none, nec_ground_kind::free_space, frequency_mhz); model.solve_port_currents({ nec_complex(1.0, 0.0) }); const nec_current_distribution latest = diff --git a/src/current_quadrature_wp2_tb.cpp b/src/current_quadrature_wp2_tb.cpp index 6904ac23..e98da5b3 100644 --- a/src/current_quadrature_wp2_tb.cpp +++ b/src/current_quadrature_wp2_tb.cpp @@ -247,7 +247,7 @@ TEST_CASE("WP2 prepared quadrature rooted monopole images are explicit", nec_stateful_model model; build_stateful( model, monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect); + nec_model_ground_connection::interpolate, nec_ground_kind::perfect); const nec_current_distribution distribution = model.get_current_distribution(nec_current_mode_kind::unit_current); diff --git a/src/current_quadrature_wp3_tb.cpp b/src/current_quadrature_wp3_tb.cpp index 7c2e69b7..16db84e6 100644 --- a/src/current_quadrature_wp3_tb.cpp +++ b/src/current_quadrature_wp3_tb.cpp @@ -326,7 +326,7 @@ nec_stateful_model scaled_dipole_array( ++tag; } } - model.complete_geometry(nec_ground_connection::none); + model.complete_geometry(nec_model_ground_connection::none); model.define_ports(ports); model.prepare(frequency_mhz); return model; @@ -437,7 +437,7 @@ TEST_CASE("Frequency-scaled unit-current characterization is dimensionally invar nec_stateful_model model; build_stateful( model, scaled_dipole_wires(wavelength_m), {{1, 6}}, - nec_ground_connection::none, nec_ground_kind::free_space, frequency_mhz); + nec_model_ground_connection::none, nec_ground_kind::free_space, frequency_mhz); nec_isolated_element_request request; request.quadrature.nodes = { -0.5, 0.0, 0.5 }; @@ -518,7 +518,7 @@ TEST_CASE("Frequency-scaled multiport modes keep selected and idle feeds normali nec_stateful_model model; build_stateful( model, scaled_turnstile_wires(wavelength_m), {{1, 6}, {2, 6}}, - nec_ground_connection::none, nec_ground_kind::free_space, frequency_mhz); + nec_model_ground_connection::none, nec_ground_kind::free_space, frequency_mhz); const nec_current_distribution unit = model.get_current_distribution(nec_current_mode_kind::unit_current); REQUIRE(unit.mode_count == 2); @@ -603,11 +603,11 @@ TEST_CASE("WP3 characterization rooted monopole keeps images off plane 0", nec_stateful_model model; build_stateful( model, monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect); + nec_model_ground_connection::interpolate, nec_ground_kind::perfect); nec_stateful_model reference; build_stateful( reference, monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect); + nec_model_ground_connection::interpolate, nec_ground_kind::perfect); const auto physical = characterization_request(); const nec_isolated_element_characterization characterized = model.characterize_isolated_element(physical); diff --git a/src/current_quadrature_wp4_tb.cpp b/src/current_quadrature_wp4_tb.cpp index 966bebe5..b770b595 100644 --- a/src/current_quadrature_wp4_tb.cpp +++ b/src/current_quadrature_wp4_tb.cpp @@ -295,7 +295,7 @@ TEST_CASE("WP4 ABI rooted-monopole images stay out of plane 0", nec_stateful_model native; build_stateful( native, monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect); + nec_model_ground_connection::interpolate, nec_ground_kind::perfect); nec_prepared_quadrature_request request; request.nodes.assign(std::begin(kFourNodes), std::end(kFourNodes)); request.images = nec_prepared_quadrature_images::perfect_ground_images; diff --git a/src/current_quadrature_wp5_tb.cpp b/src/current_quadrature_wp5_tb.cpp index 53672183..73c9220f 100644 --- a/src/current_quadrature_wp5_tb.cpp +++ b/src/current_quadrature_wp5_tb.cpp @@ -43,22 +43,22 @@ struct Fixture { const char* id; std::vector wires; std::vector ports; - nec_ground_connection connection; + nec_model_ground_connection connection; nec_ground_kind ground; }; const Fixture kFixtures[] = { { "dipole", dipole_wires(), {{1, 6}}, - nec_ground_connection::none, nec_ground_kind::free_space }, + nec_model_ground_connection::none, nec_ground_kind::free_space }, { "rooted-monopole", monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect }, + nec_model_ground_connection::interpolate, nec_ground_kind::perfect }, { "bent-multiwire", bent_wires(), {{1, kArmSegments}}, - nec_ground_connection::none, nec_ground_kind::free_space }, + nec_model_ground_connection::none, nec_ground_kind::free_space }, { "turnstile-insulated", insulated_turnstile_wires(), {{1, 6}, {2, 6}}, - nec_ground_connection::none, nec_ground_kind::free_space }, + nec_model_ground_connection::none, nec_ground_kind::free_space }, { "turnstile-connected", connected_turnstile_wires(), {{1, kArmSegments}, {3, kArmSegments}}, - nec_ground_connection::none, nec_ground_kind::free_space }, + nec_model_ground_connection::none, nec_ground_kind::free_space }, }; void build_fixture(nec_stateful_model& model, const Fixture& fixture) @@ -477,7 +477,7 @@ TEST_CASE("WP5 rooted monopole image plane stays out of plane 0", nec_stateful_model model; build_stateful( model, monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect); + nec_model_ground_connection::interpolate, nec_ground_kind::perfect); const auto physical = characterization_request(); const nec_isolated_element_characterization characterized = model.characterize_isolated_element(physical); diff --git a/src/current_quadrature_wp6_tb.cpp b/src/current_quadrature_wp6_tb.cpp index 988569b6..a0aba7ce 100644 --- a/src/current_quadrature_wp6_tb.cpp +++ b/src/current_quadrature_wp6_tb.cpp @@ -51,23 +51,23 @@ struct Fixture { const char* id; std::vector wires; std::vector ports; - nec_ground_connection connection; + nec_model_ground_connection connection; nec_ground_kind ground; size_t segment_count; }; const Fixture kFixtures[] = { { "dipole", dipole_wires(), {{1, 6}}, - nec_ground_connection::none, nec_ground_kind::free_space, 11 }, + nec_model_ground_connection::none, nec_ground_kind::free_space, 11 }, { "rooted-monopole", monopole_wires(), {{1, 1}}, - nec_ground_connection::interpolate, nec_ground_kind::perfect, 11 }, + nec_model_ground_connection::interpolate, nec_ground_kind::perfect, 11 }, { "bent-multiwire", bent_wires(), {{1, kArmSegments}}, - nec_ground_connection::none, nec_ground_kind::free_space, 10 }, + nec_model_ground_connection::none, nec_ground_kind::free_space, 10 }, { "turnstile-insulated", insulated_turnstile_wires(), {{1, 6}, {2, 6}}, - nec_ground_connection::none, nec_ground_kind::free_space, 22 }, + nec_model_ground_connection::none, nec_ground_kind::free_space, 22 }, { "turnstile-connected", connected_turnstile_wires(), {{1, kArmSegments}, {3, kArmSegments}}, - nec_ground_connection::none, nec_ground_kind::free_space, 20 }, + nec_model_ground_connection::none, nec_ground_kind::free_space, 20 }, }; void build_fixture(nec_stateful_model& model, const Fixture& fixture) diff --git a/src/libNEC.cpp b/src/libNEC.cpp index d90e8c34..f212cedc 100644 --- a/src/libNEC.cpp +++ b/src/libNEC.cpp @@ -79,6 +79,10 @@ long nec_gx_card(nec_context* in_context, int i1, int i2) { long nec_geometry_complete(nec_context* in_context, int gpflag) { + /* The public ABI value for the zero-current mode is 2 (see + nec_ground_connection); the native NEC GE flag is -1. */ + if (gpflag == NEC_GROUND_CONNECTION_ZERO_CURRENT) + gpflag = -1; NEC_ERROR_HANDLE(in_context->geometry_complete(gpflag)); } diff --git a/src/libnecpp.h b/src/libnecpp.h index 18119109..ca0a5344 100644 --- a/src/libnecpp.h +++ b/src/libnecpp.h @@ -190,12 +190,33 @@ long nec_gm_card(nec_context* in_context, int itsi, int nrpt, long nec_gx_card(nec_context* in_context, int i1, int i2); +/*! \brief Ground connection modes accepted by nec_geometry_complete() (GE card I1). + + These are the stable public ABI values. NONE and IMAGE equal the native NEC + GE values; ZERO_CURRENT (2) maps to the native signed NEC value -1. + + NEC-2 Part 3: a positive or negative I1 does not cause a ground to be + included in the calculation, it only modifies the geometry data as required + when a ground is present. The ground parameters must be specified on a GN + card following the geometry cards, and when I1 is nonzero no segment may + extend below the ground plane (X,Y plane) or lie in this plane (segments may + end on the ground plane, however). +*/ +enum nec_ground_connection { + NEC_GROUND_CONNECTION_NONE = 0, /*!< GE 0 - no ground plane is present. */ + NEC_GROUND_CONNECTION_IMAGE = 1, /*!< GE 1 - currents on segments touching the ground are interpolated to their images below the ground (charge at base is zero). */ + NEC_GROUND_CONNECTION_ZERO_CURRENT = 2 /*!< GE -1 - currents on segments touching the ground go to zero at the ground. */ +}; + /*! \brief Indicate that the geometry is complete (GE card) * \param in_context The nec_context created with nec_create() - * \param gpflag Geometry ground plain flag. - * \arg \c 0 - no ground plane is present. - * \arg \c 1 - Indicates a ground plane is present. Structure symmetry is modified as required, and the current expansion is modified so that the currents an segments touching the ground (x, Y plane) are interpolated to their images below the ground (charge at base is zero) - * \arg \c -1 - indicates a ground is present. Structure symmetry is modified as required. Current expansion, however, is not modified, Thus, currents on segments touching the ground will go to zero at the ground. + * \param gpflag Geometry ground plane flag, one of nec_ground_connection: + * \arg \c NEC_GROUND_CONNECTION_NONE (0) - no ground plane is present. + * \arg \c NEC_GROUND_CONNECTION_IMAGE (1) - a ground plane is present. Structure symmetry is modified as required, and the current expansion is modified so that the currents on segments touching the ground (X,Y plane) are interpolated to their images below the ground (charge at base is zero). + * \arg \c NEC_GROUND_CONNECTION_ZERO_CURRENT (2) - a ground is present. Structure symmetry is modified as required. Current expansion, however, is not modified, thus currents on segments touching the ground will go to zero at the ground. The native GE flag -1 is used internally. + * Both signed modes reject segments that extend below the ground plane or + * lie in it, and a non-none connection without a ground model (GN card) + * fails when the simulation is prepared. \copydoc error_return **/ long nec_geometry_complete(nec_context* in_context, int gpflag); diff --git a/src/nec_context.cpp b/src/nec_context.cpp index 3fe1c8df..59c3bb41 100644 --- a/src/nec_context.cpp +++ b/src/nec_context.cpp @@ -1157,6 +1157,16 @@ void nec_context::simulate(bool far_field_flag) { activate_medium_parameters(); + /* NEC-2 Part 3: a positive or negative GE I1 only modifies the geometry + for a ground; it does not include a ground in the calculation. The + ground parameters must be specified on a GN card. Refuse to run a + ground-connection geometry without a ground model instead of silently + simulating free space. */ + if ( (m_geometry->ground_connection() != 0) && (false == ground.present()) ) { + throw nec_exception( + "GEOMETRY DATA ERROR--GROUND CONNECTION SPECIFIED WITHOUT A GROUND MODEL (GN CARD)"); + } + /* Allocate the normalization buffer */ if ( iped ) fnorm.resize(nfrq,4); diff --git a/src/nec_context.h b/src/nec_context.h index dde7976a..ebec6afa 100644 --- a/src/nec_context.h +++ b/src/nec_context.h @@ -394,8 +394,14 @@ class nec_context static nec_float benchmark(); /*! \brief Signal the end of a geometry description. - + This function prepares for a calculation by calling calc_prepare(). + + \param gpflag Native GE ground flag: 0 = no ground, 1 = image interpolation + (charge zero at base), -1 = zero current at the ground plane. The C API + constant NEC_GROUND_CONNECTION_ZERO_CURRENT (public value 2) maps to -1. + Both signed modes reject segments below or in the ground plane, and a + non-none connection without a ground model (GN card) fails in simulate(). */ void geometry_complete(int gpflag); diff --git a/src/nec_context_tb.cpp b/src/nec_context_tb.cpp index d22ffeef..bb2cb584 100644 --- a/src/nec_context_tb.cpp +++ b/src/nec_context_tb.cpp @@ -311,6 +311,87 @@ TEST_CASE( "Legs meeting on the ground plane are accepted", "[segment_junction]" REQUIRE(geo->overlap_findings().empty()); } +TEST_CASE( "Both signed ground modes reject below-plane segments", "[ground][zero_current]") { + // NEC-2 Part 3: "When I1 is nonzero, no segment may extend below the + // ground plane (X,Y plane) or lie in this plane." The zero-current mode + // (GE -1) used to skip the checks that image mode (GE 1) applies. + for (int gpflag : {1, -1}) { + nec_context nec; + nec.initialize(); + + c_geometry* geo = nec.get_geometry(); + geo->wire(1, 5, 0.0, 0.0, -0.5, 0.0, 0.0, 0.5, 0.001, 1.0, 1.0); + REQUIRE_THROWS(nec.geometry_complete(gpflag)); + } +} + +TEST_CASE( "Both signed ground modes reject segments lying in the plane", "[ground][zero_current]") { + // A segment whose axis lies in the ground plane is rejected in both + // signed modes, while one that merely ends on the plane is accepted. + for (int gpflag : {1, -1}) { + nec_context nec; + nec.initialize(); + + c_geometry* geo = nec.get_geometry(); + geo->wire(1, 5, -0.5, 0.0, 0.0, 0.5, 0.0, 0.0, 0.001, 1.0, 1.0); + REQUIRE_THROWS(nec.geometry_complete(gpflag)); + } +} + +TEST_CASE( "GE -1 accepts a segment ending on the ground plane", "[ground][zero_current]") { + // The zero-current mode leaves a ground-touching end free (no image + // connection), so the current goes to zero there; the segment is valid. + nec_context nec; + nec.initialize(); + + c_geometry* geo = nec.get_geometry(); + geo->wire(1, 5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.001, 1.0, 1.0); + REQUIRE_NOTHROW(nec.geometry_complete(-1)); + REQUIRE(geo->ground_connection() == -1); +} + +TEST_CASE( "GE -1 accepts a horizontal wire close to the ground plane", "[ground][zero_current]") { + // NEC-2 Part 3: for a horizontal wire less than 1e-3 x its segment length + // above the ground, GE 1 connects every end to ground (or rejects the + // wire); GE -1 must accept it with the ends left free. + nec_context nec; + nec.initialize(); + + c_geometry* geo = nec.get_geometry(); + geo->wire(1, 5, -1.0, 0.0, 1.0e-4, 1.0, 0.0, 1.0e-4, 0.001, 1.0, 1.0); + REQUIRE_NOTHROW(nec.geometry_complete(-1)); +} + +TEST_CASE( "Ground connection without a ground model fails at simulate", "[ground][zero_current]") { + // NEC-2 Part 3: "A positive or negative value of I1 does not cause a + // ground to be included in the calculation... The ground parameters must + // be specified on a program control card following the geometry cards." + // Fail loudly instead of silently simulating free space. + nec_context nec; + nec.initialize(); + + c_geometry* geo = nec.get_geometry(); + geo->wire(1, 5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.001, 1.0, 1.0); + nec.geometry_complete(1); + + nec.ex_card(EXCITATION_VOLTAGE, 0, 5, 0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + REQUIRE_THROWS(nec.xq_card(0)); +} + +TEST_CASE( "Ground connection with a ground model simulates", "[ground][zero_current]") { + // The same geometry with a GN card (perfect ground) is a valid simulation. + nec_context nec; + nec.initialize(); + + c_geometry* geo = nec.get_geometry(); + geo->wire(1, 5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 0.001, 1.0, 1.0); + nec.geometry_complete(1); + nec.gn_card(1, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + + nec.ex_card(EXCITATION_VOLTAGE, 0, 5, 0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + REQUIRE_NOTHROW(nec.xq_card(0)); +} + TEST_CASE( "Wire intruding past a shared node is rejected", "[segment_junction]") { // A feed leaving the hub of a radial at 20 degrees. Its first center clears // the radial segment it shares the hub with and comes to rest inside the diff --git a/src/nec_stateful_model.cpp b/src/nec_stateful_model.cpp index d6d50714..b7a40ecc 100644 --- a/src/nec_stateful_model.cpp +++ b/src/nec_stateful_model.cpp @@ -284,32 +284,32 @@ void nec_stateful_model::add_wire(const nec_wire_definition& wire) m_state = nec_model_state::geometry_building; } -void nec_stateful_model::complete_geometry(nec_ground_connection connection) +void nec_stateful_model::complete_geometry(nec_model_ground_connection connection) { static_cast(complete_geometry(nec_geometry_symmetry{}, connection)); } const nec_geometry_completion_result& nec_stateful_model::complete_geometry( const nec_geometry_symmetry& symmetry, - nec_ground_connection connection) + nec_model_ground_connection connection) { require_state(nec_model_state::geometry_building, "COMPLETE GEOMETRY"); int flag = 0; switch (connection) { - case nec_ground_connection::none: + case nec_model_ground_connection::none: flag = 0; break; - case nec_ground_connection::interpolate: + case nec_model_ground_connection::interpolate: flag = 1; break; - case nec_ground_connection::zero_current: + case nec_model_ground_connection::zero_current: flag = -1; break; default: fail("COMPLETE GEOMETRY", "UNKNOWN GROUND CONNECTION MODE"); } - if (connection != nec_ground_connection::none && + if (connection != nec_model_ground_connection::none && symmetry.kind == nec_geometry_symmetry_kind::reflection && (symmetry.reflection_plane_mask & nec_reflection_plane_z) != 0u) fail_geometry( @@ -552,7 +552,7 @@ void nec_stateful_model::prepare(nec_float frequency_mhz) fail("PREPARE", "PORTS HAVE NOT BEEN DEFINED"); if (!finite_value(frequency_mhz) || !(frequency_mhz > 0.0)) fail("PREPARE", "FREQUENCY MUST BE POSITIVE AND FINITE"); - if (m_ground_connection != nec_ground_connection::none && + if (m_ground_connection != nec_model_ground_connection::none && m_ground.kind == nec_ground_kind::free_space) fail_geometry( "PREPARE", "A GROUND CONNECTION REQUIRES A GROUND MODEL"); diff --git a/src/nec_stateful_model.h b/src/nec_stateful_model.h index f93d031c..5d7d939a 100644 --- a/src/nec_stateful_model.h +++ b/src/nec_stateful_model.h @@ -44,7 +44,7 @@ struct nec_wire_definition { nec_float radius_m = 0.0; }; -enum class nec_ground_connection { +enum class nec_model_ground_connection { none = 0, interpolate = 1, zero_current = 2, @@ -271,12 +271,12 @@ class nec_stateful_model { /*! Complete ordinary geometry while preserving the pre-symmetry API. */ void complete_geometry( - nec_ground_connection connection = nec_ground_connection::none); + nec_model_ground_connection connection = nec_model_ground_connection::none); /*! Generate the final symmetry copies, complete geometry, and retain metadata. */ const nec_geometry_completion_result& complete_geometry( const nec_geometry_symmetry& symmetry, - nec_ground_connection connection = nec_ground_connection::none); + nec_model_ground_connection connection = nec_model_ground_connection::none); /*! Read immutable metadata for successfully completed geometry. */ const nec_geometry_completion_result& geometry_completion() const; @@ -428,7 +428,7 @@ class nec_stateful_model { nec_embedded_far_field_result m_embedded_far_field_result; std::vector m_far_field_segment_half_lengths; nec_ground_definition m_ground; - nec_ground_connection m_ground_connection = nec_ground_connection::none; + nec_model_ground_connection m_ground_connection = nec_model_ground_connection::none; nec_float m_frequency_mhz = 0.0; uint64_t m_factorization_generation = 0; uint64_t m_solve_generation = 0; diff --git a/src/nec_stateful_model_symmetry_tb.cpp b/src/nec_stateful_model_symmetry_tb.cpp index 26af69c0..4c30c843 100644 --- a/src/nec_stateful_model_symmetry_tb.cpp +++ b/src/nec_stateful_model_symmetry_tb.cpp @@ -594,7 +594,7 @@ TEST_CASE("WP-S2 rejects only ground that conflicts with structural symmetry", REQUIRE_THROWS_AS( model.complete_geometry( reflection(nec_reflection_plane_z), - nec_ground_connection::interpolate), + nec_model_ground_connection::interpolate), nec_exception); REQUIRE(model.state() == nec_model_state::geometry_building); } diff --git a/src/nec_stateful_model_wp3_tb.cpp b/src/nec_stateful_model_wp3_tb.cpp index cb85186a..ee2dbaab 100644 --- a/src/nec_stateful_model_wp3_tb.cpp +++ b/src/nec_stateful_model_wp3_tb.cpp @@ -4,6 +4,8 @@ #include "electromag.h" #include "nec_exception.h" #include "nec_stateful_model.h" +// Also compile the public headers in the reverse order (C++ before C). +#include "libnecpp.h" #include #include @@ -441,7 +443,7 @@ TEST_CASE("WP3 ground-skipped angles have deterministic zero field entries", 0.0, 0.0, 0.25, 0.001, }); - model.complete_geometry(nec_ground_connection::interpolate); + model.complete_geometry(nec_model_ground_connection::interpolate); model.define_ports({{1, kFeedSegment}}); model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}); model.prepare(kFrequencyMHz); @@ -486,7 +488,7 @@ TEST_CASE("WP3 native power budgets agree with converged field flux", 0.0, 0.0, 0.25, 0.001, }); - model.complete_geometry(nec_ground_connection::interpolate); + model.complete_geometry(nec_model_ground_connection::interpolate); model.define_ports({{1, 2}}); model.set_ground({nec_ground_kind::perfect, 0.0, 0.0}); model.prepare(kFrequencyMHz); @@ -506,7 +508,7 @@ TEST_CASE("WP3 native power budgets agree with converged field flux", TEST_CASE("WP3 signed ground connections retain distinct NEC GE semantics", "[wasm_api][wp3][ground][connection]") { - const auto rooted_impedance = [](nec_ground_connection connection) { + const auto rooted_impedance = [](nec_model_ground_connection connection) { nec_stateful_model model; model.add_wire({ 1, kSegments, @@ -523,16 +525,16 @@ TEST_CASE("WP3 signed ground connections retain distinct NEC GE semantics", }; const nec_complex interpolated = - rooted_impedance(nec_ground_connection::interpolate); + rooted_impedance(nec_model_ground_connection::interpolate); const nec_complex zero_current = - rooted_impedance(nec_ground_connection::zero_current); + rooted_impedance(nec_model_ground_connection::zero_current); REQUIRE(std::abs(interpolated - zero_current) > 1.0e-6); nec_stateful_model missing_ground; missing_ground.add_wire({ 1, kSegments, 0.0, 0.0, 0.0, 0.0, 0.0, 0.25, 0.001, }); - missing_ground.complete_geometry(nec_ground_connection::zero_current); + missing_ground.complete_geometry(nec_model_ground_connection::zero_current); missing_ground.define_ports({{1, 2}}); REQUIRE_THROWS_AS(missing_ground.prepare(kFrequencyMHz), nec_exception); } @@ -540,9 +542,9 @@ TEST_CASE("WP3 signed ground connections retain distinct NEC GE semantics", TEST_CASE("WP3 both signed ground modes reject invalid ground-plane geometry", "[wasm_api][wp3][ground][validation]") { - for (const nec_ground_connection connection : { - nec_ground_connection::interpolate, - nec_ground_connection::zero_current, + for (const nec_model_ground_connection connection : { + nec_model_ground_connection::interpolate, + nec_model_ground_connection::zero_current, }) { nec_stateful_model below; below.add_wire({ diff --git a/src/necpp_wasm_v1.cpp b/src/necpp_wasm_v1.cpp index 22d7183b..42e8699c 100644 --- a/src/necpp_wasm_v1.cpp +++ b/src/necpp_wasm_v1.cpp @@ -1130,7 +1130,7 @@ int32_t necpp_wasm_v1_complete_geometry( "Unknown ground connection"); return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { model->native.complete_geometry( - static_cast(ground_connection)); + static_cast(ground_connection)); model->geometry_completion = model->native.geometry_completion(); model->geometry_completion_available = true; clear_calculated_results(*model); @@ -1176,7 +1176,7 @@ int32_t necpp_wasm_v1_complete_geometry_symmetric( return invoke(model, NECPP_WASM_V1_GEOMETRY_ERROR, [&] { model->geometry_completion = model->native.complete_geometry( - symmetry, static_cast(ground_connection)); + symmetry, static_cast(ground_connection)); model->geometry_completion_available = true; clear_calculated_results(*model); }); diff --git a/testharness/data/discone.nec b/testharness/data/discone.nec index 80af2eaf..c2481ed0 100644 --- a/testharness/data/discone.nec +++ b/testharness/data/discone.nec @@ -359,3 +359,4 @@ GW 356,4,8.87012,-5.9268,.3048,9.85594,-4.0824,.3048,.00129 GW 357,4,9.85594,-4.0824,.3048,10.463,-2.0812,.3048,.00129 GW 358,4,10.463,-2.0812,.3048,10.6679,.00001,.3048,.00129 GE 1 +GN 1 diff --git a/testharness/python/test_examples.py b/testharness/python/test_examples.py index af58b1ef..0bd8d78c 100644 --- a/testharness/python/test_examples.py +++ b/testharness/python/test_examples.py @@ -25,7 +25,10 @@ def test_example1(self): EN ''' self.handle_nec(nec_wire(nec, 0, 7, 0., 0., .75, 0., 0., 1.25, .001, 1.0, 1.0)) - self.handle_nec(nec_geometry_complete(nec, 1)) + # Free-space dipole (deck comment: plain "GE", no GN card follows); a + # non-none ground connection without a ground model now fails at + # simulate, so the geometry must be completed with flag 0. + self.handle_nec(nec_geometry_complete(nec, 0)) self.handle_nec(nec_ex_card(nec, 0, 0, 4,0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0)) self.handle_nec(nec_xq_card(nec, 0)) self.handle_nec(nec_ld_card(nec, 0, 0, 4, 4, 10., 3.000E-09, 5.300E-11))