Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions apps/nec-cli/src/solve_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,14 +192,13 @@ pub(super) fn residual_hallen(
let n = z.n;
let mut r = vec![Complex64::new(0.0, 0.0); n];

let endpoints: &[(usize, usize)];
let fallback_endpoints;
if wire_endpoints.is_empty() || n == 0 {
let endpoints: &[(usize, usize)] = if wire_endpoints.is_empty() || n == 0 {
fallback_endpoints = if n > 0 { vec![(0usize, n - 1)] } else { vec![] };
endpoints = &fallback_endpoints;
&fallback_endpoints
} else {
endpoints = wire_endpoints;
}
wire_endpoints
};

let mut row_wire = vec![0usize; n];
for (wi, &(first, last)) in endpoints.iter().enumerate() {
Expand Down
27 changes: 12 additions & 15 deletions crates/nec_solver/src/linear.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,14 +421,13 @@ pub fn solve_hallen_sinusoidal_basis(
}

// Resolve effective wire endpoint list.
let endpoints: &[(usize, usize)];
let fallback_endpoints;
if wire_endpoints.is_empty() || n == 0 {
let endpoints: &[(usize, usize)] = if wire_endpoints.is_empty() || n == 0 {
fallback_endpoints = if n > 0 { vec![(0usize, n - 1)] } else { vec![] };
endpoints = &fallback_endpoints;
&fallback_endpoints
} else {
endpoints = wire_endpoints;
}
wire_endpoints
};

// If any wire has fewer than 2 segments, fall back to standard Hallén.
if endpoints.iter().any(|&(first, last)| last <= first) {
Expand Down Expand Up @@ -686,14 +685,13 @@ pub fn solve_hallen(
}

// Build the endpoint constraint list: per-wire if supplied, else global endpoints.
let endpoints: &[(usize, usize)];
let fallback_endpoints;
if wire_endpoints.is_empty() || n == 0 {
let endpoints: &[(usize, usize)] = if wire_endpoints.is_empty() || n == 0 {
fallback_endpoints = if n > 0 { vec![(0usize, n - 1)] } else { vec![] };
endpoints = &fallback_endpoints;
&fallback_endpoints
} else {
endpoints = wire_endpoints;
}
wire_endpoints
};

// Build the set of endpoint segment indices that participate in at least one
// junction constraint. These will receive a continuity constraint rather than
Expand Down Expand Up @@ -921,14 +919,13 @@ pub fn solve_hallen_planewave(
});
}

let endpoints: &[(usize, usize)];
let fallback_endpoints;
if wire_endpoints.is_empty() || n == 0 {
let endpoints: &[(usize, usize)] = if wire_endpoints.is_empty() || n == 0 {
fallback_endpoints = if n > 0 { vec![(0usize, n - 1)] } else { vec![] };
endpoints = &fallback_endpoints;
&fallback_endpoints
} else {
endpoints = wire_endpoints;
}
wire_endpoints
};

let w = endpoints.len();
// Two endpoint constraints (I=0 at first and last) per wire.
Expand Down
29 changes: 23 additions & 6 deletions crates/nec_solver/src/planewave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,16 @@ fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
/// Build the Hallén forcing + homogeneous columns for the first incident
/// plane-wave EX card in `deck`.
///
/// Supports one or more **straight, non-junctioned** wires (e.g. a parallel
/// dipole array). Each wire carries its own Hallén particular solution: the
/// tangential field uses that wire's axis, the along-wire coordinate is measured
/// from that wire's midpoint, and the `sin(k|sₘ−s_p|)` kernel sums only over
/// segments on the same wire. Junctioned geometry is rejected (its continuity
/// Supports one or more **straight, non-junctioned** conductors (e.g. a parallel
/// dipole array). Each conductor carries its own Hallén particular solution: the
/// tangential field uses that conductor's axis, the along-wire coordinate is
/// measured from its midpoint, and the `sin(k|sₘ−s_p|)` kernel sums only over
/// segments on the same conductor. Junctioned geometry is rejected (its continuity
/// constraints are not modelled by [`crate::solve_hallen_planewave`]).
///
/// "Conductor" rather than "`GW` card": a straight wire split across several
/// collinear `GW` cards is one conductor here, via
/// [`crate::geometry::merge_collinear_wire_endpoints`].
pub fn build_planewave_hallen(
deck: &NecDeck,
segs: &[Segment],
Expand All @@ -174,7 +178,20 @@ pub fn build_planewave_hallen(
.ok_or(PlaneWaveError::NoPlaneWaveCard)?;

let n = segs.len();
let wire_endpoints = crate::geometry::wire_endpoints_from_segs(segs);
// Collinear `GW` splits are merged into one logical conductor, exactly as the
// delta-gap sibling `crate::build_hallen_rhs` does — and as
// `crate::solve_hallen_planewave`, which consumes what this builds, already
// assumed: its caller hands it `merge_collinear_wire_endpoints`, so a raw
// per-`GW` list here meant the builder and the solver disagreed about what a
// wire is. All three uses below need the merged list, not just the junction
// test: the `sin(k|s_m - s_p|)` sum must run over the whole conductor, and `s`
// must be measured from the conductor's midpoint rather than reset at a split.
//
// On geometry with no collinear split this is a strict no-op — the merge
// returns exactly `wire_endpoints_from_segs` there — so no deck that solved
// before changes, and a genuine T/Y junction is still detected and refused
// (FND-142).
let wire_endpoints = crate::geometry::merge_collinear_wire_endpoints(segs);
if !crate::geometry::detect_wire_junctions(
segs,
&wire_endpoints,
Expand Down
135 changes: 135 additions & 0 deletions crates/nec_solver/tests/collinear_merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,138 @@ fn merge_joins_collinear_same_radius_chain() {
// Two 10-seg wires → one merged block spanning all 20 segments.
assert_eq!(merge_collinear_wire_endpoints(&segs), vec![(0, 19)]);
}

/// The **receive** twin of `collinear_chain_recovers_single_wire_impedance`.
///
/// A straight conductor split across two collinear `GW` cards, lit by an incident
/// plane wave, must carry the same induced current as the same conductor written
/// as one card. It did not: it was **refused outright** (FND-142). The delta-gap
/// builder merged collinear splits and the plane-wave builder did not, so
/// `detect_wire_junctions` saw the join as a junction and
/// `build_planewave_hallen` rejected the deck — while
/// `solve_hallen_planewave`, which consumes what that builder produces, was
/// already being handed the *merged* endpoint list by its caller.
///
/// The two decks are segmented **identically** (25 + 25 against 50 over the same
/// span, so every segment midpoint coincides). That is what makes this an
/// equality rather than a similarity: nothing differs but the card boundary and
/// the bookkeeping it drives, so any residual is the basis disagreeing with
/// itself.
#[test]
fn collinear_chain_recovers_single_wire_plane_wave_currents() {
// Broadside incidence (θ = 90°, φ = 0, η = 0) puts ê along ẑ, parallel to the
// dipole: maximum coupling. A near-axial wave would drive the conductor to
// almost nothing and let this pass on two vectors of noise.
let ex1 = ExCard {
excitation_type: 1,
tag: 0,
segment: 0,
i4: 0,
voltage_real: 90.0, // θ_inc, degrees
voltage_imag: 0.0, // φ_inc, degrees
polarization_deg: 0.0,
polarization_ratio: 0.0,
theta_inc: 0.0,
phi_inc: 0.0,
};

let gw = |tag: u32, segments: u32, z0: f64, z1: f64| {
Card::Gw(GwCard {
tag,
segments,
start: [0.0, 0.0, z0],
end: [0.0, 0.0, z1],
radius: 0.001,
})
};

let mut whole = NecDeck::new();
whole.cards.push(gw(1, 50, -5.282, 5.282));
whole.cards.push(Card::Ex(ex1.clone()));

let mut split = NecDeck::new();
split.cards.push(gw(1, 25, -5.282, 0.0));
split.cards.push(gw(2, 25, 0.0, 5.282));
split.cards.push(Card::Ex(ex1));

let induced = |deck: &NecDeck| -> Vec<Complex64> {
let segs = build_geometry(deck).unwrap();
let z = assemble_z_matrix_with_ground(&segs, FREQ, &GroundModel::FreeSpace);
solve_hallen_planewave_routed(deck, &segs, &z, FREQ)
.expect("a collinear split is one conductor, not a junction")
};

let i_whole = induced(&whole);
let i_split = induced(&split);
assert_eq!(i_whole.len(), 50);
assert_eq!(i_split.len(), 50);

// The split really is a split: two `GW` cards that merge to one conductor.
let split_segs = build_geometry(&split).unwrap();
assert_eq!(wire_endpoints_from_segs(&split_segs).len(), 2);
assert_eq!(merge_collinear_wire_endpoints(&split_segs), vec![(0, 49)]);

// Floor: an all-zero pair would satisfy every ratio below. The measured peak
// is ~1.23 mA; this only has to exclude nothing.
let peak = i_whole.iter().map(|c| c.norm()).fold(0.0, f64::max);
assert!(
peak > 1e-4,
"fixture induces no current, so the comparison is vacuous: peak = {peak:e}"
);

let worst = i_whole
.iter()
.zip(&i_split)
.map(|(a, b)| (a - b).norm())
.fold(0.0, f64::max);
assert!(
worst / peak < 1e-9,
"split conductor disagrees with the same conductor written whole: \
worst |Δ| = {worst:e} against peak |I| = {peak:e} (relative {:e})",
worst / peak
);
}

/// A genuine junction is still refused — the merge must not have widened the
/// gate it was narrowing. A bent (non-collinear) pair shares an endpoint, so the
/// merge is a no-op and the junction test still fires.
#[test]
fn a_bent_junction_is_still_refused_on_the_plane_wave_path() {
let ex1 = ExCard {
excitation_type: 1,
tag: 0,
segment: 0,
i4: 0,
voltage_real: 90.0,
voltage_imag: 0.0,
polarization_deg: 0.0,
polarization_ratio: 0.0,
theta_inc: 0.0,
phi_inc: 0.0,
};
let mut bent = NecDeck::new();
bent.cards.push(Card::Gw(GwCard {
tag: 1,
segments: 21,
start: [-5.0, 0.0, 0.0],
end: [0.0, 0.0, 3.0],
radius: 0.001,
}));
bent.cards.push(Card::Gw(GwCard {
tag: 2,
segments: 21,
start: [0.0, 0.0, 3.0],
end: [5.0, 0.0, 0.0],
radius: 0.001,
}));
bent.cards.push(Card::Ex(ex1));
let segs = build_geometry(&bent).unwrap();
assert_eq!(
merge_collinear_wire_endpoints(&segs),
wire_endpoints_from_segs(&segs),
"a bend is not a collinear continuation, so the merge must be a no-op here"
);
let err = build_planewave_hallen(&bent, &segs, FREQ)
.expect_err("a bent junction is not modelled by the per-conductor basis");
assert_eq!(err, PlaneWaveError::JunctionedGeometryNotSupported);
}
Loading
Loading