PyOpenMagnetics 1.7.31__tar.gz → 1.7.33__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/CMakeLists.txt +1 -1
  2. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/PKG-INFO +1 -1
  3. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/PyOpenMagnetics.pyi +2 -0
  4. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/pyproject.toml +1 -1
  5. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/advisers.cpp +19 -0
  6. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/database.cpp +55 -3
  7. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_magnetics_catalogue_load.py +35 -0
  8. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/.github/workflows/ci.yml +0 -0
  9. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/.github/workflows/publish.yml +0 -0
  10. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/.gitignore +0 -0
  11. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/AGENTS.md +0 -0
  12. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/LICENSE +0 -0
  13. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/README.md +0 -0
  14. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/_packaging/__init__.py +0 -0
  15. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/_packaging/_build_info.py.in +0 -0
  16. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/api/MAS.py +0 -0
  17. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/api/mas_db_reader.py +0 -0
  18. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/api/validation.py +0 -0
  19. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/clear_cibuildwheel_cache.sh +0 -0
  20. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/docs/compatibility.md +0 -0
  21. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/docs/errors.md +0 -0
  22. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/docs/performance.md +0 -0
  23. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/README.md +0 -0
  24. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/buck_inductor.py +0 -0
  25. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/complete_simulation_example.py +0 -0
  26. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/converter_design_example.py +0 -0
  27. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/debug_bobbin.py +0 -0
  28. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/debug_coil.py +0 -0
  29. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/debug_core.py +0 -0
  30. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/debug_plotting.py +0 -0
  31. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/flyback_220v_12v_1a.py +0 -0
  32. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/flyback_220v_12v_2a_complete.py +0 -0
  33. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/flyback_bh_curve.png +0 -0
  34. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/flyback_core.png +0 -0
  35. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/flyback_design.py +0 -0
  36. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/flyback_summary.png +0 -0
  37. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/flyback_waveforms.png +0 -0
  38. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/list_plot_funcs.py +0 -0
  39. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/plot_flyback_design.py +0 -0
  40. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/plot_flyback_pyom.py +0 -0
  41. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/test_field_calc.py +0 -0
  42. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/examples/test_field_plot.py +0 -0
  43. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/force_fresh_build.sh +0 -0
  44. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/llms.txt +0 -0
  45. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/notebooks/01_getting_started.ipynb +0 -0
  46. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/notebooks/02_buck_inductor.ipynb +0 -0
  47. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/notebooks/03_core_losses.ipynb +0 -0
  48. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/notebooks/README.md +0 -0
  49. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/requirements.txt +0 -0
  50. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/advisers.h +0 -0
  51. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/bobbin.cpp +0 -0
  52. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/bobbin.h +0 -0
  53. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/cmc.cpp +0 -0
  54. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/cmc.h +0 -0
  55. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/common.h +0 -0
  56. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/converter.cpp +0 -0
  57. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/converter.h +0 -0
  58. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/core.cpp +0 -0
  59. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/core.h +0 -0
  60. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/crossref.cpp +0 -0
  61. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/crossref.h +0 -0
  62. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/database.h +0 -0
  63. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/logging.cpp +0 -0
  64. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/logging.h +0 -0
  65. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/losses.cpp +0 -0
  66. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/losses.h +0 -0
  67. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/module.cpp +0 -0
  68. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/plotting.cpp +0 -0
  69. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/plotting.h +0 -0
  70. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/settings.cpp +0 -0
  71. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/settings.h +0 -0
  72. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/simulation.cpp +0 -0
  73. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/simulation.h +0 -0
  74. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/utils.cpp +0 -0
  75. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/utils.h +0 -0
  76. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/winding.cpp +0 -0
  77. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/winding.h +0 -0
  78. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/wire.cpp +0 -0
  79. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/src/wire.h +0 -0
  80. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/test.py +0 -0
  81. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/__init__.py +0 -0
  82. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/conftest.py +0 -0
  83. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_converter_endpoints.py +0 -0
  84. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_core.py +0 -0
  85. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_core_adviser.py +0 -0
  86. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_datasheet_only_catalogue.py +0 -0
  87. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_examples_integration.py +0 -0
  88. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_inputs.py +0 -0
  89. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_logging.py +0 -0
  90. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_losses_not_evaluable_catalogue.py +0 -0
  91. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_magnetic_adviser.py +0 -0
  92. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_mas_schema_walk.py +0 -0
  93. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_material_validator.py +0 -0
  94. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_plotting.py +0 -0
  95. {pyopenmagnetics-1.7.31 → pyopenmagnetics-1.7.33}/tests/test_winding.py +0 -0
@@ -142,7 +142,7 @@ if(NOT LOCAL_MKF_MAS)
142
142
  set(MKF_GIT_TAG "main"
143
143
  CACHE STRING "MKF git ref for the vendored PyOM build")
144
144
  # Force fresh clone by using a unique timestamp - update this when the pin changes
145
- set(MKF_FORCE_REFRESH "2026-10-02-mkf-7b80390f")
145
+ set(MKF_FORCE_REFRESH "2026-10-04-mkf-508edb8b")
146
146
  # Tell MKF to disable matplotplusplus and use SVG-based Painter instead
147
147
  set(INCLUDE_PYMKF ON CACHE BOOL "Build Python interface" FORCE)
148
148
  # GIT_SUBMODULES_RECURSE pulls in CAS/PEAS (added 2026-04 alongside the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: PyOpenMagnetics
3
- Version: 1.7.31
3
+ Version: 1.7.33
4
4
  Summary: Python wrapper for OpenMagnetics
5
5
  Author-Email: Alfonso Martinez <Alfonso_VII@hotmail.com>
6
6
  Classifier: Development Status :: 4 - Beta
@@ -679,6 +679,8 @@ def calculate_advised_magnetics_from_catalog(
679
679
  - "lossesNotEvaluable" (only on such parts): why the part's core losses could not be
680
680
  evaluated (its core material has no core-loss model); it is ranked without any
681
681
  loss-based filter and returned without simulated outputs.
682
+ And a "failedCandidates" array next to "data": one {"reference", "error"} per candidate
683
+ whose evaluation raised (a filter or the final simulation threw); it is not ranked.
682
684
  """
683
685
  ...
684
686
 
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "PyOpenMagnetics"
7
- version = "1.7.31"
7
+ version = "1.7.33"
8
8
  requires-python = ">=3.8"
9
9
  authors = [
10
10
  { name="Alfonso Martinez", email="Alfonso_VII@hotmail.com" },
@@ -179,6 +179,17 @@ json calculate_advised_magnetics_fast(json inputsJson, int maximumNumberResults,
179
179
  return results;
180
180
  }
181
181
 
182
+ // The candidates the adviser excluded because evaluating them raised (a filter or the final
183
+ // simulation threw), each with the error. They are not ranked; this says which and why, instead of
184
+ // the result silently holding fewer parts.
185
+ static json failed_candidates_to_json(const OpenMagnetics::MagneticAdviser& magneticAdviser) {
186
+ json failedCandidates = json::array();
187
+ for (const auto& [reference, error] : magneticAdviser.get_failed_candidates()) {
188
+ failedCandidates.push_back({{"reference", reference}, {"error", error}});
189
+ }
190
+ return failedCandidates;
191
+ }
192
+
182
193
  json calculate_advised_magnetics_from_catalog(json inputsJson, json catalogJson, int maximumNumberResults) {
183
194
  OpenMagnetics::settings.set_coil_delimit_and_compact(true);
184
195
  OpenMagnetics::Inputs inputs(inputsJson);
@@ -224,6 +235,7 @@ json calculate_advised_magnetics_from_catalog(json inputsJson, json catalogJson,
224
235
  sort(results["data"].begin(), results["data"].end(), [](json& b1, json& b2) {
225
236
  return b1["scoring"] > b2["scoring"];
226
237
  });
238
+ results["failedCandidates"] = failed_candidates_to_json(magneticAdviser);
227
239
 
228
240
  return results;
229
241
  }
@@ -282,6 +294,7 @@ json calculate_advised_magnetics_from_cache(json inputsJson, json filterFlowJson
282
294
  sort(results["data"].begin(), results["data"].end(), [](json& b1, json& b2) {
283
295
  return b1["scoring"] > b2["scoring"];
284
296
  });
297
+ results["failedCandidates"] = failed_candidates_to_json(magneticAdviser);
285
298
 
286
299
  return results;
287
300
  }
@@ -506,6 +519,9 @@ void register_adviser_bindings(py::module& m) {
506
519
  - "lossesNotEvaluable" (only on such parts): why this part's core losses could
507
520
  not be evaluated -- its core material carries no core-loss model. The part is
508
521
  ranked without any loss-based filter and returned without simulated outputs.
522
+ And a "failedCandidates" array next to "data": one {"reference", "error"} per
523
+ candidate whose evaluation raised (a filter or the final simulation threw). Such a
524
+ candidate is not ranked; this names it and the error instead of dropping it silently.
509
525
 
510
526
  Example:
511
527
  >>> inputs = PyMKF.process_inputs(raw_inputs)
@@ -550,6 +566,9 @@ void register_adviser_bindings(py::module& m) {
550
566
  not be evaluated -- its core material carries no core-loss model. The part is
551
567
  ranked on the filters that apply to it, with no loss-based filter scored, and
552
568
  is returned without simulated outputs.
569
+ And a "failedCandidates" array next to "data": one {"reference", "error"} per
570
+ candidate whose evaluation raised (a filter or the final simulation threw). Such a
571
+ candidate is not ranked; this names it and the error instead of dropping it silently.
553
572
 
554
573
  Note:
555
574
  Cache must be populated before calling this function.
@@ -234,12 +234,20 @@ struct RejectedRecord {
234
234
  // Read the whole stream before touching the cache, so a strict load is all-or-nothing
235
235
  // and a tolerant one is exact about what it dropped. Every failure is reported as
236
236
  // "<source>:<line> (part 'X'): <what actually went wrong>".
237
+ //
238
+ // Two records under one manufacturerInfo.reference are an error too. The cache is keyed
239
+ // by the reference, so the second used to replace the first without a word: a catalogue
240
+ // of 2 came back as 1 (nine WE Midcom parts whose OEM P/N cell read "#N/A" collapsed to
241
+ // one). Nothing here can tell which of the two is that part's real record, so a strict
242
+ // load refuses the file and a tolerant one rejects every record sharing the reference.
237
243
  std::vector<std::pair<std::string, OpenMagnetics::Magnetic>> stage_magnetics(std::istream& in,
238
244
  const std::string& source,
239
245
  bool expand,
240
246
  bool skipInvalid,
241
247
  std::vector<RejectedRecord>& rejected) {
242
248
  std::vector<std::pair<std::string, OpenMagnetics::Magnetic>> staged;
249
+ std::vector<size_t> stagedLineNumbers;
250
+ std::map<std::string, size_t> firstLineOfReference;
243
251
  std::string line;
244
252
  size_t lineNumber = 0;
245
253
  while (getline(in, line)) {
@@ -247,8 +255,9 @@ std::vector<std::pair<std::string, OpenMagnetics::Magnetic>> stage_magnetics(std
247
255
  if (line.find_first_not_of(" \t\r\n") == std::string::npos) {
248
256
  continue;
249
257
  }
258
+ std::pair<std::string, OpenMagnetics::Magnetic> record;
250
259
  try {
251
- staged.push_back(read_magnetic_line(line, expand));
260
+ record = read_magnetic_line(line, expand);
252
261
  }
253
262
  catch (const std::exception& e) {
254
263
  std::string reference;
@@ -262,7 +271,46 @@ std::vector<std::pair<std::string, OpenMagnetics::Magnetic>> stage_magnetics(std
262
271
  throw std::runtime_error(locate(source, lineNumber, reference) + ": " + e.what());
263
272
  }
264
273
  rejected.push_back({lineNumber, reference, e.what()});
274
+ continue;
275
+ }
276
+ auto [first, isNew] = firstLineOfReference.emplace(record.first, lineNumber);
277
+ if (!isNew && !skipInvalid) {
278
+ throw std::runtime_error(locate(source, lineNumber, record.first) + ": manufacturerInfo.reference '" +
279
+ record.first + "' is also the reference of line " +
280
+ std::to_string(first->second) + ", and the magnetics cache is keyed by it: "
281
+ "one of the two records would silently replace the other");
282
+ }
283
+ staged.push_back(std::move(record));
284
+ stagedLineNumbers.push_back(lineNumber);
285
+ }
286
+
287
+ if (skipInvalid) {
288
+ std::map<std::string, std::vector<size_t>> linesOfReference;
289
+ for (size_t index = 0; index < staged.size(); ++index) {
290
+ linesOfReference[staged[index].first].push_back(stagedLineNumbers[index]);
291
+ }
292
+ std::vector<std::pair<std::string, OpenMagnetics::Magnetic>> unique;
293
+ for (size_t index = 0; index < staged.size(); ++index) {
294
+ const auto& lines = linesOfReference[staged[index].first];
295
+ if (lines.size() == 1) {
296
+ unique.push_back(std::move(staged[index]));
297
+ continue;
298
+ }
299
+ std::string others;
300
+ for (auto otherLine : lines) {
301
+ if (otherLine != stagedLineNumbers[index]) {
302
+ others += (others.empty() ? "" : ", ") + std::to_string(otherLine);
303
+ }
304
+ }
305
+ rejected.push_back({stagedLineNumbers[index], staged[index].first,
306
+ "manufacturerInfo.reference '" + staged[index].first +
307
+ "' is also the reference of line " + others +
308
+ ", and the magnetics cache is keyed by it: every record under it is rejected,"
309
+ " since nothing tells which one is the part's real record"});
265
310
  }
311
+ staged = std::move(unique);
312
+ std::stable_sort(rejected.begin(), rejected.end(),
313
+ [](const RejectedRecord& a, const RejectedRecord& b) { return a.lineNumber < b.lineNumber; });
266
314
  }
267
315
  return staged;
268
316
  }
@@ -419,7 +467,9 @@ void register_database_bindings(py::module& m) {
419
467
  (ABT #823). The first record that cannot be loaded raises EngineError naming
420
468
  the file, the line number, the part reference and the underlying reason.
421
469
  Blank lines are skipped. Records are keyed by manufacturerInfo.reference, so
422
- a record without one is an error rather than an anonymous optional access.
470
+ a record without one is an error rather than an anonymous optional access, and
471
+ two records sharing one raise EngineError naming the reference and both lines
472
+ instead of the second silently replacing the first.
423
473
 
424
474
  Use load_magnetics_from_file_report() instead when a partial catalogue is
425
475
  acceptable and you want the list of rejected records.
@@ -440,7 +490,9 @@ void register_database_bindings(py::module& m) {
440
490
  The tolerant counterpart of load_magnetics_from_file (ABT #823): a bad record
441
491
  no longer aborts the batch, and the caller is told exactly which records were
442
492
  dropped and why instead of silently running on a truncated catalogue. A file
443
- that cannot be opened is still an error.
493
+ that cannot be opened is still an error. Records sharing a
494
+ manufacturerInfo.reference are all rejected, each naming the other lines,
495
+ since nothing tells which of them is the part's real record.
444
496
 
445
497
  Args:
446
498
  path: Path to the NDJSON file.
@@ -140,6 +140,27 @@ class TestStrictLoad:
140
140
  assert "manufacturerInfo" in message, message
141
141
  assert "anonymous.ndjson:1" in message, message
142
142
 
143
+ def test_two_records_sharing_a_reference_raise_and_name_it(self, tmp_path):
144
+ # The cache is keyed by manufacturerInfo.reference: the second record used to
145
+ # replace the first without a word (2 offered, "1" returned). Nine WE Midcom
146
+ # parts whose OEM P/N cell read "#N/A" collapsed to one that way.
147
+ first = magnetic("#N/A")
148
+ second = magnetic("#N/A")
149
+ second["coil"]["functionalDescription"][0]["numberTurns"] = 40
150
+ path = write_ndjson(tmp_path / "duplicates.ndjson", [magnetic("K1"), first, second])
151
+
152
+ with pytest.raises(PyOpenMagnetics.EngineError) as error:
153
+ PyOpenMagnetics.load_magnetics_from_file(path, True)
154
+
155
+ message = str(error.value)
156
+ assert "duplicates.ndjson:3" in message, message
157
+ assert "'#N/A'" in message, message
158
+ assert "line 2" in message, message
159
+
160
+ # All or nothing: none of the file reached the cache.
161
+ good = write_ndjson(tmp_path / "good.ndjson", [magnetic("K9")])
162
+ assert PyOpenMagnetics.load_magnetics_from_file(good, True) == "1"
163
+
143
164
 
144
165
  class TestReportingLoad:
145
166
  """load_magnetics_from_file_report: keep the good records, name the dropped ones."""
@@ -167,6 +188,20 @@ class TestReportingLoad:
167
188
  assert report["loaded"] == 2
168
189
  assert report["rejected"] == []
169
190
 
191
+ def test_records_sharing_a_reference_are_all_rejected(self, tmp_path):
192
+ path = write_ndjson(
193
+ tmp_path / "duplicates.ndjson",
194
+ [magnetic("L1"), magnetic("#N/A"), magnetic("L3"), magnetic("#N/A")],
195
+ )
196
+
197
+ report = PyOpenMagnetics.load_magnetics_from_file_report(path, True)
198
+
199
+ assert report["loaded"] == 2
200
+ assert report["cacheSize"] == 2
201
+ assert [(r["line"], r["reference"]) for r in report["rejected"]] == [(2, "#N/A"), (4, "#N/A")]
202
+ assert "line 4" in report["rejected"][0]["reason"]
203
+ assert "line 2" in report["rejected"][1]["reason"]
204
+
170
205
  def test_missing_file_still_raises(self, tmp_path):
171
206
  with pytest.raises(PyOpenMagnetics.EngineError):
172
207
  PyOpenMagnetics.load_magnetics_from_file_report(str(tmp_path / "nope.ndjson"), True)