diff --git a/.github/workflows/type_check.yml b/.github/workflows/type_check.yml
index 2e28f25..d4b2873 100644
--- a/.github/workflows/type_check.yml
+++ b/.github/workflows/type_check.yml
@@ -37,7 +37,7 @@ jobs:
# (the step above checks it); if it crashes, the JSON parse fails.
shell: bash
env:
- MAX_WARNINGS: 330
+ MAX_WARNINGS: 325
run: |
warnings=$( (uv run pyrefly check --output-format=json --min-severity=warn --summary=none || true) | uv run python -c "import json, sys; print(sum(e['severity'] == 'warn' for e in json.load(sys.stdin)['errors']))")
echo "pyrefly warnings: $warnings (ceiling $MAX_WARNINGS)"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6840189..a91f71a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -78,5 +78,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#47](https://github.com/DHI/mikecore-python/issues/47)).
- Reading an item's value type raises the DFS error when the native library
reports one, instead of returning `DataValueType.Instantaneous`.
+- `eumWrapper.eumGetNextEqvUnit` returns the units equivalent to the base unit.
+ It read the native return code the wrong way round, so it reported no unit
+ where there was one, and raised `ValueError` after the last one, where the
+ native description is null.
+- `DfsuBuilder.SetElementIds` stores the ids, so the file has them instead of
+ the default 1, 2, .... `SetElementIds` and `SetNodeIds` accept int64 arrays
+ and lists and store int32 ids, raising `ValueError` for ids that do not fit in
+ int32. Int64 node ids raised "Data type not supported: int64" in
+ `CreateFile`. `SetElementIds(None)` goes back to the default ids, as
+ `SetNodeIds(None)` does. `SetFromMeshFile` stores a mesh's ids as int32 too,
+ so a mesh from `MeshFile.Create` with int64 ids can be written.
+- `MeshBuilder.SetElementIds(None)` goes back to the default ids instead of
+ raising `TypeError` after `SetElements`, and ids that do not fit in int32
+ raise `ValueError`. They were written to a mesh file that `MeshFile.ReadMesh`
+ could not read.
+- `MeshBuilder.CreateMesh()` makes its default node and element ids int32, as
+ `MeshFile.ReadMesh` reads them. They were int64 on Linux, so the id dtype of
+ a built mesh depended on whether ids were set.
+- The id setters of `DfsuBuilder` and `MeshBuilder` raise `ValueError` for ids
+ that are not a 1-D array, or are booleans. A 2-D array whose size matched
+ the number of nodes or elements, and booleans as 0 and 1, were accepted.
+- `MeshBuilder.SetNodes` keeps z as doubles, as mesh files store it, instead of
+ rounding it to float32. Float32 z passed to `SetNodes`, such as
+ `DfsuFile.Z`, is now written with the double text of those float32 values
+ (`-1.7859922647476196` rather than `-1.7859923`). `MeshBuilder.Create(dfsuFile)`
+ is unchanged.
+- `miketools.Dfs0ToAscii` converts item times from the time axis unit to
+ seconds, so a file with a time unit other than seconds gets the right
+ timestamps. A 10 minute step was written as 10 seconds.
+- `DfsFile.ReadStaticItem` raises `ValueError` for an item number below 1. It
+ returned the first static item, numbered 0 or below. A number past the last
+ item still returns `None`, and so does a number too large for int32, which
+ returned another item or raised `ctypes.ArgumentError`.
+- `DfsFile.ReadItemTimeStepNext` and `ReadItemTimeStep` with `reshape=True`
+ reshape an item with a 2-D or 3-D spatial axis to the axis shape, as
+ `Dfs2File` and `Dfs3File` do. A file opened with
+ `DfsFileFactory.DfsGenericOpen` ignored `reshape`. 0-D and 1-D items stay
+ flat.
+- A `DfsDynamicItemBuilder` can be reused after `GetDynamicItemInfo`. It
+ replaced its item with a blank one but kept its "is set" flags, so the next
+ item passed validation with no name, quantity or value type. The builder now
+ keeps its settings for the next item, and changing it no longer changes the
+ item it returned. Associated static items carry over and add up: after an
+ item with static item 1, `SetAssociatedStaticItem(2)` gives the next item
+ `[1, 2]`.
+- Changing a `DfsStaticItemBuilder` after `GetStaticItem`, or the data array
+ passed to `SetData`, no longer changes the static item it returned. Each
+ returned item has its own copy of the data.
+- `DfsuBuilder` creates `DfsuSpectral0D` files. It raised an `Exception` with
+ no message. The caller supplies the frequency-direction mesh, one element per
+ frequency and direction, and `Validate` reports a Spectral0D builder with no
+ frequencies or directions, or an element count that does not match them, so
+ the file reads back as `DfsuSpectral0D`.
+- `DfsuBuilder.SetFrequencies` and `SetDirections` accept lists and store the
+ values as doubles, as dfsu files store frequencies and directions. A list
+ passed `Validate` and then failed in `CreateFile` with `AttributeError`,
+ leaving a half-written file, and float32 arrays were written as floats.
[Unreleased]: https://github.com/DHI/mikecore-python/compare/v0.3.a1...HEAD
diff --git a/mikecore/DfsBuilder.py b/mikecore/DfsBuilder.py
index 1150ee4..bdaf4b6 100644
--- a/mikecore/DfsBuilder.py
+++ b/mikecore/DfsBuilder.py
@@ -1,3 +1,4 @@
+import copy
from typing import Generic, TypeVar
from mikecore.DfsDLL import DfsDLL
@@ -693,7 +694,11 @@ def GetDynamicItemInfo(self):
# Create and store a clone, such that item-builder can be reused when creating more than one item
# Otherwise updates to the new item-builder would just update the same item.
res = self.ItemInfo
- self.ItemInfo = DfsDynamicItemInfo()
+ self.ItemInfo = copy.copy(res)
+ if res.AssociatedStaticItemNumbers is not None:
+ self.ItemInfo.AssociatedStaticItemNumbers = list(
+ res.AssociatedStaticItemNumbers
+ )
return res
@@ -747,4 +752,8 @@ def GetStaticItem(self):
msgs = DfsBuilder.ErrorMessage(errors)
raise Exception(msgs)
- return self.ItemInfo
+ # Return a clone with its own data, so neither reusing the builder nor
+ # changing the array given to SetData changes the item returned here.
+ res = copy.copy(self.ItemInfo)
+ res.Data = copy.copy(self.ItemInfo.Data)
+ return res
diff --git a/mikecore/DfsFile.py b/mikecore/DfsFile.py
index 1e987e3..6d37424 100644
--- a/mikecore/DfsFile.py
+++ b/mikecore/DfsFile.py
@@ -945,10 +945,18 @@ def ReadStaticItem(self, staticItemNo):
:param staticItemNo: Number of static item in the file to read. First static item has number 1
:returns: The static item number , null if there are not that many static items.
+ :raises ValueError: If is below 1.
"""
self.__CheckIfOpen()
if self.fpState == DfsFilePointerState.CreatingItems:
raise Exception("Can not read static items when file is being created.")
+ if staticItemNo < 1:
+ raise ValueError(
+ f"Static item number must be 1 or more, got {staticItemNo}"
+ )
+ # dfsFindItemStatic takes an int32, and a larger number is past the last item
+ if staticItemNo > np.iinfo(np.int32).max:
+ return None
if (
self.fpState != DfsFilePointerState.StaticItem
@@ -1064,6 +1072,12 @@ def ReadItemTimeStepNext(
res = itemData
res.Time = time
res.TimeStepIndex = self.fpTimeStepIndex
+ # A 0-D axis has shape (0,) while holding one value, so only reshape 2-D and
+ # 3-D. GetItemSpatialAxis can return None by its type, though a file with an
+ # axis it does not read, such as EqD4, already fails to open; stay flat then.
+ axis = item.SpatialAxis
+ if reshape and axis is not None and len(shape := axis.Shape) >= 2:
+ res.Data = res.Data.reshape(shape, order="F")
self.__FpDynamicIncrement()
return res
diff --git a/mikecore/DfsuBuilder.py b/mikecore/DfsuBuilder.py
index e2a8820..9f52240 100644
--- a/mikecore/DfsuBuilder.py
+++ b/mikecore/DfsuBuilder.py
@@ -3,7 +3,7 @@
import numpy as np
from mikecore.DfsFile import DfsProjection, DfsTemporalAxis
from mikecore.MeshFile import MeshFile
-from mikecore.MeshBuilder import MeshNodes
+from mikecore.MeshBuilder import MeshNodes, _Int32Ids
from mikecore.DfsFactory import DfsFactory
from mikecore.DfsBuilder import DfsBuilder
from mikecore.DfsuFile import (
@@ -74,8 +74,8 @@ def __init__(self, dfsuFileType):
self.__dynamicItemData = []
# spectral
- self.__frequencies: Sequence[float] | np.ndarray | None = None
- self.__directions: Sequence[float] | np.ndarray | None = None
+ self.__frequencies: np.ndarray | None = None
+ self.__directions: np.ndarray | None = None
self.__dfsuFileType = dfsuFileType
if dfsuFileType == DfsuFileType.Dfsu2D:
@@ -126,10 +126,15 @@ def SetNumberOfSigmaLayers(self, numberOfSigmaLayers: int):
raise Exception("dfsuFileType")
def SetFrequencies(self, frequencies):
- self.__frequencies = frequencies
+ # As an array, since the static item writer needs one
+ self.__frequencies = (
+ None if frequencies is None else np.asarray(frequencies, dtype=np.float64)
+ )
def SetDirections(self, directions):
- self.__directions = directions
+ self.__directions = (
+ None if directions is None else np.asarray(directions, dtype=np.float64)
+ )
# /
# / Set a non-standard temporal axis for the dfsu file. WARNING: The dfsu file will not be valid in all contexts.
@@ -226,6 +231,7 @@ def SetNodeIds(self, nodeIds):
if nodeIds is None:
self.__nodeIds = None
return
+ nodeIds = _Int32Ids(nodeIds, "Node")
if self.__nodes is not None and self.__nodes.x.size != nodeIds.size:
raise Exception(
"Number of node id's does not match number of nodes", "nodeIds"
@@ -270,12 +276,17 @@ def SetElements(self, connectivity):
def SetElementIds(self, elementIds):
"""Set the element id's. Optional. If not set, default values are used (1,2,3,...)"""
+ if elementIds is None:
+ self.__elementIds = None
+ return
+ elementIds = _Int32Ids(elementIds, "Element")
if (self.__connectivity is not None) and (
len(self.__connectivity) != elementIds.size
):
raise Exception(
"Number of element id's does not match number of elements", "elementIds"
)
+ self.__elementIds = elementIds
def SetFromMeshFile(self, meshFile: MeshFile):
"""Set projection, nodes and elements from mesh file.
@@ -295,11 +306,15 @@ def SetFromMeshFile(self, meshFile: MeshFile):
raise ValueError("Mesh file has no projection, nodes or quantity")
self.__dfsProjection = DfsProjection.Create(wktString)
- self.__nodeIds = meshFile.NodeIds
+ nodeIds = meshFile.NodeIds
+ self.__nodeIds = None if nodeIds is None else _Int32Ids(nodeIds, "Node")
self.__nodes = MeshNodes(x, y, z.astype(dtype=np.float32), code)
self.__zUnit = quantity.Unit
- self.__elementIds = meshFile.ElementIds
+ elementIds = meshFile.ElementIds
+ self.__elementIds = (
+ None if elementIds is None else _Int32Ids(elementIds, "Element")
+ )
self.__connectivity = meshFile.ElementTable
def AddDynamicItem(self, itemName: str, quantity):
@@ -400,8 +415,26 @@ def Validate(self, dieOnError: bool = False):
errors.append(
"The minimum number of layers is smaller than the number of sigma layers. Element table is invalid"
)
+ elif self.__dfsuFileType == DfsuFileType.DfsuSpectral0D:
+ # The mesh is the frequency-direction (rose-plot) geometry, one element
+ # per frequency and direction, as DfsuFile reads it back
+ if self.__frequencies is None and self.__directions is None:
+ errors.append(
+ "Frequencies or directions must be set for a spectral 0D file"
+ )
+ else:
+ numberOfSpectralValues = 1
+ if self.__frequencies is not None:
+ numberOfSpectralValues *= len(self.__frequencies)
+ if self.__directions is not None:
+ numberOfSpectralValues *= len(self.__directions)
+ if len(connectivity) != numberOfSpectralValues:
+ errors.append(
+ f"Number of elements ({len(connectivity)}) must equal the "
+ "number of frequencies times the number of directions "
+ f"({numberOfSpectralValues}) for a spectral 0D file"
+ )
elif self.__dfsuFileType in (
- DfsuFileType.DfsuSpectral0D,
DfsuFileType.DfsuSpectral1D,
DfsuFileType.DfsuSpectral2D,
):
@@ -612,8 +645,29 @@ def SetupBuilder(self):
np.int32,
),
)
+ elif self.__dfsuFileType == DfsuFileType.DfsuSpectral0D:
+ # 2 dimensions for a frequency-direction mesh, 1 for frequencies or directions
+ dimensions = (
+ 2
+ if self.__frequencies is not None and self.__directions is not None
+ else 1
+ )
+ dfsBuilder.AddCreateCustomBlock(
+ "MIKE_FM",
+ np.array(
+ [
+ nodes.x.size,
+ len(connectivity),
+ dimensions,
+ 0,
+ numberOfFrequencies,
+ numberOfDirections,
+ ],
+ np.int32,
+ ),
+ )
else:
- raise Exception()
+ raise Exception(f"Dfsu file type {self.__dfsuFileType} not supported")
# For the files with a vertical dimension, the first dynamic item is the Z-coordinate
if (
diff --git a/mikecore/MeshBuilder.py b/mikecore/MeshBuilder.py
index 33bded5..a1518e7 100644
--- a/mikecore/MeshBuilder.py
+++ b/mikecore/MeshBuilder.py
@@ -18,6 +18,25 @@ class MeshNodes(NamedTuple):
code: np.ndarray
+def _Int32Ids(ids, name):
+ """Ids as the int32 array mesh and dfsu files store, rejecting ids that do not fit.
+
+ Shared by MeshBuilder and DfsuBuilder; name starts the error message.
+ """
+ ids = np.asarray(ids)
+ if ids.ndim != 1:
+ raise ValueError(f"{name} ids must be a 1-D array, got {ids.ndim}-D")
+ if ids.dtype == np.bool_:
+ raise ValueError(f"{name} ids must be integers, not booleans")
+ try:
+ int32Ids = ids.astype(np.int32)
+ except OverflowError:
+ int32Ids = None
+ if int32Ids is None or np.any(int32Ids != ids):
+ raise ValueError(f"{name} ids must be integers that fit in int32")
+ return int32Ids
+
+
class MeshBuilder:
def __init__(self):
self.__projectionString: str | None = None
@@ -26,7 +45,7 @@ def __init__(self):
self.__nodeIds: np.ndarray | None = None
self.__nodes: MeshNodes | None = None
- self.__elementIds: Sequence[int] | np.ndarray | None = None
+ self.__elementIds: np.ndarray | None = None
self.__connectivity: Sequence | np.ndarray | None = None
def SetProjection(self, projection):
@@ -54,7 +73,7 @@ def SetNodes(self, x, y, z, code):
except Exception as err:
raise TypeError("y must be array of float") from err
try:
- z = np.array(z, dtype=np.float32)
+ z = np.array(z, dtype=np.float64)
except Exception as err:
raise TypeError("z must be array of float") from err
try:
@@ -100,6 +119,10 @@ def SetElements(self, connectivity):
def SetElementIds(self, elementIds):
"""Set the element id's. Optional. If not set, default values are used (1,2,3,...)"""
+ if elementIds is None:
+ self.__elementIds = None
+ return
+ elementIds = _Int32Ids(elementIds, "Element")
if (self.__connectivity is not None) and (
len(self.__connectivity) != len(elementIds)
):
@@ -149,13 +172,13 @@ def CreateMesh(self) -> MeshFile:
if self.__eumQuantity is None:
self.__eumQuantity = eumQuantity(eumItem.eumIBathymetry, eumUnit.eumUmeter)
- # Creating default node id's, if empty
+ # Creating default node id's, if empty, as int32 like MeshFile.ReadMesh
if self.__nodeIds is None:
- self.__nodeIds = np.arange(len(nodes.x)) + 1
+ self.__nodeIds = np.arange(1, len(nodes.x) + 1, dtype=np.int32)
# Creating default element id's, if empty
if self.__elementIds is None:
- self.__elementIds = np.arange(len(connectivity)) + 1
+ self.__elementIds = np.arange(1, len(connectivity) + 1, dtype=np.int32)
# Creating additional element information
elementType = np.zeros(len(connectivity), dtype=np.int32)
diff --git a/mikecore/eum.py b/mikecore/eum.py
index 4e4cb8e..d96feef 100644
--- a/mikecore/eum.py
+++ b/mikecore/eum.py
@@ -1843,7 +1843,7 @@ def eumGetNextEqvUnit(
rc = eumDLL.Wrapper.eumGetNextEqvUnit(
baseunitKey, PrevunitKey, ctypes.byref(unitKey), ctypes.byref(lpUnitDesc)
)
- if rc != 0:
+ if rc == 0:
return False, eumUnit.eumUUnitUndefined, ""
return True, eumUnit(unitKey.value), eumWrapper.DecodeString(lpUnitDesc)
diff --git a/miketools/Dfs0ToAscii.py b/miketools/Dfs0ToAscii.py
index 53025d7..1f023b1 100644
--- a/miketools/Dfs0ToAscii.py
+++ b/miketools/Dfs0ToAscii.py
@@ -7,6 +7,10 @@ def Dfs0ToAscii(dfs0FileName, txtFileName):
"""Writes dfs0 data to text file in similar format as other MIKE Zero tools"""
dfs = DfsFileFactory.DfsGenericOpen(dfs0FileName)
+ timeAxis = dfs.FileInfo.TimeAxis
+ if timeAxis is None:
+ dfs.Close()
+ raise Exception(f"{dfs0FileName} has no time axis")
txt = open(txtFileName, "w")
txt.write(" ")
@@ -37,12 +41,12 @@ def Dfs0ToAscii(dfs0FileName, txtFileName):
# Only set for calendar time axes; otherwise times are written as offsets.
startDateTime = None
if (
- dfs.FileInfo.TimeAxis.TimeAxisType == TimeAxisType.CalendarEquidistant
- or dfs.FileInfo.TimeAxis.TimeAxisType == TimeAxisType.CalendarNonEquidistant
+ timeAxis.TimeAxisType == TimeAxisType.CalendarEquidistant
+ or timeAxis.TimeAxisType == TimeAxisType.CalendarNonEquidistant
):
- startDateTime = dfs.FileInfo.TimeAxis.StartDateTime
+ startDateTime = timeAxis.StartDateTime
- for i in range(dfs.FileInfo.TimeAxis.NumberOfTimeSteps):
+ for i in range(timeAxis.NumberOfTimeSteps):
for j in range(len(dfs.ItemInfo)):
itemData = dfs.ReadItemTimeStepNext()
if itemData is None:
@@ -51,8 +55,9 @@ def Dfs0ToAscii(dfs0FileName, txtFileName):
)
if j == 0:
if startDateTime is not None:
- # TODO: Time unit is not always seconds
- itemTime = startDateTime + timedelta(seconds=itemData.Time)
+ itemTime = startDateTime + timedelta(
+ seconds=timeAxis.ToSeconds(itemData.Time)
+ )
# Depending on the format to write to the file:
# txt.write(itemTime.strftime("%Y-%m-%d %H:%M:%S")); # Seconds accuracy
txt.write(
diff --git a/tests/test_dfs_basic.py b/tests/test_dfs_basic.py
index fb70f39..fe9982a 100644
--- a/tests/test_dfs_basic.py
+++ b/tests/test_dfs_basic.py
@@ -1,6 +1,7 @@
import ctypes
import pytest
+from numpy.testing import assert_array_equal
from mikecore.DfsDLL import DfsDLL
from mikecore.DfsFile import DfsDLLUtil, DfsFile, DfsFileMode, TimeAxisType
@@ -126,26 +127,51 @@ def test_read_static_item_past_the_last_item():
assert item is None
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "Suspected bug: ReadStaticItem does not check the item number and ignores "
- "the return code of dfsFindItemStatic, so ReadStaticItem(0) returns the "
- "first static item ('Node id') with ItemNumber 0. Static item numbers start "
- "at 1. Correct: reject numbers below 1 with a ValueError, as "
- "__DynamicItemInfoReadAndCreate does for dynamic items."
- ),
-)
-def test_read_static_item_zero():
+# Numbers that do not fit in the native int32 are past the last item too
+@pytest.mark.parametrize("staticItemNo", [2**31 - 1, 2**31, 2**32 + 1, 2**64])
+def test_read_static_item_past_int32(staticItemNo):
+ dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu")
+
+ item = dfs.ReadStaticItem(staticItemNo)
+ first = dfs.ReadStaticItem(1)
+ dfs.Close()
+
+ assert item is None
+ assert first is not None
+ assert "Node id" == first.Name
+
+
+@pytest.mark.parametrize("staticItemNo", [0, -1])
+def test_read_static_item_below_one(staticItemNo):
dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu")
try:
with pytest.raises(ValueError):
- dfs.ReadStaticItem(0)
+ dfs.ReadStaticItem(staticItemNo)
finally:
dfs.Close()
+# ReadStaticItemNext is the oracle for which item each number from 1 up gives
+def test_read_static_item_reads_every_item_by_number():
+ dfs = DfsFileFactory.DfsGenericOpen("testdata/OresundHD.dfsu")
+ expected = []
+ while (item := dfs.ReadStaticItemNext()) is not None:
+ expected.append(item.Name)
+
+ # Backwards, so each read has to find its item rather than take the next
+ names = {}
+ for n in range(len(expected), 0, -1):
+ item = dfs.ReadStaticItem(n)
+ assert item is not None
+ assert n == item.ItemNumber
+ names[n] = item.Name
+ dfs.Close()
+
+ assert 9 == len(expected)
+ assert expected == [names[n] for n in range(1, len(expected) + 1)]
+
+
def test_item_quantity_descriptions():
dfs = DfsFileFactory.DfsGenericOpen("testdata/TemporalEqCal.dfs0")
quantities = [item.Quantity for item in dfs.ItemInfo]
@@ -153,3 +179,59 @@ def test_item_quantity_descriptions():
assert ["Water Level", "Water Depth"] == [q.ItemDescription for q in quantities]
assert ["meter", "meter"] == [q.UnitDescription for q in quantities]
+
+
+@pytest.mark.parametrize(
+ "filename, openTyped",
+ [
+ ("testdata/OresundHD.dfs2", DfsFileFactory.Dfs2FileOpen),
+ ("testdata/OresundHD.dfs3", DfsFileFactory.Dfs3FileOpen),
+ ],
+)
+def test_generic_read_reshapes_like_dfs123(filename, openTyped):
+ typed = openTyped(filename)
+ expected = typed.ReadItemTimeStep(1, 0, reshape=True).Data
+ typed.Close()
+
+ dfs = DfsFileFactory.DfsGenericOpen(filename)
+ shape = dfs.ItemInfo[0].SpatialAxis.Shape
+ nextItemData = dfs.ReadItemTimeStepNext(reshape=True)
+ itemData = dfs.ReadItemTimeStep(1, 0, reshape=True)
+ dfs.Close()
+
+ assert len(shape) >= 2
+ assert shape == expected.shape
+ assert nextItemData is not None
+ assert_array_equal(expected, nextItemData.Data)
+ assert itemData is not None
+ assert_array_equal(expected, itemData.Data)
+
+
+# A 0-D axis has Shape (0,) but holds one value, and a 1-D axis is already flat
+@pytest.mark.parametrize(
+ "filename, size",
+ [("testdata/TemporalEqCal.dfs0", 1), ("testdata/OresundHD.dfsu", 3636)],
+)
+def test_generic_read_does_not_reshape_0d_or_1d(filename, size):
+ dfs = DfsFileFactory.DfsGenericOpen(filename)
+ itemData = dfs.ReadItemTimeStep(1, 0, reshape=True)
+ dfs.Close()
+
+ assert itemData is not None
+ assert (size,) == itemData.Data.shape
+
+
+@pytest.mark.parametrize(
+ "filename", ["testdata/OresundHD.dfs2", "testdata/OresundHD.dfs3"]
+)
+def test_generic_read_stays_flat_without_reshape(filename):
+ dfs = DfsFileFactory.DfsGenericOpen(filename)
+ size = dfs.ItemInfo[0].ElementCount
+ nextItemData = dfs.ReadItemTimeStepNext()
+ itemData = dfs.ReadItemTimeStep(1, 0)
+ dfs.Close()
+
+ assert nextItemData is not None
+ assert (size,) == nextItemData.Data.shape
+ assert itemData is not None
+ assert (size,) == itemData.Data.shape
diff --git a/tests/test_dfsbuilder.py b/tests/test_dfsbuilder.py
index 681b77f..d70ea7c 100644
--- a/tests/test_dfsbuilder.py
+++ b/tests/test_dfsbuilder.py
@@ -1,6 +1,7 @@
from datetime import datetime
import numpy as np
+from numpy.testing import assert_array_equal
from mikecore.DfsBuilder import (
DfsBuilder,
DfsSimpleType,
@@ -8,6 +9,7 @@
DfsStaticItemBuilder,
)
from mikecore.DfsFactory import DfsFactory
+from mikecore.DfsFile import UnitConversionType
from mikecore.DfsFileFactory import DfsFileFactory
from mikecore.eum import eumUnit, eumQuantity, eumItem
@@ -337,18 +339,6 @@ def test_associated_static_item_number_is_checked(tmp_path):
builder.CreateFile(str(tmp_path / "invalid.dfs0"))
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "Suspected bug: GetDynamicItemInfo replaces ItemInfo with a blank "
- "DfsDynamicItemInfo but leaves the builder's 'is set' flags True. A reused "
- "builder then passes Validate and returns an item with an empty name, no "
- "quantity and no ValueType, which fails later when added to a file. Its "
- "comment says it means to store a clone so the builder can be reused. "
- "Correct: the second item keeps the name, quantity and value type set "
- "before the first GetDynamicItemInfo call."
- ),
-)
def test_dynamic_item_builder_can_be_reused():
factory = DfsFactory()
quantity = eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter)
@@ -369,6 +359,172 @@ def test_dynamic_item_builder_can_be_reused():
assert DataValueType.Instantaneous == second.ValueType
+def test_reused_dynamic_item_builder_leaves_earlier_items_alone():
+ factory = DfsFactory()
+ item = DfsBuilder.Create().CreateDynamicItemBuilder()
+ item.Set(
+ "First",
+ eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter),
+ DfsSimpleType.Float,
+ )
+ item.SetValueType(DataValueType.Instantaneous)
+ item.SetAxis(factory.CreateAxisEqD0())
+ item.SetAssociatedStaticItem(1)
+ first = item.GetDynamicItemInfo()
+
+ item.Set(
+ "Second",
+ eumQuantity(eumItem.eumIWaterDepth, eumUnit.eumUfeet),
+ DfsSimpleType.Double,
+ )
+ item.SetValueType(DataValueType.Accumulated)
+ item.SetAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1))
+ item.SetReferenceCoordinates(1, 2, 3)
+ item.SetAssociatedStaticItem(2)
+ second = item.GetDynamicItemInfo()
+
+ assert "First" == first.Name
+ assert first.Quantity is not None
+ assert eumItem.eumIWaterLevel == first.Quantity.Item
+ assert DfsSimpleType.Float == first.DataType
+ assert DataValueType.Instantaneous == first.ValueType
+ assert 1 == first.ElementCount
+ assert np.float32(-1e-35) == first.ReferenceCoordinateX
+ assert [1] == first.AssociatedStaticItemNumbers
+ assert "Second" == second.Name
+ assert DataValueType.Accumulated == second.ValueType
+ assert 2 == second.ElementCount
+ assert [1, 2] == second.AssociatedStaticItemNumbers
+
+
+def test_reused_static_item_builder_leaves_earlier_items_alone():
+ factory = DfsFactory()
+ staticItem = DfsStaticItemBuilder()
+ staticItem.Set(
+ "First",
+ eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined),
+ DfsSimpleType.Float,
+ )
+ staticItem.SetAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1))
+ staticItem.SetData(np.array([1.0, 2.0], dtype=np.float32))
+ first = staticItem.GetStaticItem()
+
+ staticItem.Set(
+ "Second",
+ eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined),
+ DfsSimpleType.Float,
+ )
+ staticItem.SetReferenceCoordinates(5, 6, 7)
+ data = staticItem.ItemInfo.Data
+ assert data is not None
+ data[0] = 10.0
+ second = staticItem.GetStaticItem()
+
+ assert first is not second
+ assert "First" == first.Name
+ assert np.float32(-1e-35) == first.ReferenceCoordinateX
+ assert_array_equal([1.0, 2.0], first.Data)
+ assert "Second" == second.Name
+ assert 5 == second.ReferenceCoordinateX
+ assert_array_equal([10.0, 2.0], second.Data)
+
+ staticItem.SetData(np.array([3.0, 4.0], dtype=np.float32))
+ third = staticItem.GetStaticItem()
+
+ assert_array_equal([10.0, 2.0], second.Data)
+ assert_array_equal([3.0, 4.0], third.Data)
+
+
+def test_static_item_keeps_its_data_when_the_caller_changes_the_array():
+ data = np.array([1.0, 2.0], dtype=np.float32)
+ staticItem = DfsStaticItemBuilder()
+ staticItem.Set(
+ "Item",
+ eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined),
+ DfsSimpleType.Float,
+ )
+ staticItem.SetAxis(DfsFactory().CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1))
+ staticItem.SetData(data)
+ item = staticItem.GetStaticItem()
+
+ data[0] = 10.0
+
+ assert_array_equal([1.0, 2.0], item.Data)
+
+
+def _dynamic_item_builder():
+ factory = DfsFactory()
+ builder = DfsBuilder.Create().CreateDynamicItemBuilder()
+ builder.Set(
+ "First",
+ eumQuantity(eumItem.eumIWaterLevel, eumUnit.eumUmeter),
+ DfsSimpleType.Float,
+ )
+ builder.SetValueType(DataValueType.Instantaneous)
+ builder.SetAxis(factory.CreateAxisEqD0())
+ builder.SetAssociatedStaticItem(1)
+ setters = [
+ lambda: builder.SetValueType(DataValueType.Accumulated),
+ lambda: builder.SetAssociatedStaticItem(2),
+ ]
+ return builder, builder.GetDynamicItemInfo, setters
+
+
+def _static_item_builder():
+ builder = DfsStaticItemBuilder()
+ builder.Set(
+ "First",
+ eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined),
+ DfsSimpleType.Float,
+ )
+ builder.SetAxis(DfsFactory().CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1))
+ builder.SetData(np.array([1.0, 2.0], dtype=np.float32))
+
+ def changeData():
+ data = builder.ItemInfo.Data
+ assert data is not None
+ data[0] = 10.0
+
+ setters = [
+ lambda: builder.SetData(np.array([3.0, 4.0], dtype=np.float32)),
+ changeData,
+ ]
+ return builder, builder.GetStaticItem, setters
+
+
+# Every setter of either builder, then the getter again, must leave each
+# attribute of the item returned first as the same object with the same contents
+@pytest.mark.parametrize("makeBuilder", [_dynamic_item_builder, _static_item_builder])
+def test_no_builder_setter_changes_a_returned_item(makeBuilder):
+ factory = DfsFactory()
+ builder, getItem, setters = makeBuilder()
+ first = getItem()
+ before = {
+ name: (value, np.array(value) if isinstance(value, list | np.ndarray) else None)
+ for name, value in vars(first).items()
+ }
+
+ builder.Set(
+ "Second",
+ eumQuantity(eumItem.eumIWaterDepth, eumUnit.eumUfeet),
+ DfsSimpleType.Double,
+ )
+ builder.SetAxis(factory.CreateAxisEqD1(eumUnit.eumUmeter, 2, 0, 1))
+ builder.SetReferenceCoordinates(1, 2, 3)
+ builder.SetOrientation(4, 5, 6)
+ builder.SetUnitConversion(UnitConversionType.UbgConversion, eumUnit.eumUfeet)
+ builder.SetAxisUnitConversion(UnitConversionType.UbgConversion, eumUnit.eumUfeet)
+ for setter in setters:
+ setter()
+ getItem()
+
+ assert before.keys() == vars(first).keys()
+ for name, (value, contents) in before.items():
+ assert value is getattr(first, name), name
+ if contents is not None:
+ assert_array_equal(contents, value, err_msg=name)
+
+
def test_reference_coordinates_and_orientation_are_read_back(tmp_path):
factory = DfsFactory()
builder = DfsBuilder.Create("title", "application", 1)
diff --git a/tests/test_dfsu_builder.py b/tests/test_dfsu_builder.py
index 45d50f1..3d89358 100644
--- a/tests/test_dfsu_builder.py
+++ b/tests/test_dfsu_builder.py
@@ -9,6 +9,7 @@
from mikecore.DfsuBuilder import DfsuBuilder
from mikecore.DfsuFile import DfsuFile, DfsuFileType
from mikecore.eum import eumItem, eumQuantity, eumUnit
+from mikecore.MeshBuilder import MeshBuilder
from mikecore.MeshFile import MeshFile
# Two triangles over four nodes
@@ -172,20 +173,13 @@ def test_default_node_and_element_ids_count_from_one(tmp_path):
dfsu.Close()
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "Suspected bug: SetElementIds checks the length of the ids but never "
- "stores them, so the file gets the default ids 1, 2, ... without warning. "
- "SetNodeIds stores its ids. Correct: the ids passed are written."
- ),
-)
-def test_element_ids_are_written(tmp_path):
+@pytest.mark.parametrize("dtype", [np.int32, np.int64])
+def test_element_ids_are_written(tmp_path, dtype):
filename = str(tmp_path / "element_ids.dfsu")
builder = _builder()
builder.SetNodes(X, Y, Z, CODE)
builder.SetElements(ELEMENTS)
- builder.SetElementIds(np.array([10, 20], dtype=np.int32))
+ builder.SetElementIds(np.array([10, 20], dtype=dtype))
builder.CreateFile(filename).Close()
dfsu = DfsuFile.Open(filename)
@@ -195,6 +189,147 @@ def test_element_ids_are_written(tmp_path):
assert_array_equal([10, 20], elementIds)
+# np.array of Python ints is int64 on Linux, and the native library writes int32 ids
+@pytest.mark.parametrize("dtype", [np.int32, np.int64])
+def test_node_ids_are_written(tmp_path, dtype):
+ filename = str(tmp_path / "node_ids.dfsu")
+ nodeIds = np.arange(len(X), dtype=dtype) + 100
+ builder = _builder()
+ builder.SetNodes(X, Y, Z, CODE)
+ builder.SetElements(ELEMENTS)
+ builder.SetNodeIds(nodeIds)
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ readNodeIds = dfsu.NodeIds
+ dfsu.Close()
+
+ assert_array_equal(nodeIds, readNodeIds)
+
+
+# A mesh with no ids gets the defaults
+@pytest.mark.parametrize("withIds", [True, False])
+def test_ids_from_a_built_mesh_are_written(tmp_path, withIds):
+ filename = str(tmp_path / "from_mesh_builder.dfsu")
+ meshBuilder = MeshBuilder()
+ meshBuilder.SetProjection("NON-UTM")
+ meshBuilder.SetNodes(X, Y, Z, CODE)
+ meshBuilder.SetElements(ELEMENTS)
+ mesh = meshBuilder.CreateMesh()
+ if not withIds:
+ mesh.NodeIds = None
+ mesh.ElementIds = None
+ builder = _builder()
+ builder.SetFromMeshFile(mesh)
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ nodeIds, elementIds = dfsu.NodeIds, dfsu.ElementIds
+ dfsu.Close()
+
+ assert_array_equal([1, 2, 3, 4], nodeIds)
+ assert_array_equal([1, 2], elementIds)
+
+
+# MeshFile.Create keeps the ids it is given, here int64 as np.array gives on Linux
+def test_int64_ids_from_a_created_mesh_are_written(tmp_path):
+ filename = str(tmp_path / "from_mesh_create.dfsu")
+ mesh = MeshFile.Create(
+ eumQuantity(eumItem.eumIBathymetry, eumUnit.eumUmeter),
+ "NON-UTM",
+ np.array([11, 12, 13, 14], dtype=np.int64),
+ X,
+ Y,
+ Z,
+ CODE.astype(np.int32),
+ np.array([21, 22], dtype=np.int64),
+ np.array([21, 21], dtype=np.int32),
+ ELEMENTS,
+ )
+ builder = _builder()
+ builder.SetFromMeshFile(mesh)
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ nodeIds, elementIds = dfsu.NodeIds, dfsu.ElementIds
+ dfsu.Close()
+
+ assert_array_equal([11, 12, 13, 14], nodeIds)
+ assert_array_equal([21, 22], elementIds)
+
+
+def test_element_ids_set_to_none_use_the_defaults(tmp_path):
+ filename = str(tmp_path / "element_ids_none.dfsu")
+ builder = _builder()
+ builder.SetNodes(X, Y, Z, CODE)
+ builder.SetElements(ELEMENTS)
+ builder.SetElementIds(np.array([10, 20], dtype=np.int32))
+ builder.SetElementIds(None)
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ elementIds = dfsu.ElementIds
+ dfsu.Close()
+
+ assert_array_equal([1, 2], elementIds)
+
+
+INT32 = np.iinfo(np.int32)
+
+
+# The smallest and largest int32 are written; one past either end is rejected
+def test_int32_bounds_are_written(tmp_path):
+ filename = str(tmp_path / "int32_ids.dfsu")
+ nodeIds = [INT32.min, -1, 0, INT32.max]
+ elementIds = [INT32.max, INT32.min]
+ builder = _builder()
+ builder.SetNodes(X, Y, Z, CODE)
+ builder.SetElements(ELEMENTS)
+ builder.SetNodeIds(nodeIds)
+ builder.SetElementIds(elementIds)
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ readNodeIds, readElementIds = dfsu.NodeIds, dfsu.ElementIds
+ dfsu.Close()
+
+ assert_array_equal(nodeIds, readNodeIds)
+ assert_array_equal(elementIds, readElementIds)
+
+
+@pytest.mark.parametrize("outside", [INT32.max + 1, INT32.min - 1, 2**40, 2**64])
+def test_ids_outside_int32_are_rejected(outside):
+ builder = _builder()
+ builder.SetNodes(X, Y, Z, CODE)
+ builder.SetElements(ELEMENTS)
+
+ with pytest.raises(ValueError, match="int32"):
+ builder.SetNodeIds(np.array([1, 2, 3, outside]))
+ with pytest.raises(ValueError, match="int32"):
+ builder.SetElementIds([1, outside])
+
+
+# One id per node or element, so a 2-D array or a scalar is rejected even when its
+# size fits
+@pytest.mark.parametrize(
+ "nodeIds, elementIds, match",
+ [
+ (np.array([[1, 2], [3, 4]]), np.array([[10], [20]]), "1-D"),
+ (7, 7, "1-D"),
+ ([True, False, True, False], [True, False], "not booleans"),
+ ],
+)
+def test_ids_must_be_a_1d_integer_array(nodeIds, elementIds, match):
+ builder = _builder()
+ builder.SetNodes(X, Y, Z, CODE)
+ builder.SetElements(ELEMENTS)
+
+ with pytest.raises(ValueError, match=match):
+ builder.SetNodeIds(nodeIds)
+ with pytest.raises(ValueError, match=match):
+ builder.SetElementIds(elementIds)
+
+
# Node numbers start at 1, so 0 is below the first node
@pytest.mark.parametrize("nodeNumber", [0, len(X) + 1])
def test_validate_reports_node_number_outside_the_nodes(nodeNumber):
@@ -366,18 +501,6 @@ def test_temporal_axis_is_written(tmp_path):
dfsu.Close()
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "Suspected bug: Validate accepts DfsuSpectral0D, but SetupBuilder has no "
- "branch for it and raises an Exception with no message. DfsuFile reads a "
- "Spectral0D file as data type 2001 with a 6-value MIKE_FM block "
- "[nodes, elements, 2, 0, frequencies, directions], over a mesh with one "
- "element per frequency-direction pair. Correct: the builder writes that, "
- "and the file reads back as DfsuSpectral0D with one value per frequency "
- "and direction."
- ),
-)
def test_spectral_0d_file_has_one_spectrum(tmp_path):
filename = str(tmp_path / "spectral0d.dfsu")
frequencies = np.array([0.1, 0.2])
@@ -398,3 +521,106 @@ def test_spectral_0d_file_has_one_spectrum(tmp_path):
assert_array_equal(directions, dfsu.Directions)
assert len(frequencies) * len(directions) == dfsu.ItemInfo[0].ElementCount
dfsu.Close()
+
+
+# One triangle per frequency: a strip of 3 triangles over 5 nodes
+SPECTRAL_0D_X = [0.0, 1.0, 2.0, 0.0, 1.0]
+SPECTRAL_0D_Y = [0.0, 0.0, 0.0, 1.0, 1.0]
+SPECTRAL_0D_ELEMENTS = [[1, 2, 5], [1, 5, 4], [2, 3, 5]]
+
+
+def _spectral_0d_builder():
+ builder = _builder(DfsuFileType.DfsuSpectral0D)
+ builder.SetNodes(
+ SPECTRAL_0D_X,
+ SPECTRAL_0D_Y,
+ np.zeros(len(SPECTRAL_0D_X)),
+ np.zeros(len(SPECTRAL_0D_X), dtype=np.int32),
+ )
+ builder.SetElements(SPECTRAL_0D_ELEMENTS)
+ return builder
+
+
+def test_spectral_0d_file_with_only_frequencies(tmp_path):
+ filename = str(tmp_path / "spectral0d_frequencies.dfsu")
+ frequencies = np.array([0.1, 0.2, 0.3])
+ builder = _spectral_0d_builder()
+ builder.SetFrequencies(frequencies)
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ assert DfsuFileType.DfsuSpectral0D is dfsu.DfsuFileType
+ assert_array_equal(frequencies, dfsu.Frequencies)
+ assert 0 == dfsu.NumberOfDirections
+ assert len(frequencies) == dfsu.ItemInfo[0].ElementCount
+ dfsu.Close()
+
+
+def test_spectral_0d_file_with_only_directions(tmp_path):
+ filename = str(tmp_path / "spectral0d_directions.dfsu")
+ directions = np.array([0.0, 1.0, 2.0])
+ builder = _spectral_0d_builder()
+ builder.SetDirections(directions)
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ assert DfsuFileType.DfsuSpectral0D is dfsu.DfsuFileType
+ assert_array_equal(directions, dfsu.Directions)
+ assert 0 == dfsu.NumberOfFrequencies
+ assert len(directions) == dfsu.ItemInfo[0].ElementCount
+ dfsu.Close()
+
+
+# Lists pass Validate, so CreateFile must write them too
+@pytest.mark.parametrize(
+ "frequencies, directions", [([0.1, 0.2, 0.3], None), (None, [0.0, 1.0, 2.0])]
+)
+def test_spectral_0d_file_from_lists(tmp_path, frequencies, directions):
+ filename = str(tmp_path / "spectral0d_lists.dfsu")
+ builder = _spectral_0d_builder()
+ if frequencies is not None:
+ builder.SetFrequencies(frequencies)
+ if directions is not None:
+ builder.SetDirections(directions)
+ assert [] == builder.Validate()
+ builder.CreateFile(filename).Close()
+
+ dfsu = DfsuFile.Open(filename)
+ readFrequencies, readDirections = dfsu.Frequencies, dfsu.Directions
+ fileType = dfsu.DfsuFileType
+ dfsu.Close()
+
+ assert DfsuFileType.DfsuSpectral0D is fileType
+ if frequencies is not None:
+ assert_array_equal(frequencies, readFrequencies)
+ if directions is not None:
+ assert_array_equal(directions, readDirections)
+
+
+def test_validate_spectral_0d_needs_frequencies_or_directions():
+ builder = _spectral_0d_builder()
+
+ assert [
+ "Frequencies or directions must be set for a spectral 0D file"
+ ] == builder.Validate()
+
+
+@pytest.mark.parametrize(
+ "frequencies, directions",
+ [
+ ([0.1, 0.2], None),
+ (None, [0.0, 1.0, 2.0, 3.0]),
+ ([0.1, 0.2, 0.3], [0.0, 1.0]),
+ ],
+)
+def test_validate_spectral_0d_element_count(frequencies, directions):
+ builder = _spectral_0d_builder()
+ if frequencies is not None:
+ builder.SetFrequencies(np.array(frequencies))
+ if directions is not None:
+ builder.SetDirections(np.array(directions))
+
+ errors = builder.Validate()
+
+ assert 1 == len(errors)
+ assert "Number of elements (3) must equal" in errors[0]
diff --git a/tests/test_eum.py b/tests/test_eum.py
index a2156b3..e751912 100644
--- a/tests/test_eum.py
+++ b/tests/test_eum.py
@@ -184,29 +184,56 @@ def test_converter(self):
Assert.AreEqual(np.array([0.5, 1, 2, 3.280839895]), array, 1e-6)
-EQV_UNIT_INVERTED = (
- "Suspected bug: eumGetNextEqvUnit treats a non-zero return code as failure, "
- "the opposite of every other eum wrapper (eumGetNextUnit, eumGetItemUnitSeq, "
- "...). Called directly, the native function returns 1 with meter as the first "
- "unit equivalent to meter, and 0 with a null description after the last one. "
- "Correct: (True, eumUmeter, 'meter') for the first unit and "
- "(False, eumUUnitUndefined, '') after the last."
-)
-
-
-@pytest.mark.xfail(strict=True, reason=EQV_UNIT_INVERTED)
def test_eumGetNextEqvUnit_first_unit():
assert (True, eumUnit.eumUmeter, "meter") == eumWrapper.eumGetNextEqvUnit(
eumUnit.eumUmeter, eumUnit.eumUUnitUndefined
)
-@pytest.mark.xfail(strict=True, reason=EQV_UNIT_INVERTED)
def test_eumGetNextEqvUnit_after_last_unit():
assert (False, eumUnit.eumUUnitUndefined, "") == eumWrapper.eumGetNextEqvUnit(
eumUnit.eumUmeter, eumUnit.eumUmileUS
)
+def _all_units():
+ units = []
+ ok, unit, description = eumWrapper.eumGetNextUnit(eumUnit.eumUUnitUndefined)
+ while ok:
+ units.append((eumUnit(unit), description))
+ ok, unit, description = eumWrapper.eumGetNextUnit(unit)
+ return units
+
+
+# eumUnitsEqv and eumGetNextUnit are the oracle: every unit equivalent to the
+# base, in the order eumGetNextUnit lists them
+@pytest.mark.parametrize(
+ "baseUnit",
+ [
+ eumUnit.eumUmeter,
+ eumUnit.eumUsec,
+ eumUnit.eumUm3PerSec,
+ eumUnit.eumUdegreeCelsius,
+ ],
+)
+def test_eumGetNextEqvUnit_lists_every_equivalent_unit(baseUnit):
+ expected = [
+ (unit, description)
+ for unit, description in _all_units()
+ if eumWrapper.eumUnitsEqv(baseUnit, unit)
+ ]
+
+ units = []
+ ok, unit, description = eumWrapper.eumGetNextEqvUnit(
+ baseUnit, eumUnit.eumUUnitUndefined
+ )
+ while ok and len(units) <= len(expected):
+ units.append((unit, description))
+ ok, unit, description = eumWrapper.eumGetNextEqvUnit(baseUnit, unit)
+
+ assert 1 < len(expected)
+ assert expected == units
+
+
def test_GetItemTypeTag_unknown_description():
assert eumWrapper.GetItemTypeTag("No such item type") is None
diff --git a/tests/test_mesh.py b/tests/test_mesh.py
index 8a27f79..2c45378 100644
--- a/tests/test_mesh.py
+++ b/tests/test_mesh.py
@@ -3,7 +3,7 @@
import numpy as np
import pytest
-from numpy.testing import assert_allclose, assert_array_equal
+from numpy.testing import assert_array_equal
from mikecore.DfsuBuilder import DfsuBuilder
from mikecore.DfsuFile import DfsuFile, DfsuFileType
@@ -216,8 +216,7 @@ def test_MeshBuilder_rebuilds_the_Oresund_mesh(tmp_path):
z = mesh.Z
assert originalZ is not None
assert z is not None
- # To float32 precision; exact z is test_MeshBuilder_keeps_z_precision
- assert_allclose(originalZ, z, rtol=float(np.finfo(np.float32).eps))
+ assert_array_equal(originalZ, z)
assert_array_equal(original.Code, mesh.Code)
assert_array_equal(original.ElementIds, mesh.ElementIds)
assert_array_equal(original.ElementType, mesh.ElementType)
@@ -226,15 +225,6 @@ def test_MeshBuilder_rebuilds_the_Oresund_mesh(tmp_path):
assert_array_equal(expected, element)
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "Suspected bug: MeshBuilder.SetNodes converts z to float32, so a mesh built "
- "from double z values, such as one read with MeshFile.ReadMesh, is written "
- "with z rounded to about 7 digits. Mesh files store z as doubles, and "
- "MeshFile and SetNodes keep x and y as doubles. Correct: z is kept as given."
- ),
-)
def test_MeshBuilder_keeps_z_precision(tmp_path):
filename = str(tmp_path / "z.mesh")
# ## 1 359862.97332797921 6206313.7132576201 -1.7859922534795478 1
@@ -249,13 +239,30 @@ def test_MeshBuilder_keeps_z_precision(tmp_path):
assert_array_equal(z, mesh.Z)
-def test_MeshBuilder_element_ids_are_written(tmp_path):
- filename = str(tmp_path / "element_ids.mesh")
+INT32 = np.iinfo(np.int32)
+
+
+def _square_mesh_builder():
builder = MeshBuilder()
builder.SetProjection("NON-UTM")
builder.SetNodes(X, Y, Z, CODE)
builder.SetElements(ELEMENTS)
- elementIds = [10, 20]
+ return builder
+
+
+# MeshFile.ReadMesh reads ids as int32, so the int32 bounds are the range
+@pytest.mark.parametrize(
+ "elementIds",
+ [
+ [10, 20],
+ np.array([10, 20], dtype=np.int32),
+ np.array([10, 20], dtype=np.int64),
+ [INT32.min, INT32.max],
+ ],
+)
+def test_MeshBuilder_element_ids_are_written(tmp_path, elementIds):
+ filename = str(tmp_path / "element_ids.mesh")
+ builder = _square_mesh_builder()
builder.SetElementIds(elementIds)
builder.CreateMesh().Write(filename)
@@ -263,6 +270,58 @@ def test_MeshBuilder_element_ids_are_written(tmp_path):
assert_array_equal(elementIds, mesh.ElementIds)
+# MeshFile.ReadMesh is the oracle for the id dtype
+def test_MeshBuilder_default_ids_are_int32_like_a_read_mesh(tmp_path):
+ filename = str(tmp_path / "default_ids.mesh")
+ built = _square_mesh_builder().CreateMesh()
+ built.Write(filename)
+ read = MeshFile.ReadMesh(filename)
+ nodeIds, elementIds = built.NodeIds, built.ElementIds
+ readNodeIds, readElementIds = read.NodeIds, read.ElementIds
+ assert nodeIds is not None and elementIds is not None
+ assert readNodeIds is not None and readElementIds is not None
+
+ assert_array_equal([1, 2, 3, 4], nodeIds)
+ assert_array_equal([1, 2], elementIds)
+ assert readNodeIds.dtype == nodeIds.dtype
+ assert readElementIds.dtype == elementIds.dtype
+
+
+def test_MeshBuilder_element_ids_set_to_none_use_the_defaults(tmp_path):
+ filename = str(tmp_path / "element_ids_none.mesh")
+ builder = _square_mesh_builder()
+ builder.SetElementIds([10, 20])
+ builder.SetElementIds(None)
+ builder.CreateMesh().Write(filename)
+
+ mesh = MeshFile.ReadMesh(filename)
+ assert_array_equal([1, 2], mesh.ElementIds)
+
+
+@pytest.mark.parametrize("outside", [INT32.max + 1, INT32.min - 1, 2**40, 2**64])
+def test_MeshBuilder_element_ids_outside_int32_are_rejected(outside):
+ builder = _square_mesh_builder()
+
+ with pytest.raises(ValueError, match="int32"):
+ builder.SetElementIds([1, outside])
+
+
+# One id per element, so a 2-D array or a scalar is rejected even when its size fits
+@pytest.mark.parametrize(
+ "elementIds, match",
+ [
+ (np.array([[10], [20]]), "1-D"),
+ (7, "1-D"),
+ ([True, False], "not booleans"),
+ ],
+)
+def test_MeshBuilder_element_ids_must_be_a_1d_integer_array(elementIds, match):
+ builder = _square_mesh_builder()
+
+ with pytest.raises(ValueError, match=match):
+ builder.SetElementIds(elementIds)
+
+
def test_MeshBuilder_mixed_triangles_and_quadrilaterals(tmp_path):
meshFilename = str(tmp_path / "mixed.mesh")
dfsuFilename = str(tmp_path / "mixed.dfsu")
diff --git a/tests/test_miketools.py b/tests/test_miketools.py
index 4417ccc..dc74317 100644
--- a/tests/test_miketools.py
+++ b/tests/test_miketools.py
@@ -67,27 +67,34 @@ def test_Dfs0ToAscii_writes_calendar_times(tmp_path):
]
-@pytest.mark.xfail(
- strict=True,
- reason=(
- "Suspected bug: Dfs0ToAscii adds itemData.Time to the start as seconds "
- "(its own TODO says the time unit is not always seconds), but "
- "ReadItemTimeStepNext returns times in the time axis unit. With a "
- "10 minute step the second row is stamped 00:00:10. Correct: convert "
- "with the time axis's ToSeconds, giving 00:10:00."
- ),
+# timedelta is the oracle for each time unit; a non-equidistant axis stores
+# each timestep's own time
+@pytest.mark.parametrize(
+ "timeUnit, timedeltaUnit",
+ [
+ (eumUnit.eumUmillisec, "milliseconds"),
+ (eumUnit.eumUsec, "seconds"),
+ (eumUnit.eumUminute, "minutes"),
+ (eumUnit.eumUhour, "hours"),
+ (eumUnit.eumUday, "days"),
+ ],
)
-def test_Dfs0ToAscii_writes_calendar_times_in_minutes(tmp_path):
- dfs0FileName = str(tmp_path / "minutes.dfs0")
+@pytest.mark.parametrize("equidistant", [True, False])
+def test_Dfs0ToAscii_writes_calendar_times_in_the_time_unit(
+ tmp_path, timeUnit, timedeltaUnit, equidistant
+):
+ dfs0FileName = str(tmp_path / "times.dfs0")
+ start = datetime.datetime(2020, 1, 1)
+ times = [0, 10, 20] if equidistant else [0, 10, 25]
factory = DfsFactory()
builder = DfsBuilder.Create("title", "application", 1)
builder.SetDataType(0)
builder.SetGeographicalProjection(factory.CreateProjectionUndefined())
- builder.SetTemporalAxis(
- factory.CreateTemporalEqCalendarAxis(
- eumUnit.eumUminute, datetime.datetime(2020, 1, 1), 0, 10
- )
- )
+ if equidistant:
+ timeAxis = factory.CreateTemporalEqCalendarAxis(timeUnit, start, 0, 10)
+ else:
+ timeAxis = factory.CreateTemporalNonEqCalendarAxis(timeUnit, start)
+ builder.SetTemporalAxis(timeAxis)
item = builder.CreateDynamicItemBuilder()
item.Set(
"Value",
@@ -99,17 +106,17 @@ def test_Dfs0ToAscii_writes_calendar_times_in_minutes(tmp_path):
builder.AddDynamicItem(item.GetDynamicItemInfo())
builder.CreateFile(dfs0FileName)
dfs0 = builder.GetFile()
- for value in (1.0, 2.0):
- dfs0.WriteItemTimeStepNext(0, np.array([value], dtype=np.float32))
+ for time in times:
+ dfs0.WriteItemTimeStepNext(time, np.array([1.0], dtype=np.float32))
dfs0.Close()
- txtFileName = str(tmp_path / "minutes.txt")
+ txtFileName = str(tmp_path / "times.txt")
Dfs0ToAscii(dfs0FileName, txtFileName)
- lines = _data_lines(txtFileName)
- assert [["2020-01-01", "00:00:00.000"], ["2020-01-01", "00:10:00.000"]] == [
- line.split()[:2] for line in lines
- ]
+ expected = [start + datetime.timedelta(**{timedeltaUnit: time}) for time in times]
+ assert [
+ [t.strftime("%Y-%m-%d"), t.strftime("%H:%M:%S.%f")[:-3]] for t in expected
+ ] == [line.split()[:2] for line in _data_lines(txtFileName)]
def test_Dfs0ToAscii_writes_relative_times(tmp_path):