From a3f247b70c171d23fa431d8dae1afe31ae6aa1f7 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:16:01 +0200 Subject: [PATCH 01/35] Expect eumGetNextEqvUnit to report units while rc is non-zero The tests were strict xfails; they fail until the wrapper reads the return code the same way as the other eum wrappers. Co-Authored-By: Claude Opus 5.5 --- tests/test_eum.py | 51 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/tests/test_eum.py b/tests/test_eum.py index a2156b3..e751912 100644 --- a/tests/test_eum.py +++ b/tests/test_eum.py @@ -184,29 +184,56 @@ def test_converter(self): Assert.AreEqual(np.array([0.5, 1, 2, 3.280839895]), array, 1e-6) -EQV_UNIT_INVERTED = ( - "Suspected bug: eumGetNextEqvUnit treats a non-zero return code as failure, " - "the opposite of every other eum wrapper (eumGetNextUnit, eumGetItemUnitSeq, " - "...). Called directly, the native function returns 1 with meter as the first " - "unit equivalent to meter, and 0 with a null description after the last one. " - "Correct: (True, eumUmeter, 'meter') for the first unit and " - "(False, eumUUnitUndefined, '') after the last." -) - - -@pytest.mark.xfail(strict=True, reason=EQV_UNIT_INVERTED) def test_eumGetNextEqvUnit_first_unit(): assert (True, eumUnit.eumUmeter, "meter") == eumWrapper.eumGetNextEqvUnit( eumUnit.eumUmeter, eumUnit.eumUUnitUndefined ) -@pytest.mark.xfail(strict=True, reason=EQV_UNIT_INVERTED) def test_eumGetNextEqvUnit_after_last_unit(): assert (False, eumUnit.eumUUnitUndefined, "") == eumWrapper.eumGetNextEqvUnit( eumUnit.eumUmeter, eumUnit.eumUmileUS ) +def _all_units(): + units = [] + ok, unit, description = eumWrapper.eumGetNextUnit(eumUnit.eumUUnitUndefined) + while ok: + units.append((eumUnit(unit), description)) + ok, unit, description = eumWrapper.eumGetNextUnit(unit) + return units + + +# eumUnitsEqv and eumGetNextUnit are the oracle: every unit equivalent to the +# base, in the order eumGetNextUnit lists them +@pytest.mark.parametrize( + "baseUnit", + [ + eumUnit.eumUmeter, + eumUnit.eumUsec, + eumUnit.eumUm3PerSec, + eumUnit.eumUdegreeCelsius, + ], +) +def test_eumGetNextEqvUnit_lists_every_equivalent_unit(baseUnit): + expected = [ + (unit, description) + for unit, description in _all_units() + if eumWrapper.eumUnitsEqv(baseUnit, unit) + ] + + units = [] + ok, unit, description = eumWrapper.eumGetNextEqvUnit( + baseUnit, eumUnit.eumUUnitUndefined + ) + while ok and len(units) <= len(expected): + units.append((unit, description)) + ok, unit, description = eumWrapper.eumGetNextEqvUnit(baseUnit, unit) + + assert 1 < len(expected) + assert expected == units + + def test_GetItemTypeTag_unknown_description(): assert eumWrapper.GetItemTypeTag("No such item type") is None From 4d5153ce3b68085b36497e7bdb77d82b1ff6d089 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:16:20 +0200 Subject: [PATCH 02/35] Read eumGetNextEqvUnit's return code like the other eum wrappers The native function returns non-zero while there is a next unit and 0 after the last one. The wrapper treated non-zero as failure. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ mikecore/eum.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6840189..6a2af41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,5 +78,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#47](https://github.com/DHI/mikecore-python/issues/47)). - Reading an item's value type raises the DFS error when the native library reports one, instead of returning `DataValueType.Instantaneous`. +- `eumWrapper.eumGetNextEqvUnit` returns the units equivalent to the base unit. + It read the native return code the wrong way round, so it reported no unit + where there was one, and raised `ValueError` after the last one, where the + native description is null. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/eum.py b/mikecore/eum.py index 4e4cb8e..d96feef 100644 --- a/mikecore/eum.py +++ b/mikecore/eum.py @@ -1843,7 +1843,7 @@ def eumGetNextEqvUnit( rc = eumDLL.Wrapper.eumGetNextEqvUnit( baseunitKey, PrevunitKey, ctypes.byref(unitKey), ctypes.byref(lpUnitDesc) ) - if rc != 0: + if rc == 0: return False, eumUnit.eumUUnitUndefined, "" return True, eumUnit(unitKey.value), eumWrapper.DecodeString(lpUnitDesc) From 0d49b2dbc8d606ef7271cbd37cf6fa7a1f0e3df8 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:16:41 +0200 Subject: [PATCH 03/35] Expect DfsuBuilder to write the element and node ids it is given SetElementIds was a strict xfail: it never stores the ids. Int64 node ids, as np.array of Python ints gives on Linux, fail in CreateFile. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsu_builder.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 45d50f1..68aba96 100644 --- a/tests/test_dfsu_builder.py +++ b/tests/test_dfsu_builder.py @@ -172,20 +172,13 @@ def test_default_node_and_element_ids_count_from_one(tmp_path): dfsu.Close() -@pytest.mark.xfail( - strict=True, - reason=( - "Suspected bug: SetElementIds checks the length of the ids but never " - "stores them, so the file gets the default ids 1, 2, ... without warning. " - "SetNodeIds stores its ids. Correct: the ids passed are written." - ), -) -def test_element_ids_are_written(tmp_path): +@pytest.mark.parametrize("dtype", [np.int32, np.int64]) +def test_element_ids_are_written(tmp_path, dtype): filename = str(tmp_path / "element_ids.dfsu") builder = _builder() builder.SetNodes(X, Y, Z, CODE) builder.SetElements(ELEMENTS) - builder.SetElementIds(np.array([10, 20], dtype=np.int32)) + builder.SetElementIds(np.array([10, 20], dtype=dtype)) builder.CreateFile(filename).Close() dfsu = DfsuFile.Open(filename) @@ -195,6 +188,24 @@ def test_element_ids_are_written(tmp_path): assert_array_equal([10, 20], elementIds) +# np.array of Python ints is int64 on Linux, and the native library writes int32 ids +@pytest.mark.parametrize("dtype", [np.int32, np.int64]) +def test_node_ids_are_written(tmp_path, dtype): + filename = str(tmp_path / "node_ids.dfsu") + nodeIds = np.arange(len(X), dtype=dtype) + 100 + builder = _builder() + builder.SetNodes(X, Y, Z, CODE) + builder.SetElements(ELEMENTS) + builder.SetNodeIds(nodeIds) + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + readNodeIds = dfsu.NodeIds + dfsu.Close() + + assert_array_equal(nodeIds, readNodeIds) + + # Node numbers start at 1, so 0 is below the first node @pytest.mark.parametrize("nodeNumber", [0, len(X) + 1]) def test_validate_reports_node_number_outside_the_nodes(nodeNumber): From b169d7ad370da3499c812a65ad2d278ae735527b Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:16:56 +0200 Subject: [PATCH 04/35] Store DfsuBuilder element ids, and node and element ids as int32 SetElementIds checked the length of the ids but never kept them. Both setters now convert to int32, the type the file stores, so int64 ids no longer fail in CreateFile. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ mikecore/DfsuBuilder.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2af41..648c657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,5 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 It read the native return code the wrong way round, so it reported no unit where there was one, and raised `ValueError` after the last one, where the native description is null. +- `DfsuBuilder.SetElementIds` stores the ids, so the file has them instead of + the default 1, 2, .... `SetElementIds` and `SetNodeIds` accept int64 arrays + and lists and store int32 ids. Int64 node ids raised "Data type not + supported: int64" in `CreateFile`. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index e2a8820..bd815f5 100644 --- a/mikecore/DfsuBuilder.py +++ b/mikecore/DfsuBuilder.py @@ -226,6 +226,7 @@ def SetNodeIds(self, nodeIds): if nodeIds is None: self.__nodeIds = None return + nodeIds = np.asarray(nodeIds, dtype=np.int32) if self.__nodes is not None and self.__nodes.x.size != nodeIds.size: raise Exception( "Number of node id's does not match number of nodes", "nodeIds" @@ -270,12 +271,14 @@ def SetElements(self, connectivity): def SetElementIds(self, elementIds): """Set the element id's. Optional. If not set, default values are used (1,2,3,...)""" + elementIds = np.asarray(elementIds, dtype=np.int32) if (self.__connectivity is not None) and ( len(self.__connectivity) != elementIds.size ): raise Exception( "Number of element id's does not match number of elements", "elementIds" ) + self.__elementIds = elementIds def SetFromMeshFile(self, meshFile: MeshFile): """Set projection, nodes and elements from mesh file. From 5a63c43c9a6e99ecaeabf29e09dce69d1b44326f Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:17:08 +0200 Subject: [PATCH 05/35] Expect MeshBuilder to keep z as given test_MeshBuilder_keeps_z_precision was a strict xfail, and the Oresund rebuild test compared z only to float32 precision. Co-Authored-By: Claude Opus 5.5 --- tests/test_mesh.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 8a27f79..7e95b43 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from numpy.testing import assert_allclose, assert_array_equal +from numpy.testing import assert_array_equal from mikecore.DfsuBuilder import DfsuBuilder from mikecore.DfsuFile import DfsuFile, DfsuFileType @@ -216,8 +216,7 @@ def test_MeshBuilder_rebuilds_the_Oresund_mesh(tmp_path): z = mesh.Z assert originalZ is not None assert z is not None - # To float32 precision; exact z is test_MeshBuilder_keeps_z_precision - assert_allclose(originalZ, z, rtol=float(np.finfo(np.float32).eps)) + assert_array_equal(originalZ, z) assert_array_equal(original.Code, mesh.Code) assert_array_equal(original.ElementIds, mesh.ElementIds) assert_array_equal(original.ElementType, mesh.ElementType) @@ -226,15 +225,6 @@ def test_MeshBuilder_rebuilds_the_Oresund_mesh(tmp_path): assert_array_equal(expected, element) -@pytest.mark.xfail( - strict=True, - reason=( - "Suspected bug: MeshBuilder.SetNodes converts z to float32, so a mesh built " - "from double z values, such as one read with MeshFile.ReadMesh, is written " - "with z rounded to about 7 digits. Mesh files store z as doubles, and " - "MeshFile and SetNodes keep x and y as doubles. Correct: z is kept as given." - ), -) def test_MeshBuilder_keeps_z_precision(tmp_path): filename = str(tmp_path / "z.mesh") # ## 1 359862.97332797921 6206313.7132576201 -1.7859922534795478 1 From fdbc893dfd7743436d263299638fb99a50e6b205 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:17:30 +0200 Subject: [PATCH 06/35] Keep z as float64 in MeshBuilder.SetNodes Mesh files store z as doubles, and SetNodes already kept x and y as float64. DfsuBuilder's float32 z and MeshBuilder.Create(dfsuFile) are unchanged, since dfsu stores z as float32. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ mikecore/MeshBuilder.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 648c657..3775f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,5 +86,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the default 1, 2, .... `SetElementIds` and `SetNodeIds` accept int64 arrays and lists and store int32 ids. Int64 node ids raised "Data type not supported: int64" in `CreateFile`. +- `MeshBuilder.SetNodes` keeps z as doubles, as mesh files store it, instead of + rounding it to float32. Float32 z passed to `SetNodes`, such as + `DfsuFile.Z`, is now written with the double text of those float32 values + (`-1.7859922647476196` rather than `-1.7859923`). `MeshBuilder.Create(dfsuFile)` + is unchanged. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/MeshBuilder.py b/mikecore/MeshBuilder.py index 33bded5..642ccd5 100644 --- a/mikecore/MeshBuilder.py +++ b/mikecore/MeshBuilder.py @@ -54,7 +54,7 @@ def SetNodes(self, x, y, z, code): except Exception as err: raise TypeError("y must be array of float") from err try: - z = np.array(z, dtype=np.float32) + z = np.array(z, dtype=np.float64) except Exception as err: raise TypeError("z must be array of float") from err try: From 0cbcc7d7be9ebbd43eef26b08db1a971082e1783 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:17:40 +0200 Subject: [PATCH 07/35] Expect Dfs0ToAscii to stamp rows in the time axis unit The minutes test was a strict xfail: the second row of a 10 minute file is stamped 00:00:10. Co-Authored-By: Claude Opus 5.5 --- tests/test_miketools.py | 53 +++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/tests/test_miketools.py b/tests/test_miketools.py index 4417ccc..dc74317 100644 --- a/tests/test_miketools.py +++ b/tests/test_miketools.py @@ -67,27 +67,34 @@ def test_Dfs0ToAscii_writes_calendar_times(tmp_path): ] -@pytest.mark.xfail( - strict=True, - reason=( - "Suspected bug: Dfs0ToAscii adds itemData.Time to the start as seconds " - "(its own TODO says the time unit is not always seconds), but " - "ReadItemTimeStepNext returns times in the time axis unit. With a " - "10 minute step the second row is stamped 00:00:10. Correct: convert " - "with the time axis's ToSeconds, giving 00:10:00." - ), +# timedelta is the oracle for each time unit; a non-equidistant axis stores +# each timestep's own time +@pytest.mark.parametrize( + "timeUnit, timedeltaUnit", + [ + (eumUnit.eumUmillisec, "milliseconds"), + (eumUnit.eumUsec, "seconds"), + (eumUnit.eumUminute, "minutes"), + (eumUnit.eumUhour, "hours"), + (eumUnit.eumUday, "days"), + ], ) -def test_Dfs0ToAscii_writes_calendar_times_in_minutes(tmp_path): - dfs0FileName = str(tmp_path / "minutes.dfs0") +@pytest.mark.parametrize("equidistant", [True, False]) +def test_Dfs0ToAscii_writes_calendar_times_in_the_time_unit( + tmp_path, timeUnit, timedeltaUnit, equidistant +): + dfs0FileName = str(tmp_path / "times.dfs0") + start = datetime.datetime(2020, 1, 1) + times = [0, 10, 20] if equidistant else [0, 10, 25] factory = DfsFactory() builder = DfsBuilder.Create("title", "application", 1) builder.SetDataType(0) builder.SetGeographicalProjection(factory.CreateProjectionUndefined()) - builder.SetTemporalAxis( - factory.CreateTemporalEqCalendarAxis( - eumUnit.eumUminute, datetime.datetime(2020, 1, 1), 0, 10 - ) - ) + if equidistant: + timeAxis = factory.CreateTemporalEqCalendarAxis(timeUnit, start, 0, 10) + else: + timeAxis = factory.CreateTemporalNonEqCalendarAxis(timeUnit, start) + builder.SetTemporalAxis(timeAxis) item = builder.CreateDynamicItemBuilder() item.Set( "Value", @@ -99,17 +106,17 @@ def test_Dfs0ToAscii_writes_calendar_times_in_minutes(tmp_path): builder.AddDynamicItem(item.GetDynamicItemInfo()) builder.CreateFile(dfs0FileName) dfs0 = builder.GetFile() - for value in (1.0, 2.0): - dfs0.WriteItemTimeStepNext(0, np.array([value], dtype=np.float32)) + for time in times: + dfs0.WriteItemTimeStepNext(time, np.array([1.0], dtype=np.float32)) dfs0.Close() - txtFileName = str(tmp_path / "minutes.txt") + txtFileName = str(tmp_path / "times.txt") Dfs0ToAscii(dfs0FileName, txtFileName) - lines = _data_lines(txtFileName) - assert [["2020-01-01", "00:00:00.000"], ["2020-01-01", "00:10:00.000"]] == [ - line.split()[:2] for line in lines - ] + expected = [start + datetime.timedelta(**{timedeltaUnit: time}) for time in times] + assert [ + [t.strftime("%Y-%m-%d"), t.strftime("%H:%M:%S.%f")[:-3]] for t in expected + ] == [line.split()[:2] for line in _data_lines(txtFileName)] def test_Dfs0ToAscii_writes_relative_times(tmp_path): From 1b82f890dd64c22cccebd067a296586eaf7172c1 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:17:47 +0200 Subject: [PATCH 08/35] Convert Dfs0ToAscii item times with the time axis's ToSeconds ReadItemTimeStepNext returns times in the time axis unit; Dfs0ToAscii added them to the start as seconds. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ miketools/Dfs0ToAscii.py | 17 +++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3775f97..5844420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,5 +91,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `DfsuFile.Z`, is now written with the double text of those float32 values (`-1.7859922647476196` rather than `-1.7859923`). `MeshBuilder.Create(dfsuFile)` is unchanged. +- `miketools.Dfs0ToAscii` converts item times from the time axis unit to + seconds, so a file with a time unit other than seconds gets the right + timestamps. A 10 minute step was written as 10 seconds. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/miketools/Dfs0ToAscii.py b/miketools/Dfs0ToAscii.py index 53025d7..1f023b1 100644 --- a/miketools/Dfs0ToAscii.py +++ b/miketools/Dfs0ToAscii.py @@ -7,6 +7,10 @@ def Dfs0ToAscii(dfs0FileName, txtFileName): """Writes dfs0 data to text file in similar format as other MIKE Zero tools""" dfs = DfsFileFactory.DfsGenericOpen(dfs0FileName) + timeAxis = dfs.FileInfo.TimeAxis + if timeAxis is None: + dfs.Close() + raise Exception(f"{dfs0FileName} has no time axis") txt = open(txtFileName, "w") txt.write(" ") @@ -37,12 +41,12 @@ def Dfs0ToAscii(dfs0FileName, txtFileName): # Only set for calendar time axes; otherwise times are written as offsets. startDateTime = None if ( - dfs.FileInfo.TimeAxis.TimeAxisType == TimeAxisType.CalendarEquidistant - or dfs.FileInfo.TimeAxis.TimeAxisType == TimeAxisType.CalendarNonEquidistant + timeAxis.TimeAxisType == TimeAxisType.CalendarEquidistant + or timeAxis.TimeAxisType == TimeAxisType.CalendarNonEquidistant ): - startDateTime = dfs.FileInfo.TimeAxis.StartDateTime + startDateTime = timeAxis.StartDateTime - for i in range(dfs.FileInfo.TimeAxis.NumberOfTimeSteps): + for i in range(timeAxis.NumberOfTimeSteps): for j in range(len(dfs.ItemInfo)): itemData = dfs.ReadItemTimeStepNext() if itemData is None: @@ -51,8 +55,9 @@ def Dfs0ToAscii(dfs0FileName, txtFileName): ) if j == 0: if startDateTime is not None: - # TODO: Time unit is not always seconds - itemTime = startDateTime + timedelta(seconds=itemData.Time) + itemTime = startDateTime + timedelta( + seconds=timeAxis.ToSeconds(itemData.Time) + ) # Depending on the format to write to the file: # txt.write(itemTime.strftime("%Y-%m-%d %H:%M:%S")); # Seconds accuracy txt.write( From 3a52a7466b01ad9c344dc7fa027b01e8840608b5 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:18:14 +0200 Subject: [PATCH 09/35] Expect ReadStaticItem to reject item numbers below 1 test_read_static_item_zero was a strict xfail; it now also covers -1. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfs_basic.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/tests/test_dfs_basic.py b/tests/test_dfs_basic.py index fb70f39..2c2ae68 100644 --- a/tests/test_dfs_basic.py +++ b/tests/test_dfs_basic.py @@ -126,26 +126,37 @@ def test_read_static_item_past_the_last_item(): assert item is None -@pytest.mark.xfail( - strict=True, - reason=( - "Suspected bug: ReadStaticItem does not check the item number and ignores " - "the return code of dfsFindItemStatic, so ReadStaticItem(0) returns the " - "first static item ('Node id') with ItemNumber 0. Static item numbers start " - "at 1. Correct: reject numbers below 1 with a ValueError, as " - "__DynamicItemInfoReadAndCreate does for dynamic items." - ), -) -def test_read_static_item_zero(): +@pytest.mark.parametrize("staticItemNo", [0, -1]) +def test_read_static_item_below_one(staticItemNo): dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu") try: with pytest.raises(ValueError): - dfs.ReadStaticItem(0) + dfs.ReadStaticItem(staticItemNo) finally: dfs.Close() +# ReadStaticItemNext is the oracle for which item each number from 1 up gives +def test_read_static_item_reads_every_item_by_number(): + dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu") + expected = [] + while (item := dfs.ReadStaticItemNext()) is not None: + expected.append(item.Name) + + # Backwards, so each read has to find its item rather than take the next + names = {} + for n in range(len(expected), 0, -1): + item = dfs.ReadStaticItem(n) + assert item is not None + assert n == item.ItemNumber + names[n] = item.Name + dfs.Close() + + assert 9 == len(expected) + assert expected == [names[n] for n in range(1, len(expected) + 1)] + + def test_item_quantity_descriptions(): dfs = DfsFileFactory.DfsGenericOpen("testdata/TemporalEqCal.dfs0") quantities = [item.Quantity for item in dfs.ItemInfo] From 6340f38c27f0725b01c34121e1df7159747590eb Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:18:23 +0200 Subject: [PATCH 10/35] Reject static item numbers below 1 in ReadStaticItem dfsFindItemStatic returns 0 for 0 and negative numbers and positions on the first static item. Its return code is left unchecked: it is 0 one past the last item and 1002 further out, and both already read as None. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ mikecore/DfsFile.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5844420..9a682fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,5 +94,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `miketools.Dfs0ToAscii` converts item times from the time axis unit to seconds, so a file with a time unit other than seconds gets the right timestamps. A 10 minute step was written as 10 seconds. +- `DfsFile.ReadStaticItem` raises `ValueError` for an item number below 1. It + returned the first static item, numbered 0 or below. A number past the last + item still returns `None`. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/DfsFile.py b/mikecore/DfsFile.py index 1e987e3..e6d8864 100644 --- a/mikecore/DfsFile.py +++ b/mikecore/DfsFile.py @@ -945,10 +945,15 @@ def ReadStaticItem(self, staticItemNo): :param staticItemNo: Number of static item in the file to read. First static item has number 1 :returns: The static item number , null if there are not that many static items. + :raises ValueError: If is below 1. """ self.__CheckIfOpen() if self.fpState == DfsFilePointerState.CreatingItems: raise Exception("Can not read static items when file is being created.") + if staticItemNo < 1: + raise ValueError( + f"Static item number must be 1 or more, got {staticItemNo}" + ) if ( self.fpState != DfsFilePointerState.StaticItem From 602237b36b9228cb8ed4e4a82a8cdc5d526c4285 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:19:04 +0200 Subject: [PATCH 11/35] Test reshape on items read through a generic DfsFile ReadItemTimeStepNext and ReadItemTimeStep take reshape but DfsFile ignores it; only Dfs123File reshapes. 0-D and 1-D items stay flat. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfs_basic.py | 69 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/test_dfs_basic.py b/tests/test_dfs_basic.py index 2c2ae68..9681ee1 100644 --- a/tests/test_dfs_basic.py +++ b/tests/test_dfs_basic.py @@ -1,6 +1,7 @@ import ctypes import pytest +from numpy.testing import assert_array_equal from mikecore.DfsDLL import DfsDLL from mikecore.DfsFile import DfsDLLUtil, DfsFile, DfsFileMode, TimeAxisType @@ -164,3 +165,71 @@ def test_item_quantity_descriptions(): assert ["Water Level", "Water Depth"] == [q.ItemDescription for q in quantities] assert ["meter", "meter"] == [q.UnitDescription for q in quantities] + + +@pytest.mark.parametrize( + "filename, openTyped", + [ + ("testdata/OresundHD.dfs2", DfsFileFactory.Dfs2FileOpen), + ("testdata/OresundHD.dfs3", DfsFileFactory.Dfs3FileOpen), + ], +) +def test_generic_read_reshapes_like_dfs123(filename, openTyped): + typed = openTyped(filename) + expected = typed.ReadItemTimeStep(1, 0, reshape=True).Data + typed.Close() + + dfs = DfsFileFactory.DfsGenericOpen(filename) + shape = dfs.ItemInfo[0].SpatialAxis.Shape + nextItemData = dfs.ReadItemTimeStepNext(reshape=True) + itemData = dfs.ReadItemTimeStep(1, 0, reshape=True) + dfs.Close() + + assert len(shape) >= 2 + assert shape == expected.shape + assert nextItemData is not None + assert_array_equal(expected, nextItemData.Data) + assert itemData is not None + assert_array_equal(expected, itemData.Data) + + +# A 0-D axis has Shape (0,) but holds one value, and a 1-D axis is already flat +@pytest.mark.parametrize( + "filename, size", + [("testdata/TemporalEqCal.dfs0", 1), ("testdata/OresundHD.dfsu", 3636)], +) +def test_generic_read_does_not_reshape_0d_or_1d(filename, size): + dfs = DfsFileFactory.DfsGenericOpen(filename) + itemData = dfs.ReadItemTimeStep(1, 0, reshape=True) + dfs.Close() + + assert itemData is not None + assert (size,) == itemData.Data.shape + + +@pytest.mark.parametrize( + "filename", ["testdata/OresundHD.dfs2", "testdata/OresundHD.dfs3"] +) +def test_generic_read_stays_flat_without_reshape(filename): + dfs = DfsFileFactory.DfsGenericOpen(filename) + size = dfs.ItemInfo[0].ElementCount + nextItemData = dfs.ReadItemTimeStepNext() + itemData = dfs.ReadItemTimeStep(1, 0) + dfs.Close() + + assert nextItemData is not None + assert (size,) == nextItemData.Data.shape + assert itemData is not None + assert (size,) == itemData.Data.shape + + +# Some axis types, such as EqD4, are read as None; testdata/ has no such file +def test_generic_read_stays_flat_without_an_axis(): + dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfs2") + size = dfs.ItemInfo[0].ElementCount + dfs.ItemInfo[0].SpatialAxis = None + itemData = dfs.ReadItemTimeStepNext(reshape=True) + dfs.Close() + + assert itemData is not None + assert (size,) == itemData.Data.shape From 7b59a9e7fa0940a13f59e6d3fcaaaece7ba19be2 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:19:14 +0200 Subject: [PATCH 12/35] Reshape 2-D and 3-D items in DfsFile.ReadItemTimeStepNext The reshape parameter was ignored unless the file was a Dfs123File. Reshape with the item's own spatial axis, in Fortran order as Dfs123File does. Dfs123File passes no reshape to the base method, so its behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ mikecore/DfsFile.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a682fa..34cdc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,5 +97,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `DfsFile.ReadStaticItem` raises `ValueError` for an item number below 1. It returned the first static item, numbered 0 or below. A number past the last item still returns `None`. +- `DfsFile.ReadItemTimeStepNext` and `ReadItemTimeStep` with `reshape=True` + reshape an item with a 2-D or 3-D spatial axis to the axis shape, as + `Dfs2File` and `Dfs3File` do. A file opened with + `DfsFileFactory.DfsGenericOpen` ignored `reshape`. 0-D and 1-D items stay + flat. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/DfsFile.py b/mikecore/DfsFile.py index e6d8864..d60af4d 100644 --- a/mikecore/DfsFile.py +++ b/mikecore/DfsFile.py @@ -1069,6 +1069,11 @@ def ReadItemTimeStepNext( res = itemData res.Time = time res.TimeStepIndex = self.fpTimeStepIndex + # A 0-D axis has shape (0,) while holding one value, so only reshape 2-D and + # 3-D. Some axis types, such as EqD4, are read as None, so stay flat. + axis = item.SpatialAxis + if reshape and axis is not None and len(shape := axis.Shape) >= 2: + res.Data = res.Data.reshape(shape, order="F") self.__FpDynamicIncrement() return res From bee7614cb0714490ffb7b754ab8040f15e38bcb4 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:19:56 +0200 Subject: [PATCH 13/35] Expect reused item builders to keep settings and leave items alone test_dynamic_item_builder_can_be_reused was a strict xfail. New tests check that changing a dynamic or static item builder after it returned an item does not change that item. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsbuilder.py | 163 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 12 deletions(-) diff --git a/tests/test_dfsbuilder.py b/tests/test_dfsbuilder.py index 681b77f..8d41c64 100644 --- a/tests/test_dfsbuilder.py +++ b/tests/test_dfsbuilder.py @@ -1,6 +1,7 @@ from datetime import datetime import numpy as np +from numpy.testing import assert_array_equal from mikecore.DfsBuilder import ( DfsBuilder, DfsSimpleType, @@ -8,6 +9,7 @@ DfsStaticItemBuilder, ) from mikecore.DfsFactory import DfsFactory +from mikecore.DfsFile import UnitConversionType from mikecore.DfsFileFactory import DfsFileFactory from mikecore.eum import eumUnit, eumQuantity, eumItem @@ -337,18 +339,6 @@ def test_associated_static_item_number_is_checked(tmp_path): builder.CreateFile(str(tmp_path / "invalid.dfs0")) -@pytest.mark.xfail( - strict=True, - reason=( - "Suspected bug: GetDynamicItemInfo replaces ItemInfo with a blank " - "DfsDynamicItemInfo but leaves the builder's 'is set' flags True. A reused " - "builder then passes Validate and returns an item with an empty name, no " - "quantity and no ValueType, which fails later when added to a file. Its " - "comment says it means to store a clone so the builder can be reused. " - "Correct: the second item keeps the name, quantity and value type set " - "before the first GetDynamicItemInfo call." - ), -) def test_dynamic_item_builder_can_be_reused(): factory = DfsFactory() quantity = eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter) @@ -369,6 +359,155 @@ def test_dynamic_item_builder_can_be_reused(): assert DataValueType.Instantaneous == second.ValueType +def test_reused_dynamic_item_builder_leaves_earlier_items_alone(): + factory = DfsFactory() + item = DfsBuilder.Create().CreateDynamicItemBuilder() + item.Set( + "First", + eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter), + DfsSimpleType.Float, + ) + item.SetValueType(DataValueType.Instantaneous) + item.SetAxis(factory.CreateAxisEqD0()) + item.SetAssociatedStaticItem(1) + first = item.GetDynamicItemInfo() + + item.Set( + "Second", + eumQuantity(eumItem.eumIWaterDepth, eumUnit.eumUfeet), + DfsSimpleType.Double, + ) + item.SetValueType(DataValueType.Accumulated) + item.SetAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1)) + item.SetReferenceCoordinates(1, 2, 3) + item.SetAssociatedStaticItem(2) + second = item.GetDynamicItemInfo() + + assert "First" == first.Name + assert first.Quantity is not None + assert eumItem.eumIWaterLevel == first.Quantity.Item + assert DfsSimpleType.Float == first.DataType + assert DataValueType.Instantaneous == first.ValueType + assert 1 == first.ElementCount + assert np.float32(-1e-35) == first.ReferenceCoordinateX + assert [1] == first.AssociatedStaticItemNumbers + assert "Second" == second.Name + assert DataValueType.Accumulated == second.ValueType + assert 2 == second.ElementCount + assert [1, 2] == second.AssociatedStaticItemNumbers + + +def test_reused_static_item_builder_leaves_earlier_items_alone(): + factory = DfsFactory() + staticItem = DfsStaticItemBuilder() + staticItem.Set( + "First", + eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined), + DfsSimpleType.Float, + ) + staticItem.SetAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1)) + staticItem.SetData(np.array([1.0, 2.0], dtype=np.float32)) + first = staticItem.GetStaticItem() + + staticItem.Set( + "Second", + eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined), + DfsSimpleType.Float, + ) + staticItem.SetReferenceCoordinates(5, 6, 7) + data = staticItem.ItemInfo.Data + assert data is not None + data[0] = 10.0 + second = staticItem.GetStaticItem() + + assert first is not second + assert "First" == first.Name + assert np.float32(-1e-35) == first.ReferenceCoordinateX + assert_array_equal([1.0, 2.0], first.Data) + assert "Second" == second.Name + assert 5 == second.ReferenceCoordinateX + assert_array_equal([10.0, 2.0], second.Data) + + staticItem.SetData(np.array([3.0, 4.0], dtype=np.float32)) + third = staticItem.GetStaticItem() + + assert_array_equal([10.0, 2.0], second.Data) + assert_array_equal([3.0, 4.0], third.Data) + + +def _dynamic_item_builder(): + factory = DfsFactory() + builder = DfsBuilder.Create().CreateDynamicItemBuilder() + builder.Set( + "First", + eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter), + DfsSimpleType.Float, + ) + builder.SetValueType(DataValueType.Instantaneous) + builder.SetAxis(factory.CreateAxisEqD0()) + builder.SetAssociatedStaticItem(1) + setters = [ + lambda: builder.SetValueType(DataValueType.Accumulated), + lambda: builder.SetAssociatedStaticItem(2), + ] + return builder, builder.GetDynamicItemInfo, setters + + +def _static_item_builder(): + builder = DfsStaticItemBuilder() + builder.Set( + "First", + eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined), + DfsSimpleType.Float, + ) + builder.SetAxis(DfsFactory().CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1)) + builder.SetData(np.array([1.0, 2.0], dtype=np.float32)) + + def changeData(): + data = builder.ItemInfo.Data + assert data is not None + data[0] = 10.0 + + setters = [ + lambda: builder.SetData(np.array([3.0, 4.0], dtype=np.float32)), + changeData, + ] + return builder, builder.GetStaticItem, setters + + +# Every setter of either builder, then the getter again, must leave each +# attribute of the item returned first as the same object with the same contents +@pytest.mark.parametrize("makeBuilder", [_dynamic_item_builder, _static_item_builder]) +def test_no_builder_setter_changes_a_returned_item(makeBuilder): + factory = DfsFactory() + builder, getItem, setters = makeBuilder() + first = getItem() + before = { + name: (value, np.array(value) if isinstance(value, list | np.ndarray) else None) + for name, value in vars(first).items() + } + + builder.Set( + "Second", + eumQuantity(eumItem.eumIWaterDepth, eumUnit.eumUfeet), + DfsSimpleType.Double, + ) + builder.SetAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1)) + builder.SetReferenceCoordinates(1, 2, 3) + builder.SetOrientation(4, 5, 6) + builder.SetUnitConversion(UnitConversionType.UbgConversion, eumUnit.eumUfeet) + builder.SetAxisUnitConversion(UnitConversionType.UbgConversion, eumUnit.eumUfeet) + for setter in setters: + setter() + getItem() + + assert before.keys() == vars(first).keys() + for name, (value, contents) in before.items(): + assert value is getattr(first, name), name + if contents is not None: + assert_array_equal(contents, value, err_msg=name) + + def test_reference_coordinates_and_orientation_are_read_back(tmp_path): factory = DfsFactory() builder = DfsBuilder.Create("title", "application", 1) From 37bf12426356af7cb5f03bec824792945b045198 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:20:16 +0200 Subject: [PATCH 14/35] Keep a private copy of the item in DfsBuilder's item builders GetDynamicItemInfo swapped in a blank DfsDynamicItemInfo while keeping the 'is set' flags, so a reused builder returned an empty item. Both builders now return the configured item and keep a shallow copy, with their own associated static item list or data array, as the existing comment intends. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++++++++ mikecore/DfsBuilder.py | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cdc47..2718933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,5 +102,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Dfs2File` and `Dfs3File` do. A file opened with `DfsFileFactory.DfsGenericOpen` ignored `reshape`. 0-D and 1-D items stay flat. +- A `DfsDynamicItemBuilder` can be reused after `GetDynamicItemInfo`. It + replaced its item with a blank one but kept its "is set" flags, so the next + item passed validation with no name, quantity or value type. The builder now + keeps its settings for the next item, and changing it no longer changes the + item it returned. Associated static items carry over and add up: after an + item with static item 1, `SetAssociatedStaticItem(2)` gives the next item + `[1, 2]`. +- Changing a `DfsStaticItemBuilder` after `GetStaticItem`, or the data array it + holds, no longer changes the static item it returned. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/DfsBuilder.py b/mikecore/DfsBuilder.py index 1150ee4..799346f 100644 --- a/mikecore/DfsBuilder.py +++ b/mikecore/DfsBuilder.py @@ -1,3 +1,4 @@ +import copy from typing import Generic, TypeVar from mikecore.DfsDLL import DfsDLL @@ -693,7 +694,11 @@ def GetDynamicItemInfo(self): # Create and store a clone, such that item-builder can be reused when creating more than one item # Otherwise updates to the new item-builder would just update the same item. res = self.ItemInfo - self.ItemInfo = DfsDynamicItemInfo() + self.ItemInfo = copy.copy(res) + if res.AssociatedStaticItemNumbers is not None: + self.ItemInfo.AssociatedStaticItemNumbers = list( + res.AssociatedStaticItemNumbers + ) return res @@ -747,4 +752,9 @@ def GetStaticItem(self): msgs = DfsBuilder.ErrorMessage(errors) raise Exception(msgs) - return self.ItemInfo + # Store a clone, as GetDynamicItemInfo does, so reusing the builder + # does not change the item returned here. + res = self.ItemInfo + self.ItemInfo = copy.copy(res) + self.ItemInfo.Data = copy.copy(res.Data) + return res From 825d6546ee04ff508b1e1bc055b7274250018c2f Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:20:51 +0200 Subject: [PATCH 15/35] Expect DfsuBuilder to write and validate Spectral0D files test_spectral_0d_file_has_one_spectrum was a strict xfail. New tests cover a file with only frequencies, and Validate rejecting a Spectral0D builder with no frequencies or directions, or with an element count that does not match them. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsu_builder.py | 89 +++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 68aba96..aa08517 100644 --- a/tests/test_dfsu_builder.py +++ b/tests/test_dfsu_builder.py @@ -377,18 +377,6 @@ def test_temporal_axis_is_written(tmp_path): dfsu.Close() -@pytest.mark.xfail( - strict=True, - reason=( - "Suspected bug: Validate accepts DfsuSpectral0D, but SetupBuilder has no " - "branch for it and raises an Exception with no message. DfsuFile reads a " - "Spectral0D file as data type 2001 with a 6-value MIKE_FM block " - "[nodes, elements, 2, 0, frequencies, directions], over a mesh with one " - "element per frequency-direction pair. Correct: the builder writes that, " - "and the file reads back as DfsuSpectral0D with one value per frequency " - "and direction." - ), -) def test_spectral_0d_file_has_one_spectrum(tmp_path): filename = str(tmp_path / "spectral0d.dfsu") frequencies = np.array([0.1, 0.2]) @@ -409,3 +397,80 @@ def test_spectral_0d_file_has_one_spectrum(tmp_path): assert_array_equal(directions, dfsu.Directions) assert len(frequencies) * len(directions) == dfsu.ItemInfo[0].ElementCount dfsu.Close() + + +# One triangle per frequency: a strip of 3 triangles over 5 nodes +SPECTRAL_0D_X = [0.0, 1.0, 2.0, 0.0, 1.0] +SPECTRAL_0D_Y = [0.0, 0.0, 0.0, 1.0, 1.0] +SPECTRAL_0D_ELEMENTS = [[1, 2, 5], [1, 5, 4], [2, 3, 5]] + + +def _spectral_0d_builder(): + builder = _builder(DfsuFileType.DfsuSpectral0D) + builder.SetNodes( + SPECTRAL_0D_X, + SPECTRAL_0D_Y, + np.zeros(len(SPECTRAL_0D_X)), + np.zeros(len(SPECTRAL_0D_X), dtype=np.int32), + ) + builder.SetElements(SPECTRAL_0D_ELEMENTS) + return builder + + +def test_spectral_0d_file_with_only_frequencies(tmp_path): + filename = str(tmp_path / "spectral0d_frequencies.dfsu") + frequencies = np.array([0.1, 0.2, 0.3]) + builder = _spectral_0d_builder() + builder.SetFrequencies(frequencies) + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + assert DfsuFileType.DfsuSpectral0D is dfsu.DfsuFileType + assert_array_equal(frequencies, dfsu.Frequencies) + assert 0 == dfsu.NumberOfDirections + assert len(frequencies) == dfsu.ItemInfo[0].ElementCount + dfsu.Close() + + +def test_spectral_0d_file_with_only_directions(tmp_path): + filename = str(tmp_path / "spectral0d_directions.dfsu") + directions = np.array([0.0, 1.0, 2.0]) + builder = _spectral_0d_builder() + builder.SetDirections(directions) + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + assert DfsuFileType.DfsuSpectral0D is dfsu.DfsuFileType + assert_array_equal(directions, dfsu.Directions) + assert 0 == dfsu.NumberOfFrequencies + assert len(directions) == dfsu.ItemInfo[0].ElementCount + dfsu.Close() + + +def test_validate_spectral_0d_needs_frequencies_or_directions(): + builder = _spectral_0d_builder() + + assert [ + "Frequencies or directions must be set for a spectral 0D file" + ] == builder.Validate() + + +@pytest.mark.parametrize( + "frequencies, directions", + [ + ([0.1, 0.2], None), + (None, [0.0, 1.0, 2.0, 3.0]), + ([0.1, 0.2, 0.3], [0.0, 1.0]), + ], +) +def test_validate_spectral_0d_element_count(frequencies, directions): + builder = _spectral_0d_builder() + if frequencies is not None: + builder.SetFrequencies(np.array(frequencies)) + if directions is not None: + builder.SetDirections(np.array(directions)) + + errors = builder.Validate() + + assert 1 == len(errors) + assert "Number of elements (3) must equal" in errors[0] From ccf25e3ae2e8a3012227a960092845cf4000fc8b Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:21:07 +0200 Subject: [PATCH 16/35] Write DfsuSpectral0D files in DfsuBuilder SetupBuilder had no branch for Spectral0D, so CreateFile raised an Exception with no message. Write data type 2001 with the MIKE_FM block [nodes, elements, dims, 0, nf, nd] that DfsuFile reads as Spectral0D: dims 2 for a frequency-direction mesh, 1 for frequencies or directions only. Validate checks the mesh has one element per spectral value, so the file is not read back as Dfsu2D or Dfsu1D. The fallback exception now names the file type. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ mikecore/DfsuBuilder.py | 43 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2718933..89379bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,5 +111,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `[1, 2]`. - Changing a `DfsStaticItemBuilder` after `GetStaticItem`, or the data array it holds, no longer changes the static item it returned. +- `DfsuBuilder` creates `DfsuSpectral0D` files. It raised an `Exception` with + no message. The caller supplies the frequency-direction mesh, one element per + frequency and direction, and `Validate` reports a Spectral0D builder with no + frequencies or directions, or an element count that does not match them, so + the file reads back as `DfsuSpectral0D`. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index bd815f5..6fb13de 100644 --- a/mikecore/DfsuBuilder.py +++ b/mikecore/DfsuBuilder.py @@ -403,8 +403,26 @@ def Validate(self, dieOnError: bool = False): errors.append( "The minimum number of layers is smaller than the number of sigma layers. Element table is invalid" ) + elif self.__dfsuFileType == DfsuFileType.DfsuSpectral0D: + # The mesh is the frequency-direction (rose-plot) geometry, one element + # per frequency and direction, as DfsuFile reads it back + if self.__frequencies is None and self.__directions is None: + errors.append( + "Frequencies or directions must be set for a spectral 0D file" + ) + else: + numberOfSpectralValues = 1 + if self.__frequencies is not None: + numberOfSpectralValues *= len(self.__frequencies) + if self.__directions is not None: + numberOfSpectralValues *= len(self.__directions) + if len(connectivity) != numberOfSpectralValues: + errors.append( + f"Number of elements ({len(connectivity)}) must equal the " + "number of frequencies times the number of directions " + f"({numberOfSpectralValues}) for a spectral 0D file" + ) elif self.__dfsuFileType in ( - DfsuFileType.DfsuSpectral0D, DfsuFileType.DfsuSpectral1D, DfsuFileType.DfsuSpectral2D, ): @@ -615,8 +633,29 @@ def SetupBuilder(self): np.int32, ), ) + elif self.__dfsuFileType == DfsuFileType.DfsuSpectral0D: + # 2 dimensions for a frequency-direction mesh, 1 for frequencies or directions + dimensions = ( + 2 + if self.__frequencies is not None and self.__directions is not None + else 1 + ) + dfsBuilder.AddCreateCustomBlock( + "MIKE_FM", + np.array( + [ + nodes.x.size, + len(connectivity), + dimensions, + 0, + numberOfFrequencies, + numberOfDirections, + ], + np.int32, + ), + ) else: - raise Exception() + raise Exception(f"Dfsu file type {self.__dfsuFileType} not supported") # For the files with a vertical dimension, the first dynamic item is the Z-coordinate if ( From dd5e32d65d4878aec31f2a420590b0e16e0a3fa2 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:25:57 +0200 Subject: [PATCH 17/35] Expect SetElementIds(None) to restore defaults, and int32 range checks SetNodeIds accepts None to use the default ids; SetElementIds raised. Ids that do not fit in int32 were silently wrapped by the conversion. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsu_builder.py | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index aa08517..4cc9a65 100644 --- a/tests/test_dfsu_builder.py +++ b/tests/test_dfsu_builder.py @@ -206,6 +206,57 @@ def test_node_ids_are_written(tmp_path, dtype): assert_array_equal(nodeIds, readNodeIds) +def test_element_ids_set_to_none_use_the_defaults(tmp_path): + filename = str(tmp_path / "element_ids_none.dfsu") + builder = _builder() + builder.SetNodes(X, Y, Z, CODE) + builder.SetElements(ELEMENTS) + builder.SetElementIds(np.array([10, 20], dtype=np.int32)) + builder.SetElementIds(None) + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + elementIds = dfsu.ElementIds + dfsu.Close() + + assert_array_equal([1, 2], elementIds) + + +INT32 = np.iinfo(np.int32) + + +# The smallest and largest int32 are written; one past either end is rejected +def test_int32_bounds_are_written(tmp_path): + filename = str(tmp_path / "int32_ids.dfsu") + nodeIds = [INT32.min, -1, 0, INT32.max] + elementIds = [INT32.max, INT32.min] + builder = _builder() + builder.SetNodes(X, Y, Z, CODE) + builder.SetElements(ELEMENTS) + builder.SetNodeIds(nodeIds) + builder.SetElementIds(elementIds) + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + readNodeIds, readElementIds = dfsu.NodeIds, dfsu.ElementIds + dfsu.Close() + + assert_array_equal(nodeIds, readNodeIds) + assert_array_equal(elementIds, readElementIds) + + +@pytest.mark.parametrize("outside", [INT32.max + 1, INT32.min - 1, 2**40, 2**64]) +def test_ids_outside_int32_are_rejected(outside): + builder = _builder() + builder.SetNodes(X, Y, Z, CODE) + builder.SetElements(ELEMENTS) + + with pytest.raises(ValueError, match="int32"): + builder.SetNodeIds(np.array([1, 2, 3, outside])) + with pytest.raises(ValueError, match="int32"): + builder.SetElementIds([1, outside]) + + # Node numbers start at 1, so 0 is below the first node @pytest.mark.parametrize("nodeNumber", [0, len(X) + 1]) def test_validate_reports_node_number_outside_the_nodes(nodeNumber): From 6f82e0aaefe0e75fa3694ebfa421f8ac58da783c Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Mon, 12 Oct 2026 23:26:42 +0200 Subject: [PATCH 18/35] Restore default element ids on None, and reject ids outside int32 SetElementIds(None) raised, while SetNodeIds(None) goes back to the default ids. Converting with np.asarray(..., dtype=np.int32) wrapped ids that do not fit, so both setters now raise ValueError instead. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++-- mikecore/DfsuBuilder.py | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89379bf..e59b4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,8 +84,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 native description is null. - `DfsuBuilder.SetElementIds` stores the ids, so the file has them instead of the default 1, 2, .... `SetElementIds` and `SetNodeIds` accept int64 arrays - and lists and store int32 ids. Int64 node ids raised "Data type not - supported: int64" in `CreateFile`. + and lists and store int32 ids, raising `ValueError` for ids that do not fit in + int32. Int64 node ids raised "Data type not supported: int64" in + `CreateFile`. `SetElementIds(None)` goes back to the default ids, as + `SetNodeIds(None)` does. - `MeshBuilder.SetNodes` keeps z as doubles, as mesh files store it, instead of rounding it to float32. Float32 z passed to `SetNodes`, such as `DfsuFile.Z`, is now written with the double text of those float32 values diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index 6fb13de..944e544 100644 --- a/mikecore/DfsuBuilder.py +++ b/mikecore/DfsuBuilder.py @@ -221,12 +221,24 @@ def SetZUnit(self, zUnit: eumUnit): # else: # raise Exception("Unit of z coordinate is not a length unit") + @staticmethod + def __Int32Ids(ids, name): + """Ids as the int32 array the file stores, rejecting ids that do not fit.""" + ids = np.asarray(ids) + try: + int32Ids = ids.astype(np.int32) + except OverflowError: + int32Ids = None + if int32Ids is None or np.any(int32Ids != ids): + raise ValueError(f"{name} ids must be integers that fit in int32") + return int32Ids + def SetNodeIds(self, nodeIds): """Set the node id's. Optional. If not set, default values are used (1,2,3,...)""" if nodeIds is None: self.__nodeIds = None return - nodeIds = np.asarray(nodeIds, dtype=np.int32) + nodeIds = DfsuBuilder.__Int32Ids(nodeIds, "Node") if self.__nodes is not None and self.__nodes.x.size != nodeIds.size: raise Exception( "Number of node id's does not match number of nodes", "nodeIds" @@ -271,7 +283,10 @@ def SetElements(self, connectivity): def SetElementIds(self, elementIds): """Set the element id's. Optional. If not set, default values are used (1,2,3,...)""" - elementIds = np.asarray(elementIds, dtype=np.int32) + if elementIds is None: + self.__elementIds = None + return + elementIds = DfsuBuilder.__Int32Ids(elementIds, "Element") if (self.__connectivity is not None) and ( len(self.__connectivity) != elementIds.size ): From 72db4eb6a31ae1feff75b3e91da7b4b5e9f4dc0b Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 13 Oct 2026 16:05:33 +0200 Subject: [PATCH 19/35] Expect MeshBuilder.SetElementIds to take None and reject ids outside int32 DfsuBuilder's id setters restore the default ids on None and reject ids that don't fit in int32. MeshBuilder.SetElementIds, the same setter for mesh files, raises TypeError on None although its docstring calls the ids optional. It writes ids outside int32 into a file that MeshFile.ReadMesh then can't load ("Python integer ... out of bounds for int32"). The ids-are-written test now runs over lists, int32 and int64 arrays and the int32 bounds. Co-Authored-By: Claude Opus 5.5 --- tests/test_mesh.py | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 7e95b43..286971c 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -239,13 +239,30 @@ def test_MeshBuilder_keeps_z_precision(tmp_path): assert_array_equal(z, mesh.Z) -def test_MeshBuilder_element_ids_are_written(tmp_path): - filename = str(tmp_path / "element_ids.mesh") +INT32 = np.iinfo(np.int32) + + +def _square_mesh_builder(): builder = MeshBuilder() builder.SetProjection("NON-UTM") builder.SetNodes(X, Y, Z, CODE) builder.SetElements(ELEMENTS) - elementIds = [10, 20] + return builder + + +# MeshFile.ReadMesh reads ids as int32, so the int32 bounds are the range +@pytest.mark.parametrize( + "elementIds", + [ + [10, 20], + np.array([10, 20], dtype=np.int32), + np.array([10, 20], dtype=np.int64), + [INT32.min, INT32.max], + ], +) +def test_MeshBuilder_element_ids_are_written(tmp_path, elementIds): + filename = str(tmp_path / "element_ids.mesh") + builder = _square_mesh_builder() builder.SetElementIds(elementIds) builder.CreateMesh().Write(filename) @@ -253,6 +270,25 @@ def test_MeshBuilder_element_ids_are_written(tmp_path): assert_array_equal(elementIds, mesh.ElementIds) +def test_MeshBuilder_element_ids_set_to_none_use_the_defaults(tmp_path): + filename = str(tmp_path / "element_ids_none.mesh") + builder = _square_mesh_builder() + builder.SetElementIds([10, 20]) + builder.SetElementIds(None) + builder.CreateMesh().Write(filename) + + mesh = MeshFile.ReadMesh(filename) + assert_array_equal([1, 2], mesh.ElementIds) + + +@pytest.mark.parametrize("outside", [INT32.max + 1, INT32.min - 1, 2**40, 2**64]) +def test_MeshBuilder_element_ids_outside_int32_are_rejected(outside): + builder = _square_mesh_builder() + + with pytest.raises(ValueError, match="int32"): + builder.SetElementIds([1, outside]) + + def test_MeshBuilder_mixed_triangles_and_quadrilaterals(tmp_path): meshFilename = str(tmp_path / "mixed.mesh") dfsuFilename = str(tmp_path / "mixed.dfsu") From f201509019435fb71469181a242e76a552db2c00 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 13 Oct 2026 16:06:59 +0200 Subject: [PATCH 20/35] Restore default mesh element ids on None, and reject ids outside int32 MeshBuilder.SetElementIds gets the same handling as DfsuBuilder's id setters: None goes back to the default ids 1, 2, ..., and ids are stored as int32, raising ValueError for ids that don't fit instead of writing a mesh file that MeshFile.ReadMesh can't read. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ mikecore/MeshBuilder.py | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e59b4dd..2525f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 int32. Int64 node ids raised "Data type not supported: int64" in `CreateFile`. `SetElementIds(None)` goes back to the default ids, as `SetNodeIds(None)` does. +- `MeshBuilder.SetElementIds(None)` goes back to the default ids instead of + raising `TypeError` after `SetElements`, and ids that do not fit in int32 + raise `ValueError`. They were written to a mesh file that `MeshFile.ReadMesh` + could not read. - `MeshBuilder.SetNodes` keeps z as doubles, as mesh files store it, instead of rounding it to float32. Float32 z passed to `SetNodes`, such as `DfsuFile.Z`, is now written with the double text of those float32 values diff --git a/mikecore/MeshBuilder.py b/mikecore/MeshBuilder.py index 642ccd5..c39e784 100644 --- a/mikecore/MeshBuilder.py +++ b/mikecore/MeshBuilder.py @@ -26,7 +26,7 @@ def __init__(self): self.__nodeIds: np.ndarray | None = None self.__nodes: MeshNodes | None = None - self.__elementIds: Sequence[int] | np.ndarray | None = None + self.__elementIds: np.ndarray | None = None self.__connectivity: Sequence | np.ndarray | None = None def SetProjection(self, projection): @@ -98,8 +98,24 @@ def SetElements(self, connectivity): self.__connectivity = connectivity + @staticmethod + def __Int32Ids(ids): + """Ids as the int32 array MeshFile reads, rejecting ids that do not fit.""" + ids = np.asarray(ids) + try: + int32Ids = ids.astype(np.int32) + except OverflowError: + int32Ids = None + if int32Ids is None or np.any(int32Ids != ids): + raise ValueError("Element ids must be integers that fit in int32") + return int32Ids + def SetElementIds(self, elementIds): """Set the element id's. Optional. If not set, default values are used (1,2,3,...)""" + if elementIds is None: + self.__elementIds = None + return + elementIds = MeshBuilder.__Int32Ids(elementIds) if (self.__connectivity is not None) and ( len(self.__connectivity) != len(elementIds) ): From d5afdac9298288cae20ce2ef139e501c5e2baa60 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 13 Oct 2026 16:18:12 +0200 Subject: [PATCH 21/35] Expect DfsuBuilder to write the ids of a mesh built by MeshBuilder SetFromMeshFile passes the mesh's ids to the same writer as SetNodeIds and SetElementIds. MeshBuilder makes its default ids with np.arange, which is int64, so a dfsu built from MeshBuilder.CreateMesh() fails in CreateFile with "Data type not supported: int64". Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsu_builder.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 4cc9a65..1520a35 100644 --- a/tests/test_dfsu_builder.py +++ b/tests/test_dfsu_builder.py @@ -9,6 +9,7 @@ from mikecore.DfsuBuilder import DfsuBuilder from mikecore.DfsuFile import DfsuFile, DfsuFileType from mikecore.eum import eumItem, eumQuantity, eumUnit +from mikecore.MeshBuilder import MeshBuilder from mikecore.MeshFile import MeshFile # Two triangles over four nodes @@ -206,6 +207,31 @@ def test_node_ids_are_written(tmp_path, dtype): assert_array_equal(nodeIds, readNodeIds) +# MeshBuilder's default ids may be int64 (NumPy's default int), and a mesh +# with no ids gets the defaults +@pytest.mark.parametrize("withIds", [True, False]) +def test_ids_from_a_built_mesh_are_written(tmp_path, withIds): + filename = str(tmp_path / "from_mesh_builder.dfsu") + meshBuilder = MeshBuilder() + meshBuilder.SetProjection("NON-UTM") + meshBuilder.SetNodes(X, Y, Z, CODE) + meshBuilder.SetElements(ELEMENTS) + mesh = meshBuilder.CreateMesh() + if not withIds: + mesh.NodeIds = None + mesh.ElementIds = None + builder = _builder() + builder.SetFromMeshFile(mesh) + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + nodeIds, elementIds = dfsu.NodeIds, dfsu.ElementIds + dfsu.Close() + + assert_array_equal([1, 2, 3, 4], nodeIds) + assert_array_equal([1, 2], elementIds) + + def test_element_ids_set_to_none_use_the_defaults(tmp_path): filename = str(tmp_path / "element_ids_none.dfsu") builder = _builder() From 04707faab67cbc7b85fb5c037ec6ad1139eb77c0 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 13 Oct 2026 16:18:37 +0200 Subject: [PATCH 22/35] Store the ids SetFromMeshFile takes from a mesh as int32 The ids go through the same int32 conversion and range check as SetNodeIds and SetElementIds, so a mesh with int64 ids, such as one from MeshBuilder.CreateMesh(), can be written. A mesh with no ids still gets the default ids. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +++- mikecore/DfsuBuilder.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2525f05..ff96139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and lists and store int32 ids, raising `ValueError` for ids that do not fit in int32. Int64 node ids raised "Data type not supported: int64" in `CreateFile`. `SetElementIds(None)` goes back to the default ids, as - `SetNodeIds(None)` does. + `SetNodeIds(None)` does. `SetFromMeshFile` stores a mesh's ids as int32 too, + so a mesh from `MeshBuilder.CreateMesh()`, whose default ids may be int64, + can be written. - `MeshBuilder.SetElementIds(None)` goes back to the default ids instead of raising `TypeError` after `SetElements`, and ids that do not fit in int32 raise `ValueError`. They were written to a mesh file that `MeshFile.ReadMesh` diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index 944e544..026478f 100644 --- a/mikecore/DfsuBuilder.py +++ b/mikecore/DfsuBuilder.py @@ -313,11 +313,19 @@ def SetFromMeshFile(self, meshFile: MeshFile): raise ValueError("Mesh file has no projection, nodes or quantity") self.__dfsProjection = DfsProjection.Create(wktString) - self.__nodeIds = meshFile.NodeIds + nodeIds = meshFile.NodeIds + self.__nodeIds = ( + None if nodeIds is None else DfsuBuilder.__Int32Ids(nodeIds, "Node") + ) self.__nodes = MeshNodes(x, y, z.astype(dtype=np.float32), code) self.__zUnit = quantity.Unit - self.__elementIds = meshFile.ElementIds + elementIds = meshFile.ElementIds + self.__elementIds = ( + None + if elementIds is None + else DfsuBuilder.__Int32Ids(elementIds, "Element") + ) self.__connectivity = meshFile.ElementTable def AddDynamicItem(self, itemName: str, quantity): From 1c48e76007d6d43443d784396c180d80f468bb66 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 13 Oct 2026 16:07:20 +0200 Subject: [PATCH 23/35] Lower the pyrefly warning ceiling to 325 Binding Dfs0ToAscii's time axis once removes 3 warnings, so the count falls from master's 328 to 325. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/type_check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/type_check.yml b/.github/workflows/type_check.yml index 2e28f25..d4b2873 100644 --- a/.github/workflows/type_check.yml +++ b/.github/workflows/type_check.yml @@ -37,7 +37,7 @@ jobs: # (the step above checks it); if it crashes, the JSON parse fails. shell: bash env: - MAX_WARNINGS: 330 + MAX_WARNINGS: 325 run: | warnings=$( (uv run pyrefly check --output-format=json --min-severity=warn --summary=none || true) | uv run python -c "import json, sys; print(sum(e['severity'] == 'warn' for e in json.load(sys.stdin)['errors']))") echo "pyrefly warnings: $warnings (ceiling $MAX_WARNINGS)" From cc9bc1033cb541cb471c44f5a13477430481229b Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:46:43 +0200 Subject: [PATCH 24/35] Share one int32 id helper between MeshBuilder and DfsuBuilder The two builders had the same __Int32Ids, differing only in the name at the start of the message. Move it to a module-level _Int32Ids in MeshBuilder.py, which DfsuBuilder already imports MeshNodes from, and pass the name from each call. The messages are unchanged. Co-Authored-By: Claude Opus 5.5 --- mikecore/DfsuBuilder.py | 26 +++++--------------------- mikecore/MeshBuilder.py | 29 ++++++++++++++++------------- 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index 026478f..5dc09aa 100644 --- a/mikecore/DfsuBuilder.py +++ b/mikecore/DfsuBuilder.py @@ -3,7 +3,7 @@ import numpy as np from mikecore.DfsFile import DfsProjection, DfsTemporalAxis from mikecore.MeshFile import MeshFile -from mikecore.MeshBuilder import MeshNodes +from mikecore.MeshBuilder import MeshNodes, _Int32Ids from mikecore.DfsFactory import DfsFactory from mikecore.DfsBuilder import DfsBuilder from mikecore.DfsuFile import ( @@ -221,24 +221,12 @@ def SetZUnit(self, zUnit: eumUnit): # else: # raise Exception("Unit of z coordinate is not a length unit") - @staticmethod - def __Int32Ids(ids, name): - """Ids as the int32 array the file stores, rejecting ids that do not fit.""" - ids = np.asarray(ids) - try: - int32Ids = ids.astype(np.int32) - except OverflowError: - int32Ids = None - if int32Ids is None or np.any(int32Ids != ids): - raise ValueError(f"{name} ids must be integers that fit in int32") - return int32Ids - def SetNodeIds(self, nodeIds): """Set the node id's. Optional. If not set, default values are used (1,2,3,...)""" if nodeIds is None: self.__nodeIds = None return - nodeIds = DfsuBuilder.__Int32Ids(nodeIds, "Node") + nodeIds = _Int32Ids(nodeIds, "Node") if self.__nodes is not None and self.__nodes.x.size != nodeIds.size: raise Exception( "Number of node id's does not match number of nodes", "nodeIds" @@ -286,7 +274,7 @@ def SetElementIds(self, elementIds): if elementIds is None: self.__elementIds = None return - elementIds = DfsuBuilder.__Int32Ids(elementIds, "Element") + elementIds = _Int32Ids(elementIds, "Element") if (self.__connectivity is not None) and ( len(self.__connectivity) != elementIds.size ): @@ -314,17 +302,13 @@ def SetFromMeshFile(self, meshFile: MeshFile): self.__dfsProjection = DfsProjection.Create(wktString) nodeIds = meshFile.NodeIds - self.__nodeIds = ( - None if nodeIds is None else DfsuBuilder.__Int32Ids(nodeIds, "Node") - ) + self.__nodeIds = None if nodeIds is None else _Int32Ids(nodeIds, "Node") self.__nodes = MeshNodes(x, y, z.astype(dtype=np.float32), code) self.__zUnit = quantity.Unit elementIds = meshFile.ElementIds self.__elementIds = ( - None - if elementIds is None - else DfsuBuilder.__Int32Ids(elementIds, "Element") + None if elementIds is None else _Int32Ids(elementIds, "Element") ) self.__connectivity = meshFile.ElementTable diff --git a/mikecore/MeshBuilder.py b/mikecore/MeshBuilder.py index c39e784..e7fce73 100644 --- a/mikecore/MeshBuilder.py +++ b/mikecore/MeshBuilder.py @@ -18,6 +18,21 @@ class MeshNodes(NamedTuple): code: np.ndarray +def _Int32Ids(ids, name): + """Ids as the int32 array mesh and dfsu files store, rejecting ids that do not fit. + + Shared by MeshBuilder and DfsuBuilder; name starts the error message. + """ + ids = np.asarray(ids) + try: + int32Ids = ids.astype(np.int32) + except OverflowError: + int32Ids = None + if int32Ids is None or np.any(int32Ids != ids): + raise ValueError(f"{name} ids must be integers that fit in int32") + return int32Ids + + class MeshBuilder: def __init__(self): self.__projectionString: str | None = None @@ -98,24 +113,12 @@ def SetElements(self, connectivity): self.__connectivity = connectivity - @staticmethod - def __Int32Ids(ids): - """Ids as the int32 array MeshFile reads, rejecting ids that do not fit.""" - ids = np.asarray(ids) - try: - int32Ids = ids.astype(np.int32) - except OverflowError: - int32Ids = None - if int32Ids is None or np.any(int32Ids != ids): - raise ValueError("Element ids must be integers that fit in int32") - return int32Ids - def SetElementIds(self, elementIds): """Set the element id's. Optional. If not set, default values are used (1,2,3,...)""" if elementIds is None: self.__elementIds = None return - elementIds = MeshBuilder.__Int32Ids(elementIds) + elementIds = _Int32Ids(elementIds, "Element") if (self.__connectivity is not None) and ( len(self.__connectivity) != len(elementIds) ): From 50c9a3b080fde07214d37741e5767a912fff6822 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:47:15 +0200 Subject: [PATCH 25/35] Expect id setters to reject 2-D, scalar and boolean ids DfsuBuilder.SetNodeIds and SetElementIds, and MeshBuilder.SetElementIds, accept a 2x2 array for four nodes, an (n, 1) array for n elements, and booleans, which they convert to 0 and 1. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsu_builder.py | 21 +++++++++++++++++++++ tests/test_mesh.py | 16 ++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 1520a35..6edde98 100644 --- a/tests/test_dfsu_builder.py +++ b/tests/test_dfsu_builder.py @@ -283,6 +283,27 @@ def test_ids_outside_int32_are_rejected(outside): builder.SetElementIds([1, outside]) +# One id per node or element, so a 2-D array or a scalar is rejected even when its +# size fits +@pytest.mark.parametrize( + "nodeIds, elementIds, match", + [ + (np.array([[1, 2], [3, 4]]), np.array([[10], [20]]), "1-D"), + (7, 7, "1-D"), + ([True, False, True, False], [True, False], "not booleans"), + ], +) +def test_ids_must_be_a_1d_integer_array(nodeIds, elementIds, match): + builder = _builder() + builder.SetNodes(X, Y, Z, CODE) + builder.SetElements(ELEMENTS) + + with pytest.raises(ValueError, match=match): + builder.SetNodeIds(nodeIds) + with pytest.raises(ValueError, match=match): + builder.SetElementIds(elementIds) + + # Node numbers start at 1, so 0 is below the first node @pytest.mark.parametrize("nodeNumber", [0, len(X) + 1]) def test_validate_reports_node_number_outside_the_nodes(nodeNumber): diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 286971c..c630eed 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -289,6 +289,22 @@ def test_MeshBuilder_element_ids_outside_int32_are_rejected(outside): builder.SetElementIds([1, outside]) +# One id per element, so a 2-D array or a scalar is rejected even when its size fits +@pytest.mark.parametrize( + "elementIds, match", + [ + (np.array([[10], [20]]), "1-D"), + (7, "1-D"), + ([True, False], "not booleans"), + ], +) +def test_MeshBuilder_element_ids_must_be_a_1d_integer_array(elementIds, match): + builder = _square_mesh_builder() + + with pytest.raises(ValueError, match=match): + builder.SetElementIds(elementIds) + + def test_MeshBuilder_mixed_triangles_and_quadrilaterals(tmp_path): meshFilename = str(tmp_path / "mixed.mesh") dfsuFilename = str(tmp_path / "mixed.dfsu") From 43f3287007670b83e2bde2482b0bed2391901e2f Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:47:31 +0200 Subject: [PATCH 26/35] Reject 2-D, scalar and boolean ids in the id setters The shared _Int32Ids checked only that the ids fit in int32, so a 2-D array of the right size, or booleans as 0 and 1, were stored. Check the dimensions and the dtype first, so DfsuBuilder and MeshBuilder reject them the same way. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ mikecore/MeshBuilder.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff96139..41111be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 raising `TypeError` after `SetElements`, and ids that do not fit in int32 raise `ValueError`. They were written to a mesh file that `MeshFile.ReadMesh` could not read. +- The id setters of `DfsuBuilder` and `MeshBuilder` raise `ValueError` for ids + that are not a 1-D array, or are booleans. A 2-D array whose size matched + the number of nodes or elements, and booleans as 0 and 1, were accepted. - `MeshBuilder.SetNodes` keeps z as doubles, as mesh files store it, instead of rounding it to float32. Float32 z passed to `SetNodes`, such as `DfsuFile.Z`, is now written with the double text of those float32 values diff --git a/mikecore/MeshBuilder.py b/mikecore/MeshBuilder.py index e7fce73..464652c 100644 --- a/mikecore/MeshBuilder.py +++ b/mikecore/MeshBuilder.py @@ -24,6 +24,10 @@ def _Int32Ids(ids, name): Shared by MeshBuilder and DfsuBuilder; name starts the error message. """ ids = np.asarray(ids) + if ids.ndim != 1: + raise ValueError(f"{name} ids must be a 1-D array, got {ids.ndim}-D") + if ids.dtype == np.bool_: + raise ValueError(f"{name} ids must be integers, not booleans") try: int32Ids = ids.astype(np.int32) except OverflowError: From 4a4a81ecf0ab4fee1108dd52c867686a430f7c4b Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:47:50 +0200 Subject: [PATCH 27/35] Expect MeshBuilder's default ids to be int32, as MeshFile.ReadMesh reads them CreateMesh makes the default node and element ids with np.arange, which is int64 on Linux, so a built mesh's id dtype depends on whether ids were set. Once they are int32, MeshBuilder no longer gives SetFromMeshFile int64 ids, so test that path with int64 ids from MeshFile.Create, which keeps the ids it is given. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsu_builder.py | 30 ++++++++++++++++++++++++++++-- tests/test_mesh.py | 17 +++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 6edde98..2fa82c7 100644 --- a/tests/test_dfsu_builder.py +++ b/tests/test_dfsu_builder.py @@ -207,8 +207,7 @@ def test_node_ids_are_written(tmp_path, dtype): assert_array_equal(nodeIds, readNodeIds) -# MeshBuilder's default ids may be int64 (NumPy's default int), and a mesh -# with no ids gets the defaults +# A mesh with no ids gets the defaults @pytest.mark.parametrize("withIds", [True, False]) def test_ids_from_a_built_mesh_are_written(tmp_path, withIds): filename = str(tmp_path / "from_mesh_builder.dfsu") @@ -232,6 +231,33 @@ def test_ids_from_a_built_mesh_are_written(tmp_path, withIds): assert_array_equal([1, 2], elementIds) +# MeshFile.Create keeps the ids it is given, here int64 as np.array gives on Linux +def test_int64_ids_from_a_created_mesh_are_written(tmp_path): + filename = str(tmp_path / "from_mesh_create.dfsu") + mesh = MeshFile.Create( + eumQuantity(eumItem.eumIBathymetry, eumUnit.eumUmeter), + "NON-UTM", + np.array([11, 12, 13, 14], dtype=np.int64), + X, + Y, + Z, + CODE.astype(np.int32), + np.array([21, 22], dtype=np.int64), + np.array([21, 21], dtype=np.int32), + ELEMENTS, + ) + builder = _builder() + builder.SetFromMeshFile(mesh) + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + nodeIds, elementIds = dfsu.NodeIds, dfsu.ElementIds + dfsu.Close() + + assert_array_equal([11, 12, 13, 14], nodeIds) + assert_array_equal([21, 22], elementIds) + + def test_element_ids_set_to_none_use_the_defaults(tmp_path): filename = str(tmp_path / "element_ids_none.dfsu") builder = _builder() diff --git a/tests/test_mesh.py b/tests/test_mesh.py index c630eed..2c45378 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -270,6 +270,23 @@ def test_MeshBuilder_element_ids_are_written(tmp_path, elementIds): assert_array_equal(elementIds, mesh.ElementIds) +# MeshFile.ReadMesh is the oracle for the id dtype +def test_MeshBuilder_default_ids_are_int32_like_a_read_mesh(tmp_path): + filename = str(tmp_path / "default_ids.mesh") + built = _square_mesh_builder().CreateMesh() + built.Write(filename) + read = MeshFile.ReadMesh(filename) + nodeIds, elementIds = built.NodeIds, built.ElementIds + readNodeIds, readElementIds = read.NodeIds, read.ElementIds + assert nodeIds is not None and elementIds is not None + assert readNodeIds is not None and readElementIds is not None + + assert_array_equal([1, 2, 3, 4], nodeIds) + assert_array_equal([1, 2], elementIds) + assert readNodeIds.dtype == nodeIds.dtype + assert readElementIds.dtype == elementIds.dtype + + def test_MeshBuilder_element_ids_set_to_none_use_the_defaults(tmp_path): filename = str(tmp_path / "element_ids_none.mesh") builder = _square_mesh_builder() From c8133e09ef629de397bfbcb23bcf898f84697de4 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:49:28 +0200 Subject: [PATCH 28/35] Make MeshBuilder's default ids int32 np.arange(n) + 1 is int64 on Linux. Make the defaults int32, as MeshFile.ReadMesh reads ids, so a built mesh has the same id dtype whether or not ids were set. SetFromMeshFile keeps its int32 cast, as MeshFile.Create can still give it int64 ids. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++-- mikecore/MeshBuilder.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41111be..773ae81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,12 +88,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 int32. Int64 node ids raised "Data type not supported: int64" in `CreateFile`. `SetElementIds(None)` goes back to the default ids, as `SetNodeIds(None)` does. `SetFromMeshFile` stores a mesh's ids as int32 too, - so a mesh from `MeshBuilder.CreateMesh()`, whose default ids may be int64, - can be written. + so a mesh from `MeshFile.Create` with int64 ids can be written. - `MeshBuilder.SetElementIds(None)` goes back to the default ids instead of raising `TypeError` after `SetElements`, and ids that do not fit in int32 raise `ValueError`. They were written to a mesh file that `MeshFile.ReadMesh` could not read. +- `MeshBuilder.CreateMesh()` makes its default node and element ids int32, as + `MeshFile.ReadMesh` reads them. They were int64 on Linux, so the id dtype of + a built mesh depended on whether ids were set. - The id setters of `DfsuBuilder` and `MeshBuilder` raise `ValueError` for ids that are not a 1-D array, or are booleans. A 2-D array whose size matched the number of nodes or elements, and booleans as 0 and 1, were accepted. diff --git a/mikecore/MeshBuilder.py b/mikecore/MeshBuilder.py index 464652c..a1518e7 100644 --- a/mikecore/MeshBuilder.py +++ b/mikecore/MeshBuilder.py @@ -172,13 +172,13 @@ def CreateMesh(self) -> MeshFile: if self.__eumQuantity is None: self.__eumQuantity = eumQuantity(eumItem.eumIBathymetry, eumUnit.eumUmeter) - # Creating default node id's, if empty + # Creating default node id's, if empty, as int32 like MeshFile.ReadMesh if self.__nodeIds is None: - self.__nodeIds = np.arange(len(nodes.x)) + 1 + self.__nodeIds = np.arange(1, len(nodes.x) + 1, dtype=np.int32) # Creating default element id's, if empty if self.__elementIds is None: - self.__elementIds = np.arange(len(connectivity)) + 1 + self.__elementIds = np.arange(1, len(connectivity) + 1, dtype=np.int32) # Creating additional element information elementType = np.zeros(len(connectivity), dtype=np.int32) From d628cc8506c536120309222c5f9a1a06e0591887 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:49:43 +0200 Subject: [PATCH 29/35] Expect Spectral0D files to be written from frequency and direction lists SetFrequencies and SetDirections store a list as given. Validate accepts it, then CreateFile fails with AttributeError: 'list' object has no attribute 'size', leaving a half-written file. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsu_builder.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 2fa82c7..3d89358 100644 --- a/tests/test_dfsu_builder.py +++ b/tests/test_dfsu_builder.py @@ -571,6 +571,32 @@ def test_spectral_0d_file_with_only_directions(tmp_path): dfsu.Close() +# Lists pass Validate, so CreateFile must write them too +@pytest.mark.parametrize( + "frequencies, directions", [([0.1, 0.2, 0.3], None), (None, [0.0, 1.0, 2.0])] +) +def test_spectral_0d_file_from_lists(tmp_path, frequencies, directions): + filename = str(tmp_path / "spectral0d_lists.dfsu") + builder = _spectral_0d_builder() + if frequencies is not None: + builder.SetFrequencies(frequencies) + if directions is not None: + builder.SetDirections(directions) + assert [] == builder.Validate() + builder.CreateFile(filename).Close() + + dfsu = DfsuFile.Open(filename) + readFrequencies, readDirections = dfsu.Frequencies, dfsu.Directions + fileType = dfsu.DfsuFileType + dfsu.Close() + + assert DfsuFileType.DfsuSpectral0D is fileType + if frequencies is not None: + assert_array_equal(frequencies, readFrequencies) + if directions is not None: + assert_array_equal(directions, readDirections) + + def test_validate_spectral_0d_needs_frequencies_or_directions(): builder = _spectral_0d_builder() From ae26532e53c4ee43afe1360649936d1a455bc8e6 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:50:09 +0200 Subject: [PATCH 30/35] Store DfsuBuilder frequencies and directions as float64 arrays SetFrequencies and SetDirections stored what they were given, and CreateDfsu writes them with AddCreateStaticItem, which needs an array. Convert them in the setters, as doubles, the type DfsuFile reads the Frequency and Direction static items as. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ mikecore/DfsuBuilder.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 773ae81..5d944c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -129,5 +129,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 frequency and direction, and `Validate` reports a Spectral0D builder with no frequencies or directions, or an element count that does not match them, so the file reads back as `DfsuSpectral0D`. +- `DfsuBuilder.SetFrequencies` and `SetDirections` accept lists and store the + values as doubles, as dfsu files store frequencies and directions. A list + passed `Validate` and then failed in `CreateFile` with `AttributeError`, + leaving a half-written file, and float32 arrays were written as floats. [Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index 5dc09aa..9f52240 100644 --- a/mikecore/DfsuBuilder.py +++ b/mikecore/DfsuBuilder.py @@ -74,8 +74,8 @@ def __init__(self, dfsuFileType): self.__dynamicItemData = [] # spectral - self.__frequencies: Sequence[float] | np.ndarray | None = None - self.__directions: Sequence[float] | np.ndarray | None = None + self.__frequencies: np.ndarray | None = None + self.__directions: np.ndarray | None = None self.__dfsuFileType = dfsuFileType if dfsuFileType == DfsuFileType.Dfsu2D: @@ -126,10 +126,15 @@ def SetNumberOfSigmaLayers(self, numberOfSigmaLayers: int): raise Exception("dfsuFileType") def SetFrequencies(self, frequencies): - self.__frequencies = frequencies + # As an array, since the static item writer needs one + self.__frequencies = ( + None if frequencies is None else np.asarray(frequencies, dtype=np.float64) + ) def SetDirections(self, directions): - self.__directions = directions + self.__directions = ( + None if directions is None else np.asarray(directions, dtype=np.float64) + ) # / # / Set a non-standard temporal axis for the dfsu file. WARNING: The dfsu file will not be valid in all contexts. From be550ec759bde9bf889fb493b7b4bf4aee41ccc4 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:50:41 +0200 Subject: [PATCH 31/35] Drop the reshape test that set an item's axis to None by hand test_generic_read_stays_flat_without_an_axis stood in for a file whose axis GetItemSpatialAxis reads as None, such as EqD4. No such file exists: DfsFactory cannot make EqD4, NeqD or TvarD axes, and a file given an EqD4 axis through the native dfsSetItemAxisEqD4 fails to open with 'ValueError: 11 is not a valid SpaceAxisType', because SpaceAxisType has no member for it. GetItemSpatialAxis returns a value or raises for every member, so the None branch cannot be reached from a file, and the test only exercised the hand-set value. The guard stays because the return type allows None; the comment now says so. Co-Authored-By: Claude Opus 5.5 --- mikecore/DfsFile.py | 3 ++- tests/test_dfs_basic.py | 12 ------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/mikecore/DfsFile.py b/mikecore/DfsFile.py index d60af4d..ee61204 100644 --- a/mikecore/DfsFile.py +++ b/mikecore/DfsFile.py @@ -1070,7 +1070,8 @@ def ReadItemTimeStepNext( res.Time = time res.TimeStepIndex = self.fpTimeStepIndex # A 0-D axis has shape (0,) while holding one value, so only reshape 2-D and - # 3-D. Some axis types, such as EqD4, are read as None, so stay flat. + # 3-D. GetItemSpatialAxis can return None by its type, though a file with an + # axis it does not read, such as EqD4, already fails to open; stay flat then. axis = item.SpatialAxis if reshape and axis is not None and len(shape := axis.Shape) >= 2: res.Data = res.Data.reshape(shape, order="F") diff --git a/tests/test_dfs_basic.py b/tests/test_dfs_basic.py index 9681ee1..5a58a75 100644 --- a/tests/test_dfs_basic.py +++ b/tests/test_dfs_basic.py @@ -221,15 +221,3 @@ def test_generic_read_stays_flat_without_reshape(filename): assert (size,) == nextItemData.Data.shape assert itemData is not None assert (size,) == itemData.Data.shape - - -# Some axis types, such as EqD4, are read as None; testdata/ has no such file -def test_generic_read_stays_flat_without_an_axis(): - dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfs2") - size = dfs.ItemInfo[0].ElementCount - dfs.ItemInfo[0].SpatialAxis = None - itemData = dfs.ReadItemTimeStepNext(reshape=True) - dfs.Close() - - assert itemData is not None - assert (size,) == itemData.Data.shape From 7485ae7545ad7b560556dca6c9dbd611697c99be Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:51:00 +0200 Subject: [PATCH 32/35] Expect ReadStaticItem to return None for numbers past int32 ReadStaticItem(2**31) wraps in the untyped dfsFindItemStatic call and returns 'Node id', and 2**64 raises ctypes.ArgumentError. Numbers that do not fit in int32 are past the last item, which returns None. 2**31 - 1 is the guard that already returns None. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfs_basic.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_dfs_basic.py b/tests/test_dfs_basic.py index 5a58a75..fe9982a 100644 --- a/tests/test_dfs_basic.py +++ b/tests/test_dfs_basic.py @@ -127,6 +127,20 @@ def test_read_static_item_past_the_last_item(): assert item is None +# Numbers that do not fit in the native int32 are past the last item too +@pytest.mark.parametrize("staticItemNo", [2**31 - 1, 2**31, 2**32 + 1, 2**64]) +def test_read_static_item_past_int32(staticItemNo): + dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu") + + item = dfs.ReadStaticItem(staticItemNo) + first = dfs.ReadStaticItem(1) + dfs.Close() + + assert item is None + assert first is not None + assert "Node id" == first.Name + + @pytest.mark.parametrize("staticItemNo", [0, -1]) def test_read_static_item_below_one(staticItemNo): dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu") From 10f558592769804270a9470be8433f3420678a15 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:51:19 +0200 Subject: [PATCH 33/35] Return None from ReadStaticItem for numbers past int32 dfsFindItemStatic is called without argtypes, so ctypes wrapped 2**31 to a valid item number and raised ArgumentError beyond 64 bits. No file has that many static items, so return None, as for any other number past the last item, before calling the native library. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- mikecore/DfsFile.py | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d944c7..270adb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,7 +109,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 timestamps. A 10 minute step was written as 10 seconds. - `DfsFile.ReadStaticItem` raises `ValueError` for an item number below 1. It returned the first static item, numbered 0 or below. A number past the last - item still returns `None`. + item still returns `None`, and so does a number too large for int32, which + returned another item or raised `ctypes.ArgumentError`. - `DfsFile.ReadItemTimeStepNext` and `ReadItemTimeStep` with `reshape=True` reshape an item with a 2-D or 3-D spatial axis to the axis shape, as `Dfs2File` and `Dfs3File` do. A file opened with diff --git a/mikecore/DfsFile.py b/mikecore/DfsFile.py index ee61204..6d37424 100644 --- a/mikecore/DfsFile.py +++ b/mikecore/DfsFile.py @@ -954,6 +954,9 @@ def ReadStaticItem(self, staticItemNo): raise ValueError( f"Static item number must be 1 or more, got {staticItemNo}" ) + # dfsFindItemStatic takes an int32, and a larger number is past the last item + if staticItemNo > np.iinfo(np.int32).max: + return None if ( self.fpState != DfsFilePointerState.StaticItem From d3668907e8a20b8580bed9097f7312cc77a32447 Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:51:33 +0200 Subject: [PATCH 34/35] Expect a static item to keep its data when the caller changes the array GetStaticItem copies the data array into the builder's next item and returns the caller's own array, so changing the array passed to SetData changes the item already returned. Co-Authored-By: Claude Opus 5.5 --- tests/test_dfsbuilder.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_dfsbuilder.py b/tests/test_dfsbuilder.py index 8d41c64..d70ea7c 100644 --- a/tests/test_dfsbuilder.py +++ b/tests/test_dfsbuilder.py @@ -435,6 +435,23 @@ def test_reused_static_item_builder_leaves_earlier_items_alone(): assert_array_equal([3.0, 4.0], third.Data) +def test_static_item_keeps_its_data_when_the_caller_changes_the_array(): + data = np.array([1.0, 2.0], dtype=np.float32) + staticItem = DfsStaticItemBuilder() + staticItem.Set( + "Item", + eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined), + DfsSimpleType.Float, + ) + staticItem.SetAxis(DfsFactory().CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1)) + staticItem.SetData(data) + item = staticItem.GetStaticItem() + + data[0] = 10.0 + + assert_array_equal([1.0, 2.0], item.Data) + + def _dynamic_item_builder(): factory = DfsFactory() builder = DfsBuilder.Create().CreateDynamicItemBuilder() From 3273233b01faa99f67fbf871829e7982eb32a52c Mon Sep 17 00:00:00 2001 From: Ryan Murray Date: Tue, 20 Oct 2026 09:51:51 +0200 Subject: [PATCH 35/35] Copy the data into the static item GetStaticItem returns GetStaticItem gave the builder's next item a copy of the data and returned the item holding the caller's array, so changing that array still changed the returned item. Return a copy of the item with its own copy of the data instead, and keep the builder's item as it is. This is still one copy per call, which the invariant needs: the builder keeps its data for the next item, and that array, as ItemInfo.Data, can be changed in place. AddStaticItem writes the data to the file at once, so it needs no copy of its own. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++-- mikecore/DfsBuilder.py | 9 ++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 270adb6..a91f71a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -123,8 +123,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 item it returned. Associated static items carry over and add up: after an item with static item 1, `SetAssociatedStaticItem(2)` gives the next item `[1, 2]`. -- Changing a `DfsStaticItemBuilder` after `GetStaticItem`, or the data array it - holds, no longer changes the static item it returned. +- Changing a `DfsStaticItemBuilder` after `GetStaticItem`, or the data array + passed to `SetData`, no longer changes the static item it returned. Each + returned item has its own copy of the data. - `DfsuBuilder` creates `DfsuSpectral0D` files. It raised an `Exception` with no message. The caller supplies the frequency-direction mesh, one element per frequency and direction, and `Validate` reports a Spectral0D builder with no diff --git a/mikecore/DfsBuilder.py b/mikecore/DfsBuilder.py index 799346f..bdaf4b6 100644 --- a/mikecore/DfsBuilder.py +++ b/mikecore/DfsBuilder.py @@ -752,9 +752,8 @@ def GetStaticItem(self): msgs = DfsBuilder.ErrorMessage(errors) raise Exception(msgs) - # Store a clone, as GetDynamicItemInfo does, so reusing the builder - # does not change the item returned here. - res = self.ItemInfo - self.ItemInfo = copy.copy(res) - self.ItemInfo.Data = copy.copy(res.Data) + # Return a clone with its own data, so neither reusing the builder nor + # changing the array given to SetData changes the item returned here. + res = copy.copy(self.ItemInfo) + res.Data = copy.copy(self.ItemInfo.Data) return res