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):