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)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6840189..a91f71a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,5 +78,62 @@ 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. +- `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, 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. `SetFromMeshFile` stores a mesh's ids as int32 too, + 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. +- `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. +- `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`, 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 + `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 + 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 + 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/DfsBuilder.py b/mikecore/DfsBuilder.py index 1150ee4..bdaf4b6 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,8 @@ def GetStaticItem(self): msgs = DfsBuilder.ErrorMessage(errors) raise Exception(msgs) - return self.ItemInfo + # 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 diff --git a/mikecore/DfsFile.py b/mikecore/DfsFile.py index 1e987e3..6d37424 100644 --- a/mikecore/DfsFile.py +++ b/mikecore/DfsFile.py @@ -945,10 +945,18 @@ 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}" + ) + # 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 @@ -1064,6 +1072,12 @@ 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. 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") self.__FpDynamicIncrement() return res diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index e2a8820..9f52240 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 ( @@ -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. @@ -226,6 +231,7 @@ def SetNodeIds(self, nodeIds): if nodeIds is None: self.__nodeIds = None return + 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" @@ -270,12 +276,17 @@ def SetElements(self, connectivity): 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 = _Int32Ids(elementIds, "Element") 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. @@ -295,11 +306,15 @@ 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 _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 _Int32Ids(elementIds, "Element") + ) self.__connectivity = meshFile.ElementTable def AddDynamicItem(self, itemName: str, quantity): @@ -400,8 +415,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, ): @@ -612,8 +645,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 ( diff --git a/mikecore/MeshBuilder.py b/mikecore/MeshBuilder.py index 33bded5..a1518e7 100644 --- a/mikecore/MeshBuilder.py +++ b/mikecore/MeshBuilder.py @@ -18,6 +18,25 @@ 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) + 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: + 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 @@ -26,7 +45,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): @@ -54,7 +73,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: @@ -100,6 +119,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,...)""" + if elementIds is None: + self.__elementIds = None + return + elementIds = _Int32Ids(elementIds, "Element") if (self.__connectivity is not None) and ( len(self.__connectivity) != len(elementIds) ): @@ -149,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) 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) 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( diff --git a/tests/test_dfs_basic.py b/tests/test_dfs_basic.py index fb70f39..fe9982a 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 @@ -126,26 +127,51 @@ 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(): +# 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") 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] @@ -153,3 +179,59 @@ 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 diff --git a/tests/test_dfsbuilder.py b/tests/test_dfsbuilder.py index 681b77f..d70ea7c 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,172 @@ 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 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() + 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) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 45d50f1..3d89358 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 @@ -172,20 +173,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 +189,147 @@ 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) + + +# 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) + + +# 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() + 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]) + + +# 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): @@ -366,18 +501,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]) @@ -398,3 +521,106 @@ 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() + + +# 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() + + 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] 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 diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 8a27f79..2c45378 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 @@ -249,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) @@ -263,6 +270,58 @@ def test_MeshBuilder_element_ids_are_written(tmp_path): 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() + 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]) + + +# 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") 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):