Skip to content

Write X/Y as degrees in geographic dfsu files - #80

Merged
ryan-kipawa merged 3 commits into
masterfrom
fix/47
Oct 13, 2026
Merged

ryan-kipawa merged 3 commits into
masterfrom
fix/47

Conversation

@ryan-kipawa

@ryan-kipawa ryan-kipawa commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #47

What and why

A dfsu written by DfsuBuilder with a geographic projection such as LONG/LAT holds its node coordinates in degrees, but the X-coord and Y-coord static items were recorded as eumIGeographicalCoordinate in metre. The check that switches them to eumILatLong in degrees was left commented out (# TODO: reenable) when MapProjection wasn't available yet. Projections.py now has MapProjection.IsValid and IsGeographical, so this PR turns the check back on. Files with a projected coordinate system (UTM, NON-UTM) still get metres.

The new output matches the geographic dfsu files that DHI tools produce, for example mikeio's public test file tests/testdata/wind_north_sea.dfsu (LONG/LAT, X-coord = eumILatLong, eumUdegree; see Evidence).

Commits

  1. abd3215 Test that DfsuBuilder writes X/Y in degrees for geographic projections: a parametrized test that builds a two-triangle mesh for LONG/LAT, UTM-33 and NON-UTM and reads back the X/Y quantities. The LONG/LAT case fails at this commit.
  2. 25d5f61 Write X/Y as degrees in geographic dfsu files: replaces the commented-out block with live code.
  3. 47ec11a Note the geographic X/Y quantity fix in the changelog: one entry under Unreleased / Fixed. It says the file output changes.

Code Review Guide

Glossary

  • Static item: a fixed array stored once in a dfs file. A dfsu stores its mesh this way: Node id, X-coord, Y-coord, Z-coord, … each with an EUM quantity (item type and unit).
  • eumIGeographicalCoordinate / eumUmeter: the quantity for projected map coordinates in metres.
  • eumILatLong / eumUdegree: the quantity for longitude/latitude in degrees.
  • MapProjection.IsGeographical(wkt): true when the projection string means lon/lat coordinates (LONG/LAT, a GEOGCS[...] WKT), false for projected ones such as UTM-33.

Where the change sits

flowchart LR
  A["DfsuBuilder.CreateFile<br/>mikecore/DfsuBuilder.py:681"] --> B["CreateDfsu<br/>mikecore/DfsuBuilder.py:691"]
  B --> C["pick xyQuantity<br/>mikecore/DfsuBuilder.py:706-710"]
  C --> M["MapProjection.IsValid / IsGeographical<br/>mikecore/Projections.py:1296, 1359"]
  C --> D["AddCreateStaticItem X-coord, Y-coord<br/>mikecore/DfsuBuilder.py:716, 719"]
  P["SetProjection<br/>mikecore/DfsuBuilder.py:98"] -.-> C
  classDef changed fill:#2da44e,stroke:#1a7f37,color:#fff
  class C changed
Loading

CreateDfsu picks the X/Y quantity from the projection set earlier and uses it for both coordinate static items.

Conventions followed

Optional builder fields are checked with is not None before use

mikecore/DfsuBuilder.py:489

if self.__timeAxis is not None:
    dfsBuilder.SetTemporalAxis(self.__timeAxis)

mikecore/DfsuBuilder.py:707

if self.__dfsProjection is not None:
    wkt = self.__dfsProjection.WKTString
    if MapProjection.IsValid(wkt) and MapProjection.IsGeographical(wkt):
        xyQuantity = eumQuantity(eumItem.eumILatLong, eumUnit.eumUdegree)
Builder tests write to tmp_path with the shared _builder() mesh and assert what reads back

tests/test_dfsu_builder.py:91

filename = str(tmp_path / "zunit.dfsu")
builder = _builder()
builder.SetNodes(X, Y, Z, CODE)
...
assert eumUnit.eumUfeet == dfsu.ZUnit

tests/test_dfsu_builder.py:114

filename = str(tmp_path / "xyquantity.dfsu")
builder = _builder()
builder.SetProjection(DfsFactory().CreateProjection(projection))
...
assert xyQuantity == (staticItem.Quantity.Item, staticItem.Quantity.Unit)

Deviations

The test reads the file with DfsFileFactory.DfsGenericOpen, not DfsuFile.Open. Why: DfsuFile doesn't expose the static items' quantities. Alternative: none through the public API.

tests/test_dfsu_builder.py:100

dfsu = DfsuFile.Open(filename)
assert eumUnit.eumUfeet == dfsu.ZUnit

tests/test_dfsu_builder.py:122

dfs = DfsFileFactory.DfsGenericOpen(filename)
dfs.ReadStaticItemNext()  # Node id
for name in ["X-coord", "Y-coord"]:
    staticItem = dfs.ReadStaticItemNext()

Evidence

Before (abd3215, test only):

$ uv run pytest tests/test_dfsu_builder.py -k xy_quantity -q
E           At index 0 diff: <eumItem.eumILatLong: 100157> != <eumItem.eumIGeographicalCoordinate: 100183>
FAILED tests/test_dfsu_builder.py::test_xy_quantity_follows_projection[LONG/LAT-xyQuantity0]
1 failed, 2 passed, 22 deselected in 0.30s

$ uv run python xy.py
LONG/LAT X-coord: eumIGeographicalCoordinate, eumUmeter
LONG/LAT Y-coord: eumIGeographicalCoordinate, eumUmeter
UTM-33   X-coord: eumIGeographicalCoordinate, eumUmeter
UTM-33   Y-coord: eumIGeographicalCoordinate, eumUmeter

After (47ec11a):

$ uv run pytest tests/test_dfsu_builder.py -k xy_quantity -v
tests/test_dfsu_builder.py::test_xy_quantity_follows_projection[LONG/LAT-xyQuantity0] PASSED [ 33%]
tests/test_dfsu_builder.py::test_xy_quantity_follows_projection[UTM-33-xyQuantity1] PASSED [ 66%]
tests/test_dfsu_builder.py::test_xy_quantity_follows_projection[NON-UTM-xyQuantity2] PASSED [100%]

$ uv run python xy.py
LONG/LAT X-coord: eumILatLong, eumUdegree
LONG/LAT Y-coord: eumILatLong, eumUdegree
UTM-33   X-coord: eumIGeographicalCoordinate, eumUmeter
UTM-33   Y-coord: eumIGeographicalCoordinate, eumUmeter

Reference: a geographic dfsu from mikeio's test data already uses this quantity:

$ uv run python -c "...read wind_north_sea.dfsu..."
LONG/LAT
X-coord eumILatLong eumUdegree

Full suite: uv run pytest: 173 passed, 8 xfailed. uvx pre-commit run --all-files: passed. uv run pyrefly check: 0 errors, 329 warnings (the same as master).

Verify it yourself

git fetch origin && git checkout abd3215
uv run pytest tests/test_dfsu_builder.py -k xy_quantity -q     # LONG/LAT fails
git checkout fix/47
uv run pytest tests/test_dfsu_builder.py -k xy_quantity -q     # 3 passed

# Reference file from mikeio
curl -sSLO https://raw.githubusercontent.com/DHI/mikeio/main/tests/testdata/wind_north_sea.dfsu
uv run python -c "
from mikecore.DfsFileFactory import DfsFileFactory
d = DfsFileFactory.DfsGenericOpen('wind_north_sea.dfsu')
print(d.FileInfo.Projection.WKTString[:20]); d.ReadStaticItemNext(); s = d.ReadStaticItemNext()
print(s.Name, s.Quantity.Item.name, s.Quantity.Unit.name); d.Close()"
xy.py: builds a LONG/LAT and a UTM-33 dfsu and prints their X/Y quantities
import datetime, tempfile, os
import numpy as np
from mikecore.DfsFactory import DfsFactory
from mikecore.DfsFileFactory import DfsFileFactory
from mikecore.DfsuBuilder import DfsuBuilder
from mikecore.DfsuFile import DfsuFileType
from mikecore.eum import eumItem, eumQuantity, eumUnit

for proj in ["LONG/LAT", "UTM-33"]:
    b = DfsuBuilder.Create(DfsuFileType.Dfsu2D)
    b.SetProjection(DfsFactory().CreateProjection(proj))
    b.SetTimeInfo(datetime.datetime(2020, 1, 1), 60)
    b.AddDynamicItem("Value", eumQuantity(eumItem.eumIItemUndefined, eumUnit.eumUUnitUndefined))
    b.SetNodes(np.array([0.0, 1, 1, 0]), np.array([0.0, 0, 1, 1]), np.zeros(4), np.ones(4, dtype=np.int32))
    b.SetElements([[1, 2, 3], [1, 3, 4]])
    f = os.path.join(tempfile.mkdtemp(), "xy.dfsu")
    b.CreateFile(f).Close()
    d = DfsFileFactory.DfsGenericOpen(f)
    d.ReadStaticItemNext()
    for _ in range(2):
        s = d.ReadStaticItemNext()
        print(f"{proj:8} {s.Name}: {s.Quantity.Item.name}, {s.Quantity.Unit.name}")
    d.Close()

Review

  • Round 1 (fresh code-reviewer on 47ec11a): no must-fix findings, no suggestions, no questions. It checked separately that a full GEOGCS[...] WKT is detected as geographic, that the test fails with master's DfsuBuilder.py, and that pyrefly stays at 329 warnings. Verdict: clean.

Risk

  • Output changes for geographic dfsu files only: their X/Y static items now say eumILatLong/degree. The coordinate values are unchanged, and projected files keep the same quantity as before (the UTM-33 and NON-UTM test cases). Readers that look at these quantities see what DHI's own geographic files already carry (the mikeio reference above). MIKE IO's test suite (DHI/mikeio main, through test_mikeio.yml) gives the same result on this branch as on master: 978 passed in every job, with the same two failures that master also has (test_eum.py::test_eum_conversion on all 4 jobs, test_dfs2.py::test_select_area_rotated_UTM_2 on Linux). Runs: fix/47, master. Unproven: code outside MIKE IO that relies on the old metre quantity for LONG/LAT files.

  • New import: DfsuBuilder now imports mikecore.Projections. That module is already imported, and its native library already loaded, by mikecore/__init__.py:34, so the import adds no load-time dependency.

  • Missing projection: the is not None guard can't change behaviour, because Validate (called first by CreateFile) already rejects a builder without a projection.

  • CI: all PR checks passed on 47ec11a (tests on Ubuntu and Windows, Python 3.11 and 3.14; ruff; pyrefly; CodeQL).

Not in this PR

Nothing.

🤖 Generated with Claude Code

ryan-kipawa and others added 3 commits October 13, 2026 09:39
The X-coord and Y-coord static items of a LONG/LAT dfsu hold degrees,
but DfsuBuilder records them as eumIGeographicalCoordinate in metre.
The LONG/LAT case fails until that is fixed; the projected cases pin
the existing metre quantity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DfsuBuilder always gave the X-coord and Y-coord static items the
eumIGeographicalCoordinate quantity in metre. The check that switches
them to eumILatLong in degrees for a geographic projection was left
commented out, so LONG/LAT files recorded the wrong unit. Re-enable it
using MapProjection, which Projections.py now provides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ryan-kipawa ryan-kipawa self-assigned this Oct 13, 2026
@ryan-kipawa
ryan-kipawa marked this pull request as ready for review October 13, 2026 09:16
@ryan-kipawa
ryan-kipawa requested a review from JesperGr as a code owner October 13, 2026 09:16
@ryan-kipawa
ryan-kipawa merged commit 5e1ecc1 into master Oct 13, 2026
10 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DfsuBuilder writes a metre quantity for X/Y static items even in geographic projections (disabled block at DfsuBuilder.py:524)

1 participant