diff --git a/cdm-test/src/test/java/ucar/nc2/dt/grid/TestCFWriter2.java b/cdm-test/src/test/java/ucar/nc2/dt/grid/TestCFWriter2.java index 838d2cab33..4674627caf 100644 --- a/cdm-test/src/test/java/ucar/nc2/dt/grid/TestCFWriter2.java +++ b/cdm-test/src/test/java/ucar/nc2/dt/grid/TestCFWriter2.java @@ -1,10 +1,14 @@ /* - * Copyright (c) 1998-2018 University Corporation for Atmospheric Research/Unidata + * Copyright (c) 1998-2025 University Corporation for Atmospheric Research/Unidata * See LICENSE for license information. */ package ucar.nc2.dt.grid; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; @@ -12,10 +16,12 @@ import org.junit.rules.TemporaryFolder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import ucar.nc2.Attribute; import ucar.nc2.NetcdfFile; import ucar.nc2.NetcdfFileWriter; import ucar.nc2.NetcdfFiles; import ucar.nc2.Variable; +import ucar.nc2.constants.CF; import ucar.nc2.grib.collection.Grib; import ucar.nc2.time.CalendarDate; import ucar.nc2.time.CalendarDateRange; @@ -23,7 +29,6 @@ import ucar.unidata.geoloc.LatLonPoint; import ucar.unidata.geoloc.LatLonRect; import ucar.unidata.geoloc.ProjectionPoint; -import ucar.unidata.geoloc.ProjectionPointImpl; import ucar.unidata.geoloc.ProjectionRect; import ucar.unidata.util.test.category.NeedsCdmUnitTest; import ucar.unidata.util.test.TestDir; @@ -31,7 +36,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import static org.junit.Assert.assertTrue; /** * Test CFGridWriter2 @@ -256,6 +260,51 @@ private void testFileSize(String fileIn, String gridNames, String startDate, Str } } + @Test + @Category(NeedsCdmUnitTest.class) + public void testAxisProjUnitMismatch() throws Exception { + String fileOut = tempFolder.newFile().getAbsolutePath(); + String varName = "HNW"; + + final double falseEastingMeters = 400000.0; + final double falseEastingKm = falseEastingMeters / 1000; + + try (GridDataset gds = GridDataset.open(TestDir.cdmUnitTestDir + "ncss/test/falseEastingNorthingScaleReset.nc4")) { + Variable x = gds.getNetcdfFile().findVariable("x"); + assertNotNull(x); + Attribute xUnits = x.findAttribute(CF.UNITS); + assertNotNull(xUnits); + assertThat(xUnits.getStringValue()).isEqualTo("m"); + + Variable proj = gds.getNetcdfFile().findVariable("lambert_conformal_conic"); + assertNotNull(proj); + Attribute feAttr = proj.findAttribute(CF.FALSE_EASTING); + assertNotNull(feAttr); + // false_easting in meters + assertThat(feAttr.getNumericValue()).isEqualTo(falseEastingMeters); + + // setup subset + List gridList = new ArrayList<>(); + gridList.add(varName); + + NetcdfFileWriter writer = NetcdfFileWriter.createNew(NetcdfFileWriter.Version.netcdf3, fileOut); + // write subset + CFGridWriter2.writeFile(gds, gridList, null, null, 1, null, null, 1, true, writer); + } + try (NetcdfFile ncf = NetcdfFiles.open(fileOut)) { + Variable proj = ncf.findVariable("lambert_conformal_conic"); + assertNotNull(proj); + Attribute feAttr = proj.findAttribute(CF.FALSE_EASTING); + assertNotNull(feAttr); + // GeoX in km + Variable x = ncf.findVariable("x"); + Attribute xUnits = x.findAttribute(CF.UNITS); + assertNotNull(xUnits); + assertThat(xUnits.getStringValue()).isEqualTo("km"); + // false_easting in km + assertThat(feAttr.getNumericValue()).isEqualTo(falseEastingKm); + } + } } diff --git a/cdm/core/src/main/java/ucar/nc2/ft2/coverage/writer/CFGridCoverageWriter.java b/cdm/core/src/main/java/ucar/nc2/ft2/coverage/writer/CFGridCoverageWriter.java index 049fb4ee65..9aafde31b8 100644 --- a/cdm/core/src/main/java/ucar/nc2/ft2/coverage/writer/CFGridCoverageWriter.java +++ b/cdm/core/src/main/java/ucar/nc2/ft2/coverage/writer/CFGridCoverageWriter.java @@ -1,7 +1,8 @@ /* - * Copyright (c) 1998-2020 John Caron and University Corporation for Atmospheric Research/Unidata + * Copyright (c) 1998-2025 John Caron and University Corporation for Atmospheric Research/Unidata * See LICENSE for license information. */ + package ucar.nc2.ft2.coverage.writer; import com.google.common.base.Preconditions; @@ -13,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import javax.annotation.Nullable; import ucar.ma2.Array; import ucar.ma2.DataType; @@ -20,6 +22,7 @@ import ucar.ma2.Section; import ucar.nc2.Attribute; import ucar.nc2.AttributeContainer; +import ucar.nc2.AttributeContainerMutable; import ucar.nc2.Dimension; import ucar.nc2.Group; import ucar.nc2.Variable; @@ -28,6 +31,7 @@ import ucar.nc2.constants.CDM; import ucar.nc2.constants.CF; import ucar.nc2.constants._Coordinate; +import ucar.nc2.dataset.transform.AbstractTransformBuilder; import ucar.nc2.ft2.coverage.Coverage; import ucar.nc2.ft2.coverage.CoverageCollection; import ucar.nc2.ft2.coverage.CoverageCoordAxis; @@ -308,8 +312,53 @@ private void addCoordTransforms(CoverageCollection subsetDataset, Group.Builder // scalar coordinate transform variable - container for transform info Variable.Builder ctv = Variable.builder().setName(ct.getName()).setDataType(DataType.INT); group.addVariable(ctv); - ctv.addAttributes(ct.attributes()); + AttributeContainer ctAttrs = ct.attributes(); + + AttributeContainerMutable newAttrs = AttributeContainerMutable.copyFrom(ctAttrs); + // adjust false_easting/false_northing if needed + // possibly needed if the subset dataset includes GeoX or GeoY axes, so first find those + Map mapCoordAxes = subsetDataset.getCoordAxes().stream() + .filter(ca -> ca.getAxisType() == AxisType.GeoX || ca.getAxisType() == AxisType.GeoY) + .collect(Collectors.toMap(CoverageCoordAxis::getAxisType, mca -> mca)); + // if we found GeoX and/or GeoY axes, start checking + if (!mapCoordAxes.isEmpty()) { + boolean eastScaled = false; + if (mapCoordAxes.containsKey(AxisType.GeoX)) { + eastScaled = scaleFalseEastingNorthing(CF.FALSE_EASTING, ctAttrs, newAttrs, + mapCoordAxes.get(AxisType.GeoX).attributes().findAttributeString(CF.UNITS, null)); + } + boolean northScaled = false; + if (mapCoordAxes.containsKey(AxisType.GeoY)) { + northScaled = scaleFalseEastingNorthing(CF.FALSE_NORTHING, ctAttrs, newAttrs, + mapCoordAxes.get(AxisType.GeoY).attributes().findAttributeString(CF.UNITS, null)); + } + // do not propagate the unit attribute on the projection variable if we ensured the + // units match, as this is state matchs CF (likely this attribute was added when creating + // a coverage from the NetcdfDataset. + if (!ctAttrs.findAttributeString(CF.UNITS, "").isEmpty()) { + if (eastScaled || northScaled) { + newAttrs.removeAttribute(CF.UNITS); + } + } + } + ctv.addAttributes(newAttrs.toImmutable()); + } + } + + private boolean scaleFalseEastingNorthing(String falseValueType, AttributeContainer cta, + AttributeContainerMutable newCta, String caUnits) { + double falseValue = cta.findAttributeDouble(falseValueType, Double.MIN_VALUE); + boolean scaled = false; + if (falseValue != Double.MIN_VALUE) { + double scalef = AbstractTransformBuilder.getFalseEastingScaleFactor(caUnits); + if (scalef != 1.0) { + scaled = true; + newCta.removeAttribute(falseValueType); + // here we divide by scalef, to undo the scalef applied when creating the Coverage + newCta.addAttribute(Attribute.builder(falseValueType).setNumericValue(falseValue / scalef, false).build()); + } } + return scaled; } private void addLatLon2D(CoverageCollection subsetDataset, Group.Builder group) { diff --git a/cdm/core/src/test/java/ucar/nc2/ft2/coverage/writer/TestCFGridCoverageWriter.java b/cdm/core/src/test/java/ucar/nc2/ft2/coverage/writer/TestCFGridCoverageWriter.java new file mode 100644 index 0000000000..4443eed69d --- /dev/null +++ b/cdm/core/src/test/java/ucar/nc2/ft2/coverage/writer/TestCFGridCoverageWriter.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 University Corporation for Atmospheric Research/Unidata + * See LICENSE for license information. + */ + +package ucar.nc2.ft2.coverage.writer; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertNotNull; + +import java.io.IOException; +import org.junit.Rule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.rules.TemporaryFolder; +import ucar.ma2.InvalidRangeException; +import ucar.nc2.Attribute; +import ucar.nc2.NetcdfFile; +import ucar.nc2.NetcdfFiles; +import ucar.nc2.Variable; +import ucar.nc2.constants.CF; +import ucar.nc2.constants.FeatureType; +import ucar.nc2.ft2.coverage.CoverageCollection; +import ucar.nc2.ft2.coverage.CoverageDatasetFactory; +import ucar.nc2.ft2.coverage.FeatureDatasetCoverage; +import ucar.nc2.write.NetcdfFileFormat; +import ucar.nc2.write.NetcdfFormatWriter; +import ucar.unidata.util.test.TestDir; +import ucar.unidata.util.test.category.NeedsCdmUnitTest; + +public class TestCFGridCoverageWriter { + + @Rule + public final TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + @Category(NeedsCdmUnitTest.class) + public void testCFGridCoverageWriterNonKmProjectionParams() throws IOException, InvalidRangeException { + String fileOut = tempFolder.newFile().getAbsolutePath(); + String fileIn = TestDir.cdmUnitTestDir + "ncss/test/falseEastingNorthingScaleReset.nc4"; + + try (FeatureDatasetCoverage cc = CoverageDatasetFactory.open(fileIn)) { + CoverageCollection gcs = cc.findCoverageDataset(FeatureType.GRID); + assertNotNull(gcs); + NetcdfFormatWriter.Builder writerb = + NetcdfFormatWriter.builder().setNewFile(true).setFormat(NetcdfFileFormat.NETCDF3).setLocation(fileOut); + + CFGridCoverageWriter.Result result = CFGridCoverageWriter.write(gcs, null, null, false, writerb, 0); + if (!result.wasWritten()) { + throw new InvalidRangeException("Error writing: " + result.getErrorMessage()); + } + } + + try (NetcdfFile ncf = NetcdfFiles.open(fileOut)) { + Variable proj = ncf.findVariable("lambert_conformal_conic"); + assertNotNull(proj); + Attribute feAttr = proj.findAttribute(CF.FALSE_EASTING); + assertNotNull(feAttr); + Attribute fnAttr = proj.findAttribute(CF.FALSE_NORTHING); + assertNotNull(fnAttr); + // GeoX axis in meters + Variable x = ncf.findVariable("x"); + assertNotNull(x); + assertThat(x.getUnitsString()).isEqualTo("m"); + // false_easting in m + assertThat(feAttr.getNumericValue()).isEqualTo(400000.0); + assertThat(fnAttr.getNumericValue()).isEqualTo(400000.0); + // should not have a units attribute on the proj variable + Attribute unitsAttr = proj.findAttribute(CF.UNITS); + assertThat(unitsAttr).isNull(); + } + } +}