Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/recipe/meta.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% set name = "pyremap" %}
{% set version = "2.2.0" %}
{% set version = "2.3.0" %}
{% set python_min = "3.10" %}

package:
Expand Down
3 changes: 3 additions & 0 deletions docs/remapper/building_mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ tool.
not in your path.
- `moab_path`: The path to the MOAB installation, if `mptempest` is not in
your path.
- `ntasks`: The number of MPI tasks to use to build the mapping file. With
`ntasks=1`, the mapping tool is run in serial (without `parallel_exec`) and,
for `moab`, the meshes are not partitioned.
- `parallel_exec`: The parallel executable (e.g. `srun` or `mpirun`) to use
to run the mapping tool. This may include additional flags, e.g.
`srun --mpi=pmi2`.
Expand Down
23 changes: 17 additions & 6 deletions pyremap/remapper/build_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ def _build_map(remapper, logger=None):

elif map_tool == 'moab':
moab_path = remapper.moab_path
src_scrip_filename = _moab_partition_scrip_file(
src_scrip_filename = _moab_convert_scrip_file(
remapper, src_scrip_filename, moab_path, logger
)
dst_scrip_filename = _moab_partition_scrip_file(
dst_scrip_filename = _moab_convert_scrip_file(
remapper, dst_scrip_filename, moab_path, logger
)
args = _moab_build_map_args(
Expand Down Expand Up @@ -94,16 +94,16 @@ def _build_map(remapper, logger=None):
tempobj.cleanup()


def _moab_partition_scrip_file(remapper, in_filename, moab_path, logger):
def _moab_convert_scrip_file(remapper, in_filename, moab_path, logger):
"""
Partition SCRIP file for parallel mbtempest use
Convert SCRIP file to HDF5 for mbtempest use, partitioning it if more than
one MPI task will be used
"""
ntasks = remapper.ntasks

print(f'Partition SCRIP file {in_filename}')
print(f'Convert SCRIP file {in_filename}')

h5m_filename = in_filename.replace('.nc', '.h5m')
h5m_part_filename = in_filename.replace('.nc', f'.p{ntasks}.h5m')

if moab_path is None:
mbconvert = 'mbconvert'
Expand All @@ -121,6 +121,17 @@ def _moab_partition_scrip_file(remapper, in_filename, moab_path, logger):
]
check_call(args, logger=logger)

if ntasks == 1:
# nothing to partition (mbpart requires more than one part), so
# mbtempest reads the unpartitioned mesh. The conversion above is
# still needed: mbtempest fails to compute weights when a mesh with
# more than 4 vertices per cell (e.g. an MPAS cell mesh) is loaded
# directly from SCRIP as the destination mesh.
print(' Done.')
return h5m_filename

h5m_part_filename = in_filename.replace('.nc', f'.p{ntasks}.h5m')

# Partition source SCRIP
args = [
mbpart,
Expand Down
2 changes: 1 addition & 1 deletion pyremap/version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__version_info__ = (2, 2, 0)
__version_info__ = (2, 3, 0)
__version__ = '.'.join(str(vi) for vi in __version_info__)
178 changes: 178 additions & 0 deletions tests/test_build_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# This software is open source software available under the BSD-3 license.
#
# Copyright (c) 2025 Triad National Security, LLC. All rights reserved.
# Copyright (c) 2025 Lawrence Livermore National Security, LLC. All rights
# reserved.
# Copyright (c) 2025 UT-Battelle, LLC. All rights reserved.
#
# Additional copyright and license information can be found in the LICENSE file
# distributed with this code, or at
# https://raw.githubusercontent.com/MPAS-Dev/pyremap/main/LICENSE
"""
Unit tests for the commands pyremap builds to make mapping files.
"""

import os
import shutil
import tempfile
from unittest import mock

import numpy as np
import pytest

from pyremap import LatLonGridDescriptor, Remapper
from tests import TestCase


def _get_descriptors():
src_descriptor = LatLonGridDescriptor.create(
lat_corner=np.linspace(-90.0, 90.0, 46),
lon_corner=np.linspace(-180.0, 180.0, 91),
units='degrees',
)
dst_descriptor = LatLonGridDescriptor.create(
lat_corner=np.linspace(-90.0, 90.0, 91),
lon_corner=np.linspace(-180.0, 180.0, 181),
units='degrees',
)
return src_descriptor, dst_descriptor


def _build_map_calls(ntasks, map_tool='moab', method='bilinear'):
"""
Build a map with ``check_call`` mocked out, returning the list of argument
lists that would have been passed to ``check_call``
"""
src_descriptor, dst_descriptor = _get_descriptors()
remapper = Remapper(
ntasks=ntasks,
map_tool=map_tool,
method=method,
src_descriptor=src_descriptor,
dst_descriptor=dst_descriptor,
use_tmp=False,
)
with mock.patch(
'pyremap.remapper.build_map.check_call'
) as mock_check_call:
remapper.build_map()

return [call.args[0] for call in mock_check_call.call_args_list]


class TestBuildMapArgs(TestCase):
"""
Test the argument lists passed to the mapping tools, with ``check_call``
mocked out so no ESMF or MOAB binaries are needed.
"""

def setUp(self):
self.test_dir = tempfile.mkdtemp()
self.cwd = os.getcwd()
os.chdir(self.test_dir)

def tearDown(self):
os.chdir(self.cwd)
shutil.rmtree(self.test_dir)

def test_moab_serial_does_not_partition(self):
"""
``mbpart`` cannot make a single partition, so it must not be called
when ``ntasks=1``
"""
calls = _build_map_calls(ntasks=1)

executables = [os.path.basename(args[0]) for args in calls]
assert 'mbpart' not in executables

# the SCRIP files are still converted to HDF5, which mbtempest needs
assert executables == ['mbconvert', 'mbconvert', 'mbtempest']

mbtempest_args = calls[-1]
# no MPI launcher and the unpartitioned .h5m meshes are used
assert mbtempest_args[0].endswith('mbtempest')
loaded = [
mbtempest_args[index + 1]
for index, arg in enumerate(mbtempest_args)
if arg == '--load'
]
assert loaded == ['src_mesh.h5m', 'dst_mesh.h5m']

def test_moab_parallel_partitions(self):
"""
With more than one task, the meshes are partitioned with ``mbpart``
and mbtempest is launched with an MPI launcher
"""
ntasks = 2
calls = _build_map_calls(ntasks=ntasks)

executables = [os.path.basename(args[0]) for args in calls]
assert executables == [
'mbconvert',
'mbpart',
'mbconvert',
'mbpart',
'mpirun',
]

for args in calls:
if os.path.basename(args[0]) == 'mbpart':
assert args[1] == f'{ntasks}'

mbtempest_args = calls[-1]
assert mbtempest_args[:3] == ['mpirun', '-np', f'{ntasks}']
loaded = [
mbtempest_args[index + 1]
for index, arg in enumerate(mbtempest_args)
if arg == '--load'
]
assert loaded == [
f'src_mesh.p{ntasks}.h5m',
f'dst_mesh.p{ntasks}.h5m',
]

def test_esmf_serial(self):
"""
The ESMF branch never partitions, whatever the task count
"""
calls = _build_map_calls(ntasks=1, map_tool='esmf')

assert len(calls) == 1
assert os.path.basename(calls[0][0]) == 'ESMF_RegridWeightGen'


@pytest.mark.parametrize('ntasks', [1, 2])
def test_moab_serial_args_match_parallel(ntasks):
"""
Whatever the task count, mbtempest gets the same weight-generation
options
"""
cwd = os.getcwd()
test_dir = tempfile.mkdtemp()
os.chdir(test_dir)
try:
calls = _build_map_calls(ntasks=ntasks)
finally:
os.chdir(cwd)
shutil.rmtree(test_dir)

mbtempest_args = calls[-1]
start = mbtempest_args.index('--file')
assert mbtempest_args[start:] == [
'--file',
mbtempest_args[start + 1],
'--weights',
'--gnomonic',
'--boxeps',
'1e-9',
'--method',
'fv',
'--method',
'fv',
'--order',
'1',
'--order',
'1',
'--fvmethod',
'bilin',
]
Loading