diff --git a/.github/workflows/type_check.yml b/.github/workflows/type_check.yml index 2e28f25..72f80fb 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: 326 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 0ea5f6a..1b00c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with `subprocess`, no longer inherit it. - The architecture check at import requires an x86-64 CPU as well as a 64-bit Python. +- **Breaking:** `DfsFile.ReadStaticItem` raises `ValueError` for a number past + the last static item instead of returning `None`, as reading a dynamic item + past the last one does. To read every static item, call `ReadStaticItemNext` + until it returns `None`. +- **Breaking:** `DfsFile.ReadItemTimeStepNext` and `ReadItemTimeStep` no longer + take `reshape`, which a file opened with `DfsFileFactory.DfsGenericOpen` + ignored. Open dfs1, dfs2 and dfs3 files with `Dfs1FileOpen`, `Dfs2FileOpen` + or `Dfs3FileOpen` to reshape their data. ### Fixed @@ -107,5 +115,54 @@ 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 of integers. A 2-D array whose size matched the + number of nodes or elements, booleans as 0 and 1, and floats were accepted; + whole-number floats such as 1.0 are now rejected too. +- `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 too large for + int32, which returned another item or raised `ctypes.ArgumentError`, now + raises `ValueError` as any number past the last item does (see Changed). +- `DfsDynamicItemBuilder` and `DfsStaticItemBuilder` reset after + `GetDynamicItemInfo` and `GetStaticItem`. A reused dynamic builder used to + return a blank item that passed validation and failed only when added to a + file. Each builder now starts empty for the next item, and validation reports + any setting that is not set again. Changing a builder no longer changes the + 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`. +- `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/Dfs123File.py b/mikecore/Dfs123File.py index dde5676..ca2a75e 100644 --- a/mikecore/Dfs123File.py +++ b/mikecore/Dfs123File.py @@ -21,7 +21,20 @@ def Reshape(self, reshape): def ReadItemTimeStepNext( self, itemData: DfsItemData | None = None, reshape: bool = False ) -> DfsItemData | None: - res = super().ReadItemTimeStepNext(itemData) + return self.__Reshaped(super().ReadItemTimeStepNext(itemData), reshape) + + def ReadItemTimeStep( + self, + itemNumber: int | DfsItemData, + timestepIndex: int, + reshape: bool = False, + ) -> DfsItemData | None: + return self.__Reshaped( + super().ReadItemTimeStep(itemNumber, timestepIndex), reshape + ) + + def __Reshaped(self, res: DfsItemData | None, reshape: bool) -> DfsItemData | None: + # Reshaping an array that already has the axis shape leaves it unchanged if res is not None and (self.reshape or reshape): res.Data = res.Data.reshape(self.SpatialAxis.Shape, order="F") return res diff --git a/mikecore/DfsBuilder.py b/mikecore/DfsBuilder.py index 1150ee4..546ce18 100644 --- a/mikecore/DfsBuilder.py +++ b/mikecore/DfsBuilder.py @@ -610,6 +610,10 @@ class DfsAbstractItemBuilder(Generic[ItemInfoT]): ItemInfo: ItemInfoT def __init__(self): + self._Reset() + + def _Reset(self): + """Start a new, empty item. Each subclass also sets a new ItemInfo.""" self.isSetNameQuantityDataType = False self.isSetSpatialAxis = False @@ -663,8 +667,8 @@ class DfsDynamicItemBuilder(DfsAbstractItemBuilder[DfsDynamicItemInfo]): written to file, i.e., length of strings can be changed. """ - def __init__(self): - super().__init__() + def _Reset(self): + super()._Reset() self.isSetDataValueType = False self.ItemInfo = DfsDynamicItemInfo() @@ -690,10 +694,10 @@ def GetDynamicItemInfo(self): msgs = DfsBuilder.ErrorMessage(errors) raise Exception(msgs) - # 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. + # Hand over the item and start empty, so reusing the builder + # cannot change the item returned here. res = self.ItemInfo - self.ItemInfo = DfsDynamicItemInfo() + self._Reset() return res @@ -712,14 +716,14 @@ class DfsStaticItemBuilder(DfsAbstractItemBuilder[DfsStaticItem]): can be added to the static item from the file. + To read every static item, use `ReadStaticItemNext`, which returns None + after the last one. + :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. + :returns: The static item number . + :raises ValueError: If is below 1 or past the last static item. """ 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}" + ) + pastTheEnd = ValueError( + f"Static item number {staticItemNo} is past the last static item" + ) + # dfsFindItemStatic takes an int32, and a larger number is past the last item + if staticItemNo > np.iinfo(np.int32).max: + raise pastTheEnd if ( self.fpState != DfsFilePointerState.StaticItem or self.fpItemNumber != staticItemNo ): - DfsDLL.Wrapper.dfsFindItemStatic( + rc = DfsDLL.Wrapper.dfsFindItemStatic( self.headPointer, self.filePointer, staticItemNo ) self.fpState = DfsFilePointerState.StaticItem self.fpItemNumber = staticItemNo self.fpTimeStepIndex = -1 - return self.ReadStaticItemNext() + if rc != 0: + raise pastTheEnd + # dfsFindItemStatic also succeeds one past the last item + staticItem = self.ReadStaticItemNext() + if staticItem is None: + raise pastTheEnd + return staticItem def WriteStaticItemData(self, staticItem, data: np.ndarray): """ @@ -1006,7 +1026,7 @@ def WriteStaticItemData(self, staticItem, data: np.ndarray): self.fpItemNumber += 1 def ReadItemTimeStepNext( - self, itemData: DfsItemData | None = None, reshape: bool = False + self, itemData: DfsItemData | None = None ) -> DfsItemData | None: """ Reads the next dynamic item-timestep. First time called it returns the first @@ -1026,7 +1046,6 @@ def ReadItemTimeStepNext( since it iterates exactly as the data is stored on the disk. :param itemData DfsItemData: DfsItemData for item to store timestep values in, for reuse of memory. - :param reshape bool: Reshape data array to dimension of data, 2D or 3D depending on spatial axis. :returns DfsIemData: The next dynamic item-timestep, None if no more items are present. """ self.__CheckIfOpen() @@ -1071,7 +1090,6 @@ def ReadItemTimeStep( self, itemNumber: int | DfsItemData, timestepIndex: int, - reshape: bool = False, ) -> DfsItemData | None: """ Reads the dynamic item-timestep as specified from the file. It throws an @@ -1080,7 +1098,6 @@ def ReadItemTimeStep( :param itemNumber Union[int,DfsItemData]: Item number (1-based), or DfsItemData for item :param timestepIndex int: Time step index (0-based) - :param reshape bool: Reshape data array to dimension of data, 2D or 3D depending on spatial axis. :return DfsItemData: The dynamic item-timestep as specified """ self.__CheckIfOpen() @@ -1115,7 +1132,7 @@ def ReadItemTimeStep( # Position file pointer self.__FpFindItemTimeStep(itemNumber, timestepIndex) - return self.ReadItemTimeStepNext(itemData, reshape) + return self.ReadItemTimeStepNext(itemData) def __GetTime(self, time, timestepIndex): # TODO: This assumes time in seconds? diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py index e2a8820..b006e91 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, _ToInt32Ids 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. @@ -223,10 +228,12 @@ def SetZUnit(self, zUnit: eumUnit): 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 - if self.__nodes is not None and self.__nodes.x.size != nodeIds.size: + nodeIds = _ToInt32Ids(nodeIds, "Node") + if ( + nodeIds is not None + and 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 +277,16 @@ 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 (self.__connectivity is not None) and ( - len(self.__connectivity) != elementIds.size + elementIds = _ToInt32Ids(elementIds, "Element") + if ( + elementIds is not None + and 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,11 @@ def SetFromMeshFile(self, meshFile: MeshFile): raise ValueError("Mesh file has no projection, nodes or quantity") self.__dfsProjection = DfsProjection.Create(wktString) - self.__nodeIds = meshFile.NodeIds + self.__nodeIds = _ToInt32Ids(meshFile.NodeIds, "Node") self.__nodes = MeshNodes(x, y, z.astype(dtype=np.float32), code) self.__zUnit = quantity.Unit - self.__elementIds = meshFile.ElementIds + self.__elementIds = _ToInt32Ids(meshFile.ElementIds, "Element") self.__connectivity = meshFile.ElementTable def AddDynamicItem(self, itemName: str, quantity): @@ -400,8 +411,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 +641,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..b2fbd3a 100644 --- a/mikecore/MeshBuilder.py +++ b/mikecore/MeshBuilder.py @@ -18,6 +18,26 @@ class MeshNodes(NamedTuple): code: np.ndarray +def _ToInt32Ids(ids, name): + """Ids as the int32 array mesh and dfsu files store, rejecting ids that do not fit. + + None, for the default ids, is returned as is. Shared by MeshBuilder and + DfsuBuilder; name starts the error message. + """ + if ids is None: + return None + ids = np.asarray(ids) + if ids.ndim != 1: + raise ValueError(f"{name} ids must be a 1-D array, got {ids.ndim}-D") + # Rejects booleans, floats and Python ints too big for any numpy integer + if not np.issubdtype(ids.dtype, np.integer): + raise ValueError(f"{name} ids must be int32 integers, got {ids.dtype}") + int32 = np.iinfo(np.int32) + if np.any((ids < int32.min) | (ids > int32.max)): + raise ValueError(f"{name} ids must be integers that fit in int32") + return ids.astype(np.int32) + + class MeshBuilder: def __init__(self): self.__projectionString: str | None = None @@ -26,7 +46,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 +74,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,8 +120,11 @@ 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 (self.__connectivity is not None) and ( - len(self.__connectivity) != len(elementIds) + elementIds = _ToInt32Ids(elementIds, "Element") + if ( + elementIds is not None + and self.__connectivity is not None + and len(self.__connectivity) != len(elementIds) ): raise ValueError("Number of element id's does not match number of elements") self.__elementIds = 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 d2c0b88..9fdd843 100644 --- a/mikecore/eum.py +++ b/mikecore/eum.py @@ -1838,7 +1838,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..dfec61e 100644 --- a/tests/test_dfs_basic.py +++ b/tests/test_dfs_basic.py @@ -117,35 +117,70 @@ def test_read_static_item_after_read_static_item_next(): assert "Node id" == item.Name -def test_read_static_item_past_the_last_item(): +# OresundHD.dfsu has 9 static items. dfsFindItemStatic succeeds for 10 and +# fails from 11, so both are checked. +@pytest.mark.parametrize("staticItemNo", [10, 11, 100]) +def test_read_static_item_past_the_last_item(staticItemNo): dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu") - item = dfs.ReadStaticItem(10) - dfs.Close() + try: + with pytest.raises(ValueError, match="past the last static item"): + dfs.ReadStaticItem(staticItemNo) + first = dfs.ReadStaticItem(1) + finally: + dfs.Close() - assert item is None + assert first is not None + assert "Node id" == first.Name -@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") + + try: + with pytest.raises(ValueError, match="past the last static item"): + dfs.ReadStaticItem(staticItemNo) + first = dfs.ReadStaticItem(1) + finally: + dfs.Close() + + 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] diff --git a/tests/test_dfs_static_item.py b/tests/test_dfs_static_item.py index 5217b67..38b8af5 100644 --- a/tests/test_dfs_static_item.py +++ b/tests/test_dfs_static_item.py @@ -9,13 +9,8 @@ def test_static_item(self): dfsFile = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu") staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 assert staticItems != None assert 9 == len(staticItems) diff --git a/tests/test_dfsbuilder.py b/tests/test_dfsbuilder.py index 681b77f..dd63773 100644 --- a/tests/test_dfsbuilder.py +++ b/tests/test_dfsbuilder.py @@ -337,36 +337,35 @@ 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(): +def test_dynamic_item_builder_must_be_set_again_after_GetDynamicItemInfo(): factory = DfsFactory() - quantity = eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter) item = DfsBuilder.Create().CreateDynamicItemBuilder() - item.Set("Value", quantity, DfsSimpleType.Float) + item.Set( + "Value", + eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter), + DfsSimpleType.Float, + ) item.SetValueType(DataValueType.Instantaneous) item.SetAxis(factory.CreateAxisEqD0()) - first = item.GetDynamicItemInfo() + item.GetDynamicItemInfo() - item.SetAxis(factory.CreateAxisEqD0()) - second = item.GetDynamicItemInfo() - - assert first is not second - assert "Value" == second.Name - assert second.Quantity is not None - assert eumItem.eumIWaterLevel == second.Quantity.Item - assert eumUnit.eumUmeter == second.Quantity.Unit - assert DataValueType.Instantaneous == second.ValueType + with pytest.raises(Exception, match="Name, Quantity and DataType has not been set"): + item.GetDynamicItemInfo() + + +def test_static_item_builder_must_be_set_again_after_GetStaticItem(): + staticItem = DfsStaticItemBuilder() + staticItem.Set( + "Item", + eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined), + DfsSimpleType.Float, + ) + staticItem.SetAxis(DfsFactory().CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1)) + staticItem.SetData(np.array([1.0, 2.0], dtype=np.float32)) + staticItem.GetStaticItem() + + with pytest.raises(Exception, match="Data has not been set"): + staticItem.GetStaticItem() def test_reference_coordinates_and_orientation_are_read_back(tmp_path): diff --git a/tests/test_dfsu2D.py b/tests/test_dfsu2D.py index 5747cd9..aabb479 100644 --- a/tests/test_dfsu2D.py +++ b/tests/test_dfsu2D.py @@ -581,13 +581,8 @@ def DynamicItemTester(dfsFile): def StaticItemTester(dfsFile): staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 Assert.IsNotNull(staticItems) Assert.AreEqual(9, len(staticItems)) @@ -905,13 +900,8 @@ def DynamicItemTester(dfsFile): def StaticItemTester(dfsFile): staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 Assert.IsNotNull(staticItems) Assert.AreEqual(9, len(staticItems)) diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py index 45d50f1..95afedd 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,149 @@ 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 integer id per node or element, so a 2-D array or a scalar is rejected even +# when its size fits, and so are booleans and floats, even whole ones +@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], "integers, got bool"), + ([1.0, 2.0, 3.0, 4.0], [10.0, 20.0], "integers, got float64"), + ([1.5, 2.0, 3.0, 4.0], [10.5, 20.0], "integers, got float64"), + ], +) +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 +503,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 +523,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_dfsu_file.py b/tests/test_dfsu_file.py index 10ba0dc..1722f36 100644 --- a/tests/test_dfsu_file.py +++ b/tests/test_dfsu_file.py @@ -264,13 +264,8 @@ def CustomBlockTester(dfsFile, original): def StaticItemTester(dfsFile, original): staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 Assert.IsNotNull(staticItems) Assert.AreEqual(9, len(staticItems)) @@ -500,13 +495,8 @@ def CustomBlockTester(dfsFile, original): def StaticItemTester(dfsFile, original): staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 Assert.IsNotNull(staticItems) Assert.AreEqual(9, len(staticItems)) @@ -685,13 +675,8 @@ def DynamicItemTester(dynamicItemInfos): def StaticItemTester(dfsFile, datamanager): staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 Assert.IsNotNull(staticItems) Assert.AreEqual(9, len(staticItems)) @@ -952,13 +937,8 @@ def DynamicItemTester(dynamicItemInfos): def StaticItemTester(dfsFile, datamanager): staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 Assert.IsNotNull(staticItems) Assert.AreEqual(9, len(staticItems)) @@ -1268,13 +1248,8 @@ def DynamicItemTester(dynamicItemInfos): def StaticItemTester(dfsFile, datamanager): staticItems = [] - staticItemNumber = 1 - while True: - varstaticItem = dfsFile.ReadStaticItem(staticItemNumber) - if varstaticItem is None: - break + while (varstaticItem := dfsFile.ReadStaticItemNext()) is not None: staticItems.append(varstaticItem) - staticItemNumber += 1 Assert.IsNotNull(staticItems) Assert.AreEqual(9, len(staticItems)) 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..bd69193 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,61 @@ 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 integer id per element, so a 2-D array or a scalar is rejected even when its +# size fits, and so are booleans and floats, even whole ones +@pytest.mark.parametrize( + "elementIds, match", + [ + (np.array([[10], [20]]), "1-D"), + (7, "1-D"), + ([True, False], "integers, got bool"), + ([10.0, 20.0], "integers, got float64"), + ([10.5, 20.0], "integers, got float64"), + ], +) +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):