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
36 changes: 30 additions & 6 deletions apps/nec-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,26 @@ fn main() -> ExitCode {
} else {
frequencies_from_fr(deck)
};
if freqs_hz.is_empty() {
return ExitCode::SUCCESS;
// This used to be `return ExitCode::SUCCESS` — a deck with no `FR` and no
// `--sweep-config` exited 0 having written zero bytes to stdout AND stderr,
// while the GUI and `fnec_py` refused the same deck (FND-070). A silent
// success is the worst of the three answers: it is indistinguishable from a
// run that worked.
//
// The check is over the RESOLVED list, so `--sweep-config` on a deck with no
// `FR` still solves — frequencies do not have to come from the deck, which is
// exactly why this cannot live in `pre_solve_error`.
//
// Reached only when both sources are absent: a `--sweep-config` that parsed
// but yielded nothing is already refused by `SweepConfig::from_file`, and a
// file that failed to parse exits above.
if let Some(err) = nec_solver::validate::no_frequency_error(
&freqs_hz,
"Add an `FR` card to the deck, or pass `--sweep-config <file.toml>` to \
supply the frequencies yourself.",
) {
eprintln!("error: {err}");
return ExitCode::FAILURE;
}

if !exec_flag_explicitly_set && profile == CompatibilityProfile::Native {
Expand Down Expand Up @@ -1211,10 +1229,16 @@ fn run_sweep_subcommand(args: &[String]) -> ExitCode {

// Find the single FR frequency from the deck.
let freqs = frequencies_from_fr(deck);
let freq_hz = freqs
.first()
.copied()
.ok_or_else(|| "resonance search: deck must have an FR card".to_string())?;
let freq_hz = freqs.first().copied().ok_or_else(|| {
// Same sentence as every other frontend (FND-070). The remedy is
// narrower here: a resonance search substitutes into a template
// deck, so `--sweep-config` is not the answer.
nec_solver::validate::no_frequency_error(
&freqs,
"Add an `FR` card to the template deck.",
)
.unwrap_or_else(|| "resonance search: no frequency".to_string())
})?;

let solve_result = solve_frequency_point(
deck,
Expand Down
86 changes: 86 additions & 0 deletions apps/nec-cli/tests/sweep_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,89 @@ fn sweep_output_is_machine_parseable() {
"expected 2 FEEDPOINTS sections (one per frequency), got:\n{stdout}"
);
}

/// The deck-free half of the flag: `--sweep-config` does not merely *override* an
/// `FR` card, it **supplies** the frequencies when the deck has none.
///
/// That capability was gated by nothing until now, which mattered the moment a
/// refusal for frequency-less decks was added (FND-070): the obvious placement
/// for that refusal — `validate::pre_solve_error`, which every frontend already
/// calls — sees only the deck and would have refused this working case. The
/// refusal is typed on the *resolved* frequency list instead, and this test is
/// what stops a future author from "simplifying" it back onto the deck.
///
/// `docs/cli-guide.md` said "overrides the `FR` card frequency list", which is
/// true and incomplete; it now says it also supplies one.
#[test]
fn sweep_config_supplies_the_frequencies_for_a_deck_with_no_fr_card() {
const NO_FR_DECK: &str = "GW 1 51 0 0 -5.282 0 0 5.282 0.001\nGE\nEX 0 1 26 0 1.0 0.0\nEN\n";
assert!(
!NO_FR_DECK.contains("FR"),
"the fixture must have no FR card, or this test proves nothing"
);

let deck = write_temp("no-fr-deck", NO_FR_DECK);
let cfg = write_temp("no-fr-cfg", "[frequency]\npoints_mhz = [14.0, 14.2]\n");
let output = Command::new(env!("CARGO_BIN_EXE_fnec"))
.arg("--sweep-config")
.arg(&cfg)
.arg(&deck)
.output()
.expect("failed to run fnec");

let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"a deck with no FR must still solve when --sweep-config supplies the \
frequencies: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
freq_values_mhz(&stdout),
vec![14.0, 14.2],
"both configured points must be solved: {stdout}"
);
}

/// The other half: with neither source, the run is refused rather than silently
/// succeeding.
///
/// It used to exit **0 having written zero bytes to stdout AND stderr** — a
/// silent success, indistinguishable from a run that worked — while the GUI and
/// `fnec_py` refused the same deck (FND-070). Both output formats are checked
/// because the `[]` that JSON mode prints for a *solved* deck with no priceable
/// feedpoint must not be reused for a deck that was never solved at all.
#[test]
fn a_deck_with_no_frequency_at_all_is_refused_in_both_output_formats() {
const NO_FR_DECK: &str = "GW 1 51 0 0 -5.282 0 0 5.282 0.001\nGE\nEX 0 1 26 0 1.0 0.0\nEN\n";
let deck = write_temp("no-freq-deck", NO_FR_DECK);

for format in ["text", "json"] {
let output = Command::new(env!("CARGO_BIN_EXE_fnec"))
.args(["--output-format", format])
.arg(&deck)
.output()
.expect("failed to run fnec");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"a deck with no frequency must not report success (--output-format \
{format}): {stderr}"
);
assert!(
output.stdout.is_empty(),
"the refusal must not also emit a report (--output-format {format}): \
{} byte(s)",
output.stdout.len()
);
assert!(
stderr.contains("no frequency to solve at"),
"the refusal must name its reason (--output-format {format}): {stderr}"
);
assert!(
stderr.contains("--sweep-config"),
"the CLI's remedy must name the second frequency source \
(--output-format {format}): {stderr}"
);
}
}
20 changes: 18 additions & 2 deletions apps/nec-gui/src/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,15 @@ pub fn solve_deck_str(deck_text: &str, solver: SolverKind) -> Result<SolveResult
let freq_hz = nec_solver::frequencies_hz(deck)
.first()
.copied()
.ok_or_else(|| "deck has no FR card".to_string())?;
.ok_or_else(|| {
// One sentence for all four frontends (FND-070). No `--sweep-config`
// remedy here: this path takes its frequency from the deck only.
nec_solver::validate::no_frequency_error(
&nec_solver::frequencies_hz(deck),
"Add an `FR` card to the deck.",
)
.unwrap_or_else(|| "deck has no FR card".to_string())
})?;

// --- validation (before any solve) -----------------------------------
let warnings = validate_deck(deck, &segs, &ground, freq_hz, &parsed.warnings, solver)?;
Expand Down Expand Up @@ -1052,7 +1060,15 @@ fn solve_for_currents(deck_text: &str, solver: SolverKind) -> Result<SolvedDeck,
let freq_hz = nec_solver::frequencies_hz(deck)
.first()
.copied()
.ok_or_else(|| "deck has no FR card".to_string())?;
.ok_or_else(|| {
// One sentence for all four frontends (FND-070). No `--sweep-config`
// remedy here: this path takes its frequency from the deck only.
nec_solver::validate::no_frequency_error(
&nec_solver::frequencies_hz(deck),
"Add an `FR` card to the deck.",
)
.unwrap_or_else(|| "deck has no FR card".to_string())
})?;

let mut z_mat = hallen_z_matrix(deck, &segs, freq_hz, &ground, solver);

Expand Down
39 changes: 39 additions & 0 deletions apps/nec-gui/tests/gui_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3087,3 +3087,42 @@ fn a_driven_deck_and_a_receive_deck_both_still_solve() {
"receive deck produced no induced current — the plane-wave capability regressed"
);
}

/// A deck with no `FR` card is refused by both GUI solve seams, with the shared
/// sentence.
///
/// The GUI already refused it — that is not what changed. What changed is that
/// all four frontends now say the same thing: the CLI used to exit 0 in silence
/// on this deck, `fnec_py`'s `sweep_deck_str` returned `[]`, and the three that
/// did refuse used three different wordings (FND-070).
///
/// Both seams, because `solve_deck_str` and `solve_for_currents` read the
/// frequency independently — a fix wired into one is the FND-038 shape.
#[test]
fn a_deck_with_no_frequency_is_refused_by_both_gui_seams() {
const NO_FR: &str =
"CM no FR card\nCE\nGW 1 21 0 0 -5.282 0 0 5.282 0.001\nGE 0\nEX 0 1 11 0 1.0 0.0\nEN\n";
use nec_gui::solve::SolverKind;

for (name, err) in [
(
"impedance",
nec_gui::solve::solve_deck_str(NO_FR, SolverKind::Hallen).err(),
),
(
"currents",
nec_gui::solve::load_currents_str(NO_FR, SolverKind::Hallen).err(),
),
] {
let msg = err.unwrap_or_else(|| panic!("{name}: a deck with no frequency must be refused"));
assert!(
msg.contains("no frequency to solve at"),
"{name}: must use the shared sentence, got: {msg}"
);
// The GUI has no `--sweep-config`, so it must not advertise one.
assert!(
!msg.contains("--sweep-config"),
"{name}: the GUI must not offer a remedy it does not have: {msg}"
);
}
}
23 changes: 17 additions & 6 deletions bindings/fnec_py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,14 @@ fn solve_deck_str(py: Python<'_>, deck: &str, solver: &str) -> PyResult<PyObject
let result = parse(deck)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("parse error: {e}")))?;
let freqs = frequencies_from_deck(&result.deck);
let freq_hz = freqs
.first()
.copied()
.ok_or_else(|| pyo3::exceptions::PyRuntimeError::new_err("deck has no FR card"))?;
let freq_hz = freqs.first().copied().ok_or_else(|| {
// The shared sentence (FND-070). No `--sweep-config` remedy: these
// bindings take their frequencies from the deck only.
pyo3::exceptions::PyRuntimeError::new_err(
nec_solver::validate::no_frequency_error(&freqs, "Add an `FR` card to the deck.")
.unwrap_or_else(|| "deck has no FR card".to_string()),
)
})?;
let (rec, mut warnings) = solve_at_freq(&result.deck, freq_hz, solver)
.map_err(pyo3::exceptions::PyRuntimeError::new_err)?;
let mut seen = Vec::new();
Expand Down Expand Up @@ -313,8 +317,15 @@ fn sweep_deck_str(py: Python<'_>, deck: &str, solver: &str) -> PyResult<PyObject
let result = parse(deck)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("parse error: {e}")))?;
let freqs = frequencies_from_deck(&result.deck);
if freqs.is_empty() {
return Ok(pyo3::types::PyList::empty(py).into());
// This returned an empty list, at success, for a deck with no `FR` — while
// `solve_deck_str` two functions up refused the same deck. So this module
// disagreed with itself, and the sweep half returned the null-standing-for-an
// -error shape that `docs/json-output-schema.md` now tells consumers not to
// read that way (FND-070). Both raise now, with one sentence.
if let Some(err) =
nec_solver::validate::no_frequency_error(&freqs, "Add an `FR` card to the deck.")
{
return Err(pyo3::exceptions::PyRuntimeError::new_err(err));
}

let mut seen = Vec::new();
Expand Down
37 changes: 37 additions & 0 deletions bindings/fnec_py/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,40 @@ def test_the_sweep_entry_point_refuses_it_too():
"""
with pytest.raises(RuntimeError, match="no EX card"):
fnec_py.sweep_deck_str(NO_EX)


NO_FR = """CM no FR card
CE
GW 1 21 0 0 -5.282 0 0 5.282 0.001
GE 0
EX 0 1 11 0 1.0 0.0
EN
"""


def test_both_entry_points_refuse_a_deck_with_no_frequency():
"""`sweep_deck_str` returned `[]` here while `solve_deck_str` raised.

Two functions in one module disagreeing about the same deck, and the sweep
half returning the empty-list-standing-for-an-error shape that
`docs/json-output-schema.md` now tells consumers not to read that way
(FND-070). Both raise now, with the sentence all four frontends share.

Parametrised over both entry points rather than written twice: the defect
was precisely that one of them was fixed and the other was not.
"""
for name, fn in [
("solve_deck_str", fnec_py.solve_deck_str),
("sweep_deck_str", fnec_py.sweep_deck_str),
]:
with pytest.raises(RuntimeError, match="no frequency to solve at") as exc:
fn(NO_FR)
# The bindings have no --sweep-config, so they must not advertise one.
assert "--sweep-config" not in str(exc.value), name


def test_a_deck_with_an_fr_card_still_sweeps():
"""The control: the refusal must key on the missing frequency, nothing else."""
deck = NO_FR.replace("EN\n", "FR 0 2 0 0 14.0 0.1\nEN\n")
rows = fnec_py.sweep_deck_str(deck)
assert len(rows) == 2, rows
39 changes: 39 additions & 0 deletions crates/nec_solver/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,45 @@ pub fn undriven_deck_error(deck: &NecDeck) -> Option<String> {
)
}

/// A solve with no frequencies to run, if that is the case.
///
/// **Typed on the resolved list, not on the deck**, and that is the whole point.
/// A frequency does not have to come from an `FR` card: the CLI takes one from
/// `--sweep-config`, the GUI's sweep tab from its own range widgets, and the
/// worker from the wire. A deck-typed predicate would refuse all three — so this
/// cannot live in [`pre_solve_error`], which sees only the deck, and it is why
/// [`frequency_error`] (which validates the values of an `FR` card that exists)
/// does not cover the case where none does.
///
/// Sharing only the *sentence* would have left the predicate written out at every
/// call site, where the next one is one missing `is_empty()` away from the defect
/// this closes. Sharing the predicate means deleting this check fails every
/// frontend at once.
///
/// `remedy` is the caller's, because the honest advice differs: the CLI has a
/// second way to supply frequencies and the other frontends do not, so only the
/// CLI may mention `--sweep-config`.
///
/// The defect it closes (FND-070): `fnec deck.nec` on a deck with no `FR` exited
/// **0 with zero bytes on stdout and stderr** — a silent success — while the GUI
/// and `fnec_py`'s `solve_deck_str` refused the same deck, and `fnec_py`'s
/// `sweep_deck_str` returned `[]`.
///
/// Deliberately divergent from nec2c, which defaults an `FR`-less deck to
/// 299.8 MHz (λ = 1 m) and answers it: measured on a 10.5 m dipole it reports
/// 133.18 + j280.36 Ω, pricing the wire as 10.5 λ. That is a plausible number for
/// a deck the user did not write.
pub fn no_frequency_error(freqs_hz: &[f64], remedy: &str) -> Option<String> {
if !freqs_hz.is_empty() {
return None;
}
Some(format!(
"FR: this deck has no frequency to solve at, so there is nothing to \
compute — an antenna's impedance and pattern are properties at a \
frequency, not of the geometry alone. {remedy}"
))
}

/// Every reason a deck must not be solved at all, geometry or otherwise.
///
/// This is the gate a frontend calls before solving; [`geometry_error`] is one
Expand Down
29 changes: 29 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ from 0.13.0 and earlier predate the Keep a Changelog headings and are left as wr

## [Unreleased]

### Changed

- **A deck with no frequency at all is refused instead of silently succeeding.**
`fnec deck.nec` on a deck with no `FR` card exited **0 having written zero
bytes to stdout *and* stderr** — a silent success, indistinguishable from a run
that worked — while the GUI and `fnec_py`'s `solve_deck_str` refused the same
deck and `fnec_py`'s `sweep_deck_str` returned `[]` (FND-070). Four frontends,
three wordings and one silence; now one sentence, via
`validate::no_frequency_error`.

The check is typed on the **resolved** frequency list, not on the deck, which
is what keeps `--sweep-config` working: that flag *supplies* frequencies for a
deck that has no `FR` card, so a deck-typed check in `pre_solve_error` — the
gate every frontend already calls — would have refused a working, documented
capability. That capability was gated by no test until now.

BREAKING for anyone scripting `fnec` on a frequency-less deck and reading exit
0. `docs/json-output-schema.md`'s claim that such a deck yields `[]` was never
true (it yielded zero bytes, so `json.loads` raised) and is now withdrawn
rather than honoured — FND-084's proposed one-line fix, "emit `[]` before the
early return", was **not** taken: it applies only to JSON mode and would have
spent `[]`, which already means *solved, with no feedpoint to price*, on a deck
that was never solved at all.

Deliberately divergent from nec2c, which defaults an `FR`-less deck to
299.8 MHz (λ = 1 m) and answers it — measured on a 10.5 m dipole it reports
133.18 + j280.36 Ω, pricing the wire as 10.5 λ. A plausible number for a deck
the user did not write.

## [0.18.0] — 2026-09-08 — Nothing drives it, so there is no solve

Eighteen changes since v0.17.0, in three clusters. **Remediation of the
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Compatibility profile note:
| `--bench` | flag | off | Enable benchmark instrumentation plumbing (also used by the GPU benchmark timing gates) |
| `--bench-format` | `human` \| `csv` \| `json` | `human` | Emit machine-readable benchmark records to stderr as `bench_csv:` or `bench_json:` lines while keeping the normal human-readable report on stdout |
| `--output-format` | `text` \| `json` | `text` | Report format on stdout. `json` writes one JSON record per solved frequency point instead of the text report; diagnostics stay on stderr either way. Schema: `docs/json-output-schema.md` |
| `--sweep-config` | `<file.toml>` | — | Load a TOML frequency-sweep spec (range or explicit list); overrides the `FR` card frequency list for a batch solve. See `examples/sweep-spec.toml`. |
| `--sweep-config` | `<file.toml>` | — | Load a TOML frequency-sweep spec (range or explicit list). Replaces the `FR` card's frequency list for a batch solve — and **supplies** one when the deck has no `FR` card at all, which is the only way to solve such a deck (without it the run is refused). See `examples/sweep-spec.toml`. |
| `--vars` | `<file.toml\|file.json>` | — | Load a flat key→value map and substitute `$VAR` tokens in the deck before parsing. TOML (any extension except `.json`) and JSON flat-object files are both accepted. An undefined token causes a non-zero exit with a diagnostic. |
| `--loads-config` | `<file.toml>` | — | Load fnec-specific extended loads (Laplace-domain `Z(s) = N(s)/D(s)`) from a TOML file and stamp them on the solve alongside any `LD` cards. Hallén/pulse paths only — rejected with `--solver mpie`. See **Laplace-domain loads** below. |
| `--hosts` | `<hosts.toml>` | — | Distribute the frequency points of a sweep across SSH worker nodes listed in a TOML file (`[[worker]]` entries with `hostname` / `ssh_user` / optional `binary_path`). There is no local fallback: a missing file, no `[[worker]]` entries, or no reachable worker exits **1** with a diagnostic. See `docs/worker-deployment.md` |
Expand Down
Loading
Loading