Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cice grid #5

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from 23 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
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ jobs:
strategy:
matrix:
python-version: ['3.10', '3.11', '3.12']

steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand All @@ -43,7 +42,7 @@ jobs:
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
Collection of utilities aimed at simplifying the creation and handling of ACCESS-OM3 runs. It currently includes:
- functions to read and write ACCESS-OM3 configuration files
- functions to read and process profiling data
- scripts to create the CICE grid file from an existing MOM grid file
87 changes: 87 additions & 0 deletions om3utils/cice_grid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""
Script: cice_grid.py
Description:
This script generates a CICE grid from the MOM super grid provided in the input NetCDF file.

Usage:
python cice_grid.py <ocean_hgrid> <ocean_hgrid>
- ocean_hgrid: Path to the MOM super grid NetCDF file.
- ocean_mask: Path to the corresponding mask NetCDF file.

Dependencies:
- 'module use /g/data/hh5/public/modules ; module load conda/analysis3-23.10'.
- or 'pyproject.toml' file

"""

#!/usr/bin/env python3
# File based on https://github.com/COSIMA/access-om2/blob/29118914d5224152ce286e0590394b231fea632e/tools/make_cice_grid.py

import sys
import argparse

from esmgrids.mom_grid import MomGrid
from esmgrids.cice_grid import CiceGrid


class CiceGridNc:
"""
Create CICE grid.nc and kmt.nc from MOM ocean_hgrid.nc and ocean_mask.nc
"""

def __init__(self, grid_file="grid.nc", mask_file="kmt.nc"):
self.grid_file = grid_file
self.mask_file = mask_file
return

def build_from_mom(self, ocean_hgrid, ocean_mask):
mom = MomGrid.fromfile(ocean_hgrid, mask_file=ocean_mask)

cice = CiceGrid.fromgrid(mom)

cice.create_gridnc(self.grid_file)

# Add versioning information
cice.grid_f.inputfile = f"{ocean_hgrid}"
cice.grid_f.inputfile_md5 = md5sum(ocean_hgrid)
cice.grid_f.history_command = f"python make_CICE_grid.py {ocean_hgrid} {ocean_mask}"

# Add the typical crs (i.e. WGS84/EPSG4326 , but in radians).
crs = cice.grid_f.createVariable("crs", "S1")
crs.grid_mapping_name = "tripolar_latitude_longitude"
crs.crs_wkt = 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["radians",1,AUTHORITY["EPSG","9122"]],AXIS["Latitude",NORTH],AXIS["Longitude",EAST],AUTHORITY["EPSG","4326"]]'

cice.write()

cice.create_masknc(self.mask_file)

# Add versioning information
cice.mask_f.inputfile = f"{ocean_mask}"
cice.mask_f.inputfile_md5 = md5sum(ocean_mask)
cice.mask_f.history_command = f"python cice_grid.py {ocean_hgrid} {ocean_mask}"

# Add the typical crs (i.e. WGS84/EPSG4326 , but in radians).
crs = cice.mask_f.createVariable("crs", "S1")
crs.grid_mapping_name = "tripolar_latitude_longitude"
crs.crs_wkt = 'GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["radians",1,AUTHORITY["EPSG","9122"]],AXIS["Latitude",NORTH],AXIS["Longitude",EAST],AUTHORITY["EPSG","4326"]]'

cice.write_mask()


if __name__ == "__main__":
# command line arguments
from utils import md5sum # load from file

Check warning on line 73 in om3utils/cice_grid.py

View check run for this annotation

Codecov / codecov/patch

om3utils/cice_grid.py#L73

Added line #L73 was not covered by tests

parser = argparse.ArgumentParser()
parser.add_argument("ocean_hgrid", help="ocean_hgrid.nc file")
parser.add_argument("ocean_mask", help="ocean_mask.nc file")

Check warning on line 77 in om3utils/cice_grid.py

View check run for this annotation

Codecov / codecov/patch

om3utils/cice_grid.py#L75-L77

Added lines #L75 - L77 were not covered by tests
# to-do: add argument for CRS & output filenames?

args = vars(parser.parse_args())

Check warning on line 80 in om3utils/cice_grid.py

View check run for this annotation

Codecov / codecov/patch

om3utils/cice_grid.py#L80

Added line #L80 was not covered by tests

grid = CiceGridNc()

Check warning on line 82 in om3utils/cice_grid.py

View check run for this annotation

Codecov / codecov/patch

om3utils/cice_grid.py#L82

Added line #L82 was not covered by tests

sys.exit(grid.build_from_mom(**args))

Check warning on line 84 in om3utils/cice_grid.py

View check run for this annotation

Codecov / codecov/patch

om3utils/cice_grid.py#L84

Added line #L84 was not covered by tests

else:
from .utils import md5sum # load from package
6 changes: 6 additions & 0 deletions om3utils/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def md5sum(filename):
from hashlib import md5
from mmap import mmap, ACCESS_READ

with open(filename) as file, mmap(file.fileno(), 0, access=ACCESS_READ) as file:
return md5(file).hexdigest()
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ dynamic = ["version"]
dependencies = [
"f90nml",
"ruamel.yaml",
"xarray",
"numpy",
"netcdf4",
"ocean_model_grid_generator@git+https://github.com/nikizadehgfdl/ocean_model_grid_generator@790069b31f9791864ccd514a2b8f53f385a0452e",
"esmgrids@git+https://github.com/anton-seaice/esmgrids@e5791f17a699627566687021e250aac32567ecd4",
]

[build-system]
Expand Down
5 changes: 5 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Scripts

This folder contains scripts which are the reference implementation of how to use software within OM3 utils.

i.e. run `qsub pbs_make_cice_grids.sh` to create the cice grid for ACCESS-OM3 at all three standard resolutions. After sanity checking, these are then moved by hand to the desired folder to run the model.
33 changes: 33 additions & 0 deletions scripts/pbs_make_cice_grids.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash

#PBS -W umask=0022
#PBS -l mem=24gb
#PBS -l storage=gdata/ik11+gdata/tm70+gdata/hh5
#PBS -l wd
#PBS -j oe

run='python3 ../om3utils/cice_grid.py'

umask 0003

module purge
module use /g/data/hh5/public/modules
module load conda/analysis3-23.10

echo "1 degree"
$run /g/data/ik11/inputs/access-om2/input_20201102/mom_1deg/ocean_hgrid.nc /g/data/ik11/inputs/access-om2/input_20201102/mom_1deg/ocean_mask.nc

mkdir 1deg
mv grid.nc kmt.nc 1deg

echo "0.25 deg"
$run /g/data/ik11/inputs/access-om2/input_20230515_025deg_topog/mom_025deg/ocean_hgrid.nc /g/data/ik11/inputs/access-om2/input_20230515_025deg_topog/mom_025deg/ocean_mask.nc

mkdir 025deg
mv grid.nc kmt.nc 025deg

echo "01 deg"
$run /g/data/ik11/inputs/access-om2/input_20201102/mom_01deg/ocean_hgrid.nc /g/data/ik11/inputs/access-om2/input_20201102/mom_01deg/ocean_mask.nc

mkdir 01deg
mv grid.nc kmt.nc 01deg
197 changes: 197 additions & 0 deletions tests/test_cice_grid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import pytest
import xarray as xr
from numpy.testing import assert_allclose
from numpy import deg2rad
from subprocess import run

from om3utils.cice_grid import CiceGridNc

# ----------------
# test data:


class MomGrid:
"""Generate a sample tripole grid to use as test data"""

def __init__(self, tmp_path):
self.path = str(tmp_path) + "/ocean_hgrid.nc"
self.mask_path = str(tmp_path) + "/ocean_mask.nc"

# generate an tripolar grid as test data
anton-seaice marked this conversation as resolved.
Show resolved Hide resolved
run(
[
"ocean_grid_generator.py",
"-r",
"0.25", # 4 degree grid
"--no_south_cap",
"--ensure_nj_even",
"-f",
self.path,
]
)

# open ocean_hgrid.nc
self.ds = xr.open_dataset(self.path)

# an ocean mask with a arbitrary mask
self.mask_ds = xr.Dataset()
self.mask_ds["mask"] = (self.ds.area.coarsen(ny=2).sum().coarsen(nx=2).sum()) > 5e9
self.mask_ds.to_netcdf(self.mask_path)


class CiceGrid:
"""Make the CICE grid, using script under test"""

def __init__(self, mom_grid, tmp_path):
self.path = str(tmp_path) + "/grid.nc"
self.kmt_path = str(tmp_path) + "/kmt.nc"
cice_grid = CiceGridNc(self.path, self.kmt_path)
cice_grid.build_from_mom(mom_grid.path, mom_grid.mask_path)
self.ds = xr.open_dataset(self.path, decode_cf=False)
self.kmt_ds = xr.open_dataset(self.kmt_path, decode_cf=False)


# pytest doesn't support class fixtures, so we need these two constructor funcs
@pytest.fixture
def mom_grid(tmp_path):
return MomGrid(tmp_path)


@pytest.fixture
def cice_grid(mom_grid, tmp_path):
return CiceGrid(mom_grid, tmp_path)


@pytest.fixture
def test_grid_ds(mom_grid):
# this generates the expected answers

ds = mom_grid.ds

test_grid = xr.Dataset()
# corners of the cice grid are NE corner
test_grid["ulat"] = deg2rad(ds.y.isel(nxp=slice(2, None, 2), nyp=slice(2, None, 2)))
test_grid["ulon"] = deg2rad(ds.x.isel(nxp=slice(2, None, 2), nyp=slice(2, None, 2)))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I right in guessing that isel slices start from 2 rather than 0 because 0 corresponds to the NE corner of cells that are not part of the actual grid?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If so, perhaps add a comment to that effect as it is a bit confusing otherwise

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I right in guessing that isel slices start from 2 rather than 0 because 0 corresponds to the NE corner of cells that are not part of the actual grid?

Yes that's right, in the old OM2 grid ulat starts from zero led to mismatch in longitudes

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bit of a refactor to hopefully clarify. Its hard to write this stuff concisely in comments.


# centers of cice grid
test_grid["tlat"] = deg2rad(ds.y.isel(nxp=slice(1, None, 2), nyp=slice(1, None, 2)))
test_grid["tlon"] = deg2rad(ds.x.isel(nxp=slice(1, None, 2), nyp=slice(1, None, 2)))

# length of top edge of cells
test_grid["htn"] = ds.dx.isel(nyp=slice(2, None, 2)).coarsen(nx=2).sum() * 100
# length of right edge of cells
test_grid["hte"] = ds.dy.isel(nxp=slice(2, None, 2)).coarsen(ny=2).sum() * 100

# angle at u point
test_grid["angle"] = deg2rad(ds.angle_dx.isel(nyp=slice(2, None, 2), nxp=slice(2, None, 2)))
# angle a t points
anton-seaice marked this conversation as resolved.
Show resolved Hide resolved
test_grid["angleT"] = deg2rad(ds.angle_dx.isel(nyp=slice(1, None, 2), nxp=slice(1, None, 2)))

# area of cells
test_grid["tarea"] = mom_grid.ds.area.coarsen(ny=2).sum().coarsen(nx=2).sum()
anton-seaice marked this conversation as resolved.
Show resolved Hide resolved

# uarea is area of a cell centred around the u point
# we need to wrap in latitude and fold on longitude to calculate this
area_wrapped = mom_grid.ds.area
anton-seaice marked this conversation as resolved.
Show resolved Hide resolved
area_wrapped = xr.concat([ds.area.isel(nx=slice(1, None)), ds.area.isel(nx=0)], dim="nx")

top_row = xr.concat([ds.area.isel(ny=-1, nx=slice(-2, 0, -1)), ds.area.isel(ny=-1, nx=[-1, 0])], dim="nx")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
top_row = xr.concat([ds.area.isel(ny=-1, nx=slice(-2, 0, -1)), ds.area.isel(ny=-1, nx=[-1, 0])], dim="nx")
top_row = area_wrapped.isel(ny=-1, nx=slice(-1, 0, -1))

Do we just want a reversed copy of the wrapped top row here, or have I misunderstood?
(Or perhaps the folded top row should be concatenated before wrapping? The order of these operations affects the index of the tripole singularities - see fig 9.9 of Griffies - note that the singularities fall on u points 0 and ni/2)
Screenshot 2024-04-05 at 6 28 09 pm

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I tried this suggestion and the resulting areas were offset by 1 cell ... I will have to investigate. I am confident the areas from this method are right, but maybe the method is wrong.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ...

xarray is confusing sometimes.

ds.area.isel(ny=-1, nx=[-1, 0]) and ds.area.isel(ny=-1, nx=[0,-1]) are equal :)

Anyway the slightly better version of this line would be:

xr.concat([ds.area.isel(ny=-1, nx=slice(-2, None, -1)), ds.area.isel(ny=-1, nx=-1)], dim="nx")

But the even better version, is to fold first, then wrap, and then its generally clearer in total!

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to fold first, then wrap


area_folded = xr.concat([area_wrapped.isel(ny=slice(1, None)), top_row], dim="ny")

test_grid["uarea"] = area_folded.coarsen(ny=2).sum().coarsen(nx=2).sum()

return test_grid


# ----------------
# the tests in earnest:


@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_cice_var_list(cice_grid, test_grid_ds):
# Test : Are there missing vars in cice_grid?
assert set(test_grid_ds.variables).difference(cice_grid.ds.variables) == set()


@pytest.mark.filterwarnings("ignore::DeprecationWarning")
def test_cice_grid(cice_grid, test_grid_ds):
# Test : Is the data the same as the test_grid
for jVar in test_grid_ds.variables:
assert_allclose(cice_grid.ds[jVar], test_grid_ds[jVar], rtol=1e-13, verbose=True, err_msg=f"{jVar} mismatch")


def test_cice_kmt(mom_grid, cice_grid):
# Test : does the mask match
mask = mom_grid.mask_ds.mask
kmt = cice_grid.kmt_ds.kmt

assert_allclose(mask, kmt, rtol=1e-13, verbose=True, err_msg="mask mismatch")


def test_cice_grid_attributes(cice_grid):
# Test: do the expected attributes to exist in the cice ds
cf_attributes = {
"ulat": {"standard_name": "latitude", "units": "radians"},
"ulon": {"standard_name": "longitude", "units": "radians"},
"tlat": {"standard_name": "latitude", "units": "radians"},
"tlon": {"standard_name": "longitude", "units": "radians"},
"uarea": {
"standard_name": "cell_area",
"units": "m^2",
"grid_mapping": "crs",
"coordinates": "ulat ulon",
},
"tarea": {
"standard_name": "cell_area",
"units": "m^2",
"grid_mapping": "crs",
"coordinates": "tlat tlon",
},
"angle": {
"standard_name": "angle_of_rotation_from_east_to_x",
"units": "radians",
"grid_mapping": "crs",
"coordinates": "ulat ulon",
},
"angleT": {
"standard_name": "angle_of_rotation_from_east_to_x",
"units": "radians",
"grid_mapping": "crs",
"coordinates": "tlat tlon",
},
"htn": {"units": "cm", "coordinates": "ulat tlon", "grid_mapping": "crs"},
"hte": {"units": "cm", "coordinates": "tlat ulon", "grid_mapping": "crs"},
}

for iVar in cf_attributes.keys():
print(cice_grid.ds[iVar])

for jAttr in cf_attributes[iVar].keys():
assert cice_grid.ds[iVar].attrs[jAttr] == cf_attributes[iVar][jAttr]


def test_crs_exist(cice_grid):
# Test: has the crs been added ?
# todo: open with GDAL and rioxarray and confirm they find the crs?
assert hasattr(cice_grid.ds, "crs")
assert hasattr(cice_grid.kmt_ds, "crs")


def test_inputs_logged(cice_grid, mom_grid):
# Test: have the source data been logged ?

for ds in [cice_grid.ds, cice_grid.kmt_ds]:
assert hasattr(ds, "inputfile"), "inputfile attribute missing"
assert hasattr(ds, "inputfile_md5"), "inputfile md5sum attribute missing"

sys_md5 = run(["md5sum", ds.inputfile], capture_output=True, text=True)
sys_md5 = sys_md5.stdout.split(" ")[0]
assert ds.inputfile_md5 == sys_md5, f"inputfile md5sum attribute incorrect, {ds.inputfile_md5} != {sys_md5}"

assert (
cice_grid.ds.inputfile == mom_grid.path
), "inputfile attribute incorrect ({cice_grid.ds.inputfile} != {mom_grid.path})"
assert (
cice_grid.kmt_ds.inputfile == mom_grid.mask_path
), "mask inputfile attribute incorrect ({cice_grid.kmt_ds.inputfile} != {mom_grid.mask_path})"
2 changes: 1 addition & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class MockFile:
"""Class for testing parsers that require a file.
"""Class for testing parsers that require a text file.

Usage:
@pytest.fixture
Expand Down
Loading