You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2018/12/26 18:36:19 UTC

[sis] branch geoapi-4.0 updated: Replace fill/missing values by NaN in netCDF files when there is no offset and scale factor. The intent is to make easier to build a SampleDimension for such data.

This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 3ebe785  Replace fill/missing values by NaN in netCDF files when there is no offset and scale factor. The intent is to make easier to build a SampleDimension for such data.
3ebe785 is described below

commit 3ebe7855c1da8ae43e62726953f103233cafea81
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Wed Dec 26 19:35:22 2018 +0100

    Replace fill/missing values by NaN in netCDF files when there is no offset and scale factor.
    The intent is to make easier to build a SampleDimension for such data.
---
 .../java/org/apache/sis/coverage/Category.java     |  84 +++++---
 .../org/apache/sis/coverage/ConvertedCategory.java |  16 --
 .../org/apache/sis/coverage/SampleDimension.java   |  41 +++-
 .../java/org/apache/sis/coverage/CategoryTest.java |  21 ++
 .../sis/geometry/AbstractDirectPosition.java       |   8 +-
 .../org/apache/sis/geometry/AbstractEnvelope.java  |   6 +-
 .../org/apache/sis/geometry/ArrayEnvelope.java     |   3 +-
 .../org/apache/sis/geometry/DirectPosition2D.java  |   4 +-
 .../apache/sis/geometry/GeneralDirectPosition.java |   4 +-
 .../operation/transform/MathTransformTestCase.java |   4 +-
 .../transform/PassThroughTransformTest.java        |   3 +-
 .../org/apache/sis/internal/util/Numerics.java     |  91 --------
 .../main/java/org/apache/sis/math/ArrayVector.java |   3 +-
 .../main/java/org/apache/sis/util/ArraysExt.java   | 231 ++++++++++++++++++---
 .../org/apache/sis/internal/util/NumericsTest.java |  22 --
 .../java/org/apache/sis/util/ArraysExtTest.java    |  24 ++-
 .../org/apache/sis/internal/netcdf/Variable.java   |  83 +++++++-
 .../sis/internal/netcdf/impl/VariableInfo.java     |  10 +-
 .../sis/internal/netcdf/ucar/VariableWrapper.java  |  17 +-
 .../apache/sis/storage/netcdf/GridResource.java    |  42 ++--
 20 files changed, 473 insertions(+), 244 deletions(-)

diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/Category.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/Category.java
index f7c16a5..1f0a7e3 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/coverage/Category.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/Category.java
@@ -133,6 +133,7 @@ public class Category implements Serializable {
      * The range is null if this category is a qualitative category converted to real values.
      * Those categories are characterized by two apparently contradictory properties,
      * and are implemented using {@link Float#NaN} values:
+     *
      * <ul>
      *   <li>This category is member of a {@code SampleDimension} having an identity
      *       {@linkplain SampleDimension#getTransferFunction() transfer function}.</li>
@@ -192,13 +193,14 @@ public class Category implements Serializable {
      *
      * @param  name     the category name (mandatory).
      * @param  samples  the minimum and maximum sample values (mandatory).
-     * @param  toUnits  the conversion from sample values to real values,
-     *                  or {@code null} for constructing a qualitative category.
+     * @param  toUnits  the conversion from sample values to real values (possibly identity), or {@code null}
+     *                  for constructing a qualitative category. Mandatory if {@code units} is non-null.
      * @param  units    the units of measurement, or {@code null} if not applicable.
      *                  This is the target units after conversion by {@code toUnits}.
      * @param  toNaN    mapping from sample values to ordinal values to be supplied to {@link MathFunctions#toNanFloat(int)}.
-     *                  That mapping is used only if {@code toUnits} is {@code null}. That mapping is responsible to ensure that
-     *                  there is no ordinal value collision between different categories in the same {@link SampleDimension}.
+     *                  That mapping is used only if {@code toUnits} is {@code null} and {@code samples} are not NaN values.
+     *                  That mapping is responsible to ensure that there is no ordinal value collision between different categories
+     *                  in the same {@link SampleDimension}.
      *                  The input is a real number in the {@code samples} range and the output shall be a unique value between
      *                  {@value MathFunctions#MIN_NAN_ORDINAL} and {@value MathFunctions#MAX_NAN_ORDINAL} inclusive.
      */
@@ -207,21 +209,33 @@ public class Category implements Serializable {
     {
         ArgumentChecks.ensureNonEmpty("name", name);
         ArgumentChecks.ensureNonNull("samples", samples);
+        if (units != null) {
+            ArgumentChecks.ensureNonNull("toUnits", toUnits);
+            // The converse is not true: we allow 'units' to be null even if 'toUnits' is non-null.
+        }
         this.name    = Types.toInternationalString(name);
         this.minimum = samples.getMinDouble(true);
         this.maximum = samples.getMaxDouble(true);
+        final boolean isNaN = Double.isNaN(minimum);
         /*
-         * Following arguments check uses '!' in comparison in order to reject NaN values.
+         * Following arguments check uses '!' in comparison in order to reject NaN values in quantitative category.
+         * For qualitative category, NaN is accepted provided that it is the same NaN for both ends of the range.
          */
-        if (!(minimum <= maximum) || (minimum == Double.NEGATIVE_INFINITY) || (maximum == Double.POSITIVE_INFINITY)) {
-            throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalCategoryRange_2, name, samples));
+        if (!(minimum <= maximum)) {
+            if (toUnits != null || !isNaN || Double.doubleToRawLongBits(minimum) != Double.doubleToRawLongBits(maximum)) {
+                throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalCategoryRange_2, name, samples));
+            }
         }
-        /*
-         * Creates the transform doing the inverse conversion (from real values to sample values).
-         * This transform is assigned to a new Category object with its own minimum and maximum values.
-         * Those minimum and maximum may be NaN if this category is a qualitative category.
-         */
-        try {
+        if (isNaN) {
+            range      = null;
+            converse   = this;
+            toConverse = identity();
+        } else try {
+            /*
+             * Creates the transform doing the inverse conversion (from real values to sample values).
+             * This transform is assigned to a new Category object with its own minimum and maximum values.
+             * Those minimum and maximum may be NaN if this category is a qualitative category.
+             */
             final MathTransform1D toSamples;
             if (toUnits != null) {
                 toConverse = toUnits;
@@ -235,15 +249,15 @@ public class Category implements Serializable {
                 }
                 toSamples = toUnits.inverse();
             } else {
-                ArgumentChecks.ensureNonNull("toNaN", toNaN);
-                final int ordinal = toNaN.applyAsInt(minimum);
                 /*
                  * For qualitative category, the transfer function maps to NaN while the inverse function maps back
                  * to some value in the [minimum … maximum] range. We chose the value closest to positive zero.
                  */
-                toConverse = (MathTransform1D) MathTransforms.linear(0, MathFunctions.toNanFloat(ordinal));
+                ArgumentChecks.ensureNonNull("toNaN", toNaN);
                 final double value = (minimum > 0) ? minimum : (maximum <= 0) ? maximum : 0d;
-                toSamples = (MathTransform1D) MathTransforms.linear(0, value);
+                final float nan = MathFunctions.toNanFloat(toNaN.applyAsInt(value));
+                toConverse = (MathTransform1D) MathTransforms.linear(0, nan);
+                toSamples  = (MathTransform1D) MathTransforms.linear(0, value);
             }
             range = samples;
             converse = new ConvertedCategory(this, toSamples, toUnits != null, units);
@@ -347,10 +361,35 @@ public class Category implements Serializable {
      *
      * @see SampleDimension#getSampleRange()
      */
+    @SuppressWarnings({"unchecked", "rawtypes"})
     public NumberRange<?> getSampleRange() {
-        // Same assumption than in 'isQuantitative()'.
-        assert range != null : this;
-        return range;
+        if (range != null) {
+            return range;
+        }
+        /*
+         * The range can be null only if the minimum and maximum are NaN. This may be the case if NaN were
+         * given explicitly to the constructor or if this category is an instance of ConvertedCategory for
+         * qualitative category. In the later case, the NaN are the result of converting the sample values.
+         * We favor the Float type because values should be NaN produced by MathFunctions.toNanFloat(int).
+         * The minimum and maximum are usually the same value, but not necessarily.
+         */
+        final float min = (float) minimum;
+        final float max = (float) maximum;
+        final Number v1, v2;
+        final Class<?> type;
+        if (Double.doubleToRawLongBits(minimum) == Double.doubleToRawLongBits(min) &&
+            Double.doubleToRawLongBits(maximum) == Double.doubleToRawLongBits(max))
+        {
+            v1 = min;
+            v2 = (Float.floatToRawIntBits(min) == Float.floatToRawIntBits(max)) ? v1 : max;
+            type = Float.class;
+        } else {
+            v1 = minimum;
+            v2 = (Double.doubleToRawLongBits(minimum) == Double.doubleToRawLongBits(maximum)) ? v1 : maximum;
+            type = Double.class;
+        }
+        return new NumberRange(type, v1, true, v2, true);
+        // Do not use NumberRange.create(float, …) because it rejects NaN values.
     }
 
     /**
@@ -391,12 +430,9 @@ public class Category implements Serializable {
      */
     public Optional<MathTransform1D> getTransferFunction() {
         /*
-         * This implementation assumes that this method will always be invoked on the instance
-         * created for sample values, never on the instance created by the private constructor.
-         * If this method was invoked on "real values category", then we would need to return
+         * Note: if this method is invoked on "real values category", then we need to return
          * the identity transform instead than 'toConverse'. This is done by ConvertedCategory.
          */
-        assert range != null : this;
         return (converse.range != null) ? Optional.of(toConverse) : Optional.empty();
     }
 
diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
index f73cec7..00c9007 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
@@ -20,7 +20,6 @@ import java.util.Optional;
 import javax.measure.Unit;
 import org.opengis.referencing.operation.MathTransform1D;
 import org.opengis.referencing.operation.TransformException;
-import org.apache.sis.measure.NumberRange;
 
 
 /**
@@ -65,21 +64,6 @@ final class ConvertedCategory extends Category {
     }
 
     /**
-     * Returns the range of value, which is the same as {@link #getMeasurementRange()} unless the values are NaN.
-     */
-    @Override
-    public NumberRange<?> getSampleRange() {
-        if (range != null) {
-            return range;
-        }
-        Float min = (float) minimum;        // Should be NaN produced by MathFunctions.toNanFloat(int).
-        Float max = (float) maximum;
-        if (max.equals(min)) max = min;
-        // Do not use NumberRange.create(float, …) because it rejects NaN values.
-        return new NumberRange<>(Float.class, min, true, max, true);
-    }
-
-    /**
      * Returns the <cite>transfer function</cite> from sample values to real values in units of measurement.
      * The function is absent if this category is not a {@linkplain #isQuantitative() quantitative} category.
      */
diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleDimension.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleDimension.java
index fce8b7b..834585d 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleDimension.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleDimension.java
@@ -586,16 +586,39 @@ public class SampleDimension implements Serializable {
          * Creates a range for the given minimum and maximum values. We use the static factory methods instead
          * than the {@link NumberRange} constructor for sharing existing range instances. This is also a way
          * to ensure that the number type is one of the primitive wrappers.
+         *
+         * <p>This method is invoked for qualitative categories only. For that reason, it accepts NaN values.</p>
          */
-        private static NumberRange<?> range(final Class<?> type, final Number minimum, final Number maximum) {
+        private static NumberRange<?> range(final Class<?> type, Number minimum, Number maximum) {
             switch (Numbers.getEnumConstant(type)) {
-                case Numbers.BYTE:    return NumberRange.create(minimum.byteValue(),   true, maximum.byteValue(),   true);
-                case Numbers.SHORT:   return NumberRange.create(minimum.shortValue(),  true, maximum.shortValue(),  true);
-                case Numbers.INTEGER: return NumberRange.create(minimum.intValue(),    true, maximum.intValue(),    true);
-                case Numbers.LONG:    return NumberRange.create(minimum.longValue(),   true, maximum.longValue(),   true);
-                case Numbers.FLOAT:   return NumberRange.create(minimum.floatValue(),  true, maximum.floatValue(),  true);
-                default:              return NumberRange.create(minimum.doubleValue(), true, maximum.doubleValue(), true);
+                case Numbers.BYTE:    return NumberRange.create(minimum.byteValue(),  true, maximum.byteValue(),   true);
+                case Numbers.SHORT:   return NumberRange.create(minimum.shortValue(), true, maximum.shortValue(),  true);
+                case Numbers.INTEGER: return NumberRange.create(minimum.intValue(),   true, maximum.intValue(),    true);
+                case Numbers.LONG:    return NumberRange.create(minimum.longValue(),  true, maximum.longValue(),   true);
+                case Numbers.FLOAT: {
+                    final float min = minimum.floatValue();
+                    final float max = maximum.floatValue();
+                    if (!Float.isNaN(min) || !Float.isNaN(max)) {       // Let 'create' throws an exception if only one value is NaN.
+                        return NumberRange.create(min, true, max, true);
+                    }
+                    if (minimum.getClass() != Float.class) minimum = min;
+                    if (maximum.getClass() != Float.class) maximum = max;
+                    break;
+                }
+                default: {
+                    final double min = minimum.doubleValue();
+                    final double max = maximum.doubleValue();
+                    if (!Double.isNaN(min) || !Double.isNaN(max)) {     // Let 'create' throws an exception if only one value is NaN.
+                        return NumberRange.create(min, true, max, true);
+                    }
+                    if (minimum.getClass() != Double.class) minimum = min;
+                    if (maximum.getClass() != Double.class) maximum = max;
+                    break;
+                }
             }
+            @SuppressWarnings({"unchecked", "rawtypes"})
+            final NumberRange<?> samples = new NumberRange(type, minimum, true, maximum, true);
+            return samples;
         }
 
         /**
@@ -606,10 +629,10 @@ public class SampleDimension implements Serializable {
          *
          * @param  name    the category name as a {@link String} or {@link InternationalString} object,
          *                 or {@code null} for a default "fill value" name.
-         * @param  sample  the background value. Can not be NaN.
+         * @param  sample  the background value.
          * @return {@code this}, for method call chaining.
          */
-        public Builder setBackground(CharSequence name, final Number sample) {
+        public Builder setBackground(CharSequence name, Number sample) {
             ArgumentChecks.ensureNonNull("sample", sample);
             if (name == null) {
                 name = Vocabulary.formatInternational(Vocabulary.Keys.FillValue);
diff --git a/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java b/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java
index df2db15..11b659b 100644
--- a/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java
+++ b/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java
@@ -218,4 +218,25 @@ public final strictfp class CategoryTest extends TestCase {
             assertTrue       ("isQuantitative",                          category.isQuantitative());
         }
     }
+
+    /**
+     * Tests a category with a NaN value.
+     */
+    @Test
+    public void testCategoryNaN() {
+        final Category category = new Category("NaN", new NumberRange<>(Float.class, Float.NaN, true, Float.NaN, true), null, null, null);
+        final NumberRange<?> range = category.getSampleRange();
+        assertSame  ("converse",       category,   category.converse);
+        assertEquals("name",           "NaN",      String.valueOf(category.name));
+        assertEquals("name",           "NaN",      String.valueOf(category.getName()));
+        assertEquals("minimum",        Double.NaN, category.minimum, STRICT);
+        assertEquals("maximum",        Double.NaN, category.maximum, STRICT);
+        assertNull  ("sampleRange",                category.range);
+        assertEquals("range.minValue", Float.NaN,  range.getMinValue());
+        assertEquals("range.maxValue", Float.NaN,  range.getMaxValue());
+        assertFalse ("measurementRange",           category.getMeasurementRange().isPresent());
+        assertFalse ("transferFunction",           category.getTransferFunction().isPresent());
+        assertTrue  ("toConverse.isIdentity",      category.toConverse.isIdentity());
+        assertFalse ("isQuantitative",             category.isQuantitative());
+    }
 }
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractDirectPosition.java b/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractDirectPosition.java
index 2136a8e..3573944 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractDirectPosition.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractDirectPosition.java
@@ -252,12 +252,12 @@ public abstract class AbstractDirectPosition extends FormattableObject implement
      * (WKT) format.
      *
      * @param  position           the position to format.
-     * @param  isSimplePrecision  {@code true} if every ordinate values can be casted to {@code float}.
+     * @param  isSinglePrecision  {@code true} if every ordinate values can be casted to {@code float}.
      * @return the point as a {@code POINT} in WKT format.
      *
-     * @see Numerics#isSimplePrecision(double[])
+     * @see Numerics#isSinglePrecision(double[])
      */
-    static String toString(final DirectPosition position, final boolean isSimplePrecision) {
+    static String toString(final DirectPosition position, final boolean isSinglePrecision) {
         final StringBuilder buffer = new StringBuilder(32).append("POINT");
         final int dimension = position.getDimension();
         if (dimension == 0) {
@@ -267,7 +267,7 @@ public abstract class AbstractDirectPosition extends FormattableObject implement
             for (int i=0; i<dimension; i++) {
                 buffer.append(separator);
                 final double ordinate = position.getOrdinate(i);
-                if (isSimplePrecision) {
+                if (isSinglePrecision) {
                     buffer.append((float) ordinate);
                 } else {
                     buffer.append(ordinate);
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractEnvelope.java b/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractEnvelope.java
index abdcf54..d34990e 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractEnvelope.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/geometry/AbstractEnvelope.java
@@ -1152,14 +1152,14 @@ public abstract class AbstractEnvelope extends FormattableObject implements Enve
      * methods for formatting a {@code BOX} element from an envelope.
      *
      * @param  envelope           the envelope to format.
-     * @param  isSimplePrecision  {@code true} if every lower and upper corner values can be casted to {@code float}.
+     * @param  isSinglePrecision  {@code true} if every lower and upper corner values can be casted to {@code float}.
      * @return this envelope as a {@code BOX} or {@code BOX3D} (most typical dimensions) element.
      *
      * @see GeneralEnvelope#GeneralEnvelope(CharSequence)
      * @see CoordinateFormat
      * @see org.apache.sis.io.wkt
      */
-    static String toString(final Envelope envelope, final boolean isSimplePrecision) {
+    static String toString(final Envelope envelope, final boolean isSinglePrecision) {
         final int dimension = envelope.getDimension();
         final StringBuilder buffer = new StringBuilder(64).append("BOX");
         if (dimension != 2) {
@@ -1175,7 +1175,7 @@ public abstract class AbstractEnvelope extends FormattableObject implements Enve
                 for (int i=0; i<dimension; i++) {
                     buffer.append(i == 0 && !isUpper ? '(' : ' ');
                     final double ordinate = (isUpper ? upperCorner : lowerCorner).getOrdinate(i);
-                    if (isSimplePrecision) {
+                    if (isSinglePrecision) {
                         buffer.append((float) ordinate);
                     } else {
                         buffer.append(ordinate);
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/geometry/ArrayEnvelope.java b/core/sis-referencing/src/main/java/org/apache/sis/geometry/ArrayEnvelope.java
index e5beb1e..14bbab6 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/geometry/ArrayEnvelope.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/geometry/ArrayEnvelope.java
@@ -37,7 +37,6 @@ import org.apache.sis.referencing.CommonCRS;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.CharSequences;
 import org.apache.sis.util.resources.Errors;
-import org.apache.sis.internal.util.Numerics;
 
 import static org.apache.sis.util.ArgumentChecks.*;
 import static org.apache.sis.math.MathFunctions.isNegative;
@@ -594,6 +593,6 @@ scanNumber: while ((i += Character.charCount(c)) < length) {
      */
     @Override
     public String toString() {
-        return toString(this, Numerics.isSimplePrecision(ordinates));
+        return toString(this, ArraysExt.isSinglePrecision(ordinates));
     }
 }
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/geometry/DirectPosition2D.java b/core/sis-referencing/src/main/java/org/apache/sis/geometry/DirectPosition2D.java
index c3ed407..8152d6e 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/geometry/DirectPosition2D.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/geometry/DirectPosition2D.java
@@ -22,8 +22,8 @@ import org.opengis.geometry.DirectPosition;
 import org.opengis.geometry.MismatchedDimensionException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.opengis.referencing.cs.AxisDirection;
-import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.ArraysExt;
 
 import static java.lang.Double.doubleToLongBits;
 import static org.apache.sis.util.ArgumentChecks.ensureNonNull;
@@ -292,7 +292,7 @@ public class DirectPosition2D extends Point2D.Double implements DirectPosition,
      */
     @Override
     public String toString() {
-        return AbstractDirectPosition.toString(this, Numerics.isSimplePrecision(x, y));
+        return AbstractDirectPosition.toString(this, ArraysExt.isSinglePrecision(x, y));
     }
 
     /**
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/geometry/GeneralDirectPosition.java b/core/sis-referencing/src/main/java/org/apache/sis/geometry/GeneralDirectPosition.java
index 2d85188..4a81c2f 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/geometry/GeneralDirectPosition.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/geometry/GeneralDirectPosition.java
@@ -30,8 +30,8 @@ import java.security.PrivilegedAction;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.geometry.MismatchedDimensionException;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.util.resources.Errors;
+import org.apache.sis.util.ArraysExt;
 
 import static org.apache.sis.util.ArgumentChecks.ensureDimensionMatches;
 
@@ -283,7 +283,7 @@ public class GeneralDirectPosition extends AbstractDirectPosition implements Ser
      */
     @Override
     public String toString() {
-        return toString(this, Numerics.isSimplePrecision(ordinates));
+        return toString(this, ArraysExt.isSinglePrecision(ordinates));
     }
 
     /**
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/MathTransformTestCase.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/MathTransformTestCase.java
index ed6cc93..dc2be7b 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/MathTransformTestCase.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/MathTransformTestCase.java
@@ -32,10 +32,10 @@ import org.apache.sis.parameter.Parameterized;
 import org.apache.sis.measure.Longitude;
 import org.apache.sis.util.Debug;
 import org.apache.sis.util.Classes;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.io.TableAppender;
 import org.apache.sis.io.wkt.Convention;
 import org.apache.sis.io.wkt.FormattableObject;
-import org.apache.sis.internal.util.Numerics;
 import static java.lang.StrictMath.*;
 
 // Test imports
@@ -243,7 +243,7 @@ public abstract strictfp class MathTransformTestCase extends TransformTestCase {
          * But in Apache SIS, we want to verify consistency for all math transforms. A previous version had
          * a bug with the Google projection which was unnoticed because of lack of this consistency check.
          */
-        final float[] asFloats = Numerics.copyAsFloats(coordinates);
+        final float[] asFloats = ArraysExt.copyAsFloats(coordinates);
         final float[] result   = verifyConsistency(asFloats);
         for (int i=0; i<coordinates.length; i++) {
             assertEquals("Detected change in source coordinates.", (float) coordinates[i], asFloats[i], 0f);    // Paranoiac check.
diff --git a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/PassThroughTransformTest.java b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/PassThroughTransformTest.java
index 66ceb84..2b6398b 100644
--- a/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/PassThroughTransformTest.java
+++ b/core/sis-referencing/src/test/java/org/apache/sis/referencing/operation/transform/PassThroughTransformTest.java
@@ -26,7 +26,6 @@ import org.opengis.referencing.operation.MathTransformFactory;
 import org.opengis.referencing.operation.TransformException;
 import org.apache.sis.referencing.operation.matrix.Matrices;
 import org.apache.sis.referencing.operation.matrix.Matrix3;
-import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.util.ArraysExt;
 
 // Test imports
@@ -241,7 +240,7 @@ public final strictfp class PassThroughTransformTest extends MathTransformTestCa
         /*
          * Verify the consistency between different 'transform(…)' methods.
          */
-        final float[] sourceAsFloat = Numerics.copyAsFloats(passthroughData);
+        final float[] sourceAsFloat = ArraysExt.copyAsFloats(passthroughData);
         final float[] targetAsFloat = verifyConsistency(sourceAsFloat);
         assertEquals("Unexpected length of transformed array.", expectedData.length, targetAsFloat.length);
         /*
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
index 757e519..8090ef6 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
@@ -20,7 +20,6 @@ import java.util.Map;
 import java.util.HashMap;
 import org.apache.sis.util.Debug;
 import org.apache.sis.util.Static;
-import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.ComparisonMode;
 import org.apache.sis.math.DecimalFunctions;
 import org.opengis.referencing.operation.Matrix;    // For javadoc
@@ -208,96 +207,6 @@ public final class Numerics extends Static {
     }
 
     /**
-     * Returns {@code true} if every values in the given {@code double} array could be casted to the
-     * {@code float} type without precision lost. This method treats all {@code NaN} values as equal.
-     *
-     * @param  values  the value to test for their precision.
-     * @return {@code true} if every values can be casted to the {@code float} type without precision lost.
-     */
-    public static boolean isSimplePrecision(final double... values) {
-        for (final double value : values) {
-            if (Double.doubleToLongBits(value) != Double.doubleToLongBits((float) value)) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    /**
-     * Returns a copy of the given array where each value has been casted to the {@code float} type,
-     * but only if this cast is lossless. If any cast causes data loss, then this method returns {@code null}.
-     *
-     * @param  data  the array to copy.
-     * @return a copy of the given array with values casted to the {@code float} type,
-     *         or {@code null} if the cast would cause data lost.
-     */
-    public static float[] copyAsFloatsIfLossless(final double[] data) {
-        /*
-         * Before to allocate a new array, performs a quick sampling of a few values.
-         * Basically the first value, the last value, a value in the middle and a few others.
-         */
-        int i = data.length - 1;
-        if (i < 0) {
-            return ArraysExt.EMPTY_FLOAT;
-        }
-        for (;;) {
-            final double d = data[i];
-            if (Double.doubleToRawLongBits(d) != Double.doubleToRawLongBits((float) d)) {
-                return null;
-            }
-            if (i == 0) break;
-            i >>>= 1;
-        }
-        /*
-         * At this point the quick sampling found no data loss. We can now allocate the array,
-         * but we will still need to check for each value, which may interrupt the copy at any time.
-         */
-        final float[] result = new float[data.length];
-        for (i = data.length; --i >= 0;) {
-            final double d = data[i];
-            final float  f = (float) d;
-            if (Double.doubleToRawLongBits(d) != Double.doubleToRawLongBits(f)) {
-                return null;
-            }
-            result[i] = f;
-        }
-        return result;
-    }
-
-    /**
-     * Returns a copy of the given array where each value has been casted to the {@code float} type.
-     *
-     * @param  data  the array to copy, or {@code null}.
-     * @return a copy of the given array with values casted to the {@code float} type,
-     *         or {@code null} if the given array was null.
-     */
-    public static float[] copyAsFloats(final double[] data) {
-        if (data == null) return null;
-        final float[] result = new float[data.length];
-        for (int i=0; i<data.length; i++) {
-            result[i] = (float) data[i];
-        }
-        return result;
-    }
-
-    /**
-     * Returns a copy of the given array where each value has been
-     * {@linkplain Math#round(double) rounded} to the {@code int} type.
-     *
-     * @param  data  the array to copy, or {@code null}.
-     * @return a copy of the given array with values rounded to the {@code int} type,
-     *         or {@code null} if the given array was null.
-     */
-    public static int[] copyAsInts(final double[] data) {
-        if (data == null) return null;
-        final int[] result = new int[data.length];
-        for (int i=0; i<data.length; i++) {
-            result[i] = Math.toIntExact(Math.round(data[i]));
-        }
-        return result;
-    }
-
-    /**
      * Returns {@code true} if the given floats are equals. Positive and negative zero are
      * considered different, while a NaN value is considered equal to all other NaN values.
      *
diff --git a/core/sis-utility/src/main/java/org/apache/sis/math/ArrayVector.java b/core/sis-utility/src/main/java/org/apache/sis/math/ArrayVector.java
index ed42e74..1ea8354 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/math/ArrayVector.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/math/ArrayVector.java
@@ -28,6 +28,7 @@ import java.util.Arrays;
 import java.util.Optional;
 import java.util.function.IntSupplier;
 import org.apache.sis.util.Numbers;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.collection.CheckedContainer;
 import org.apache.sis.internal.util.Numerics;
@@ -300,7 +301,7 @@ abstract class ArrayVector<E extends Number> extends Vector implements CheckedCo
 
         /** Returns a copy of current data as a floating point array. */
         @Override public float[] floatValues() {
-            return Numerics.copyAsFloats(array);
+            return ArraysExt.copyAsFloats(array);
         }
 
         /** Applies hash code contract specified {@link Vector#hashCode()}. */
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java b/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
index 3d91f73..51c80b0 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/ArraysExt.java
@@ -1703,6 +1703,203 @@ public final class ArraysExt extends Static {
     }
 
     /**
+     * Replaces all occurrences of the given value by the given replacement.
+     * This method compares the values using {@link Double#doubleToRawLongBits(double)}:
+     *
+     * <ul>
+     *   <li>Positive zero is considered different then negative zero.</li>
+     *   <li>The {@linkplain org.apache.sis.math.MathFunctions#toNanFloat(int) various
+     *       possible NaN values} are considered different.</li>
+     * </ul>
+     *
+     * A common usage for this method is to replace pad values by {@link Double#NaN} in the
+     * sample values of a {@linkplain org.apache.sis.coverage.grid.GridCoverage grid coverage}.
+     * This method does nothing if the given array is {@code null} or if {@code search} is the
+     * same bits pattern than {@code replacement}.
+     *
+     * @param  array        the array where to perform the search and replace, or {@code null}.
+     * @param  search       the value to search.
+     * @param  replacement  the replacement.
+     *
+     * @since 1.0
+     */
+    public static void replace(final double[] array, final double search, final double replacement) {
+        if (array != null) {
+            final long bits = Double.doubleToRawLongBits(search);
+            if (bits != Double.doubleToRawLongBits(replacement)) {
+                for (int i=0; i<array.length; i++) {
+                    if (Double.doubleToRawLongBits(array[i]) == bits) {
+                        array[i] = replacement;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Replaces all occurrences of the given value by the given replacement.
+     * This method compares the values using {@link Float#floatToRawIntBits(float)}:
+     *
+     * <ul>
+     *   <li>Positive zero is considered different then negative zero.</li>
+     *   <li>The {@linkplain org.apache.sis.math.MathFunctions#toNanFloat(int) various
+     *       possible NaN values} are considered different.</li>
+     * </ul>
+     *
+     * A common usage for this method is to replace pad values by {@link Float#NaN} in the
+     * sample values of a {@linkplain org.apache.sis.coverage.grid.GridCoverage grid coverage}.
+     * This method does nothing if the given array is {@code null} or if {@code search} is the
+     * same bits pattern than {@code replacement}.
+     *
+     * @param  array        the array where to perform the search and replace, or {@code null}.
+     * @param  search       the value to search.
+     * @param  replacement  the replacement.
+     *
+     * @since 1.0
+     */
+    public static void replace(final float[] array, final float search, final float replacement) {
+        if (array != null) {
+            final int bits = Float.floatToRawIntBits(search);
+            if (bits != Float.floatToRawIntBits(replacement)) {
+                for (int i=0; i<array.length; i++) {
+                    if (Float.floatToRawIntBits(array[i]) == bits) {
+                        array[i] = replacement;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns a copy of the given array where each value has been casted to the {@code float} type.
+     * This method does not verify if the casts would cause data loss.
+     *
+     * @param  data  the array to copy, or {@code null}.
+     * @return a copy of the given array with values casted to the {@code float} type,
+     *         or {@code null} if the given array was null.
+     *
+     * @since 1.0
+     */
+    public static float[] copyAsFloats(final double[] data) {
+        if (data == null) return null;
+        final float[] result = new float[data.length];
+        for (int i=0; i<data.length; i++) {
+            result[i] = (float) data[i];
+        }
+        return result;
+    }
+
+    /**
+     * Returns a copy of the given array where each value has been casted to the {@code float} type,
+     * but only if all casts are lossless. If any cast causes data loss, then this method returns {@code null}.
+     * This method is equivalent to the following code, but potentially more efficient:
+     *
+     * {@preformat java
+     *    if (isSinglePrecision(data)) {
+     *        return copyAsFloat(data);
+     *    } else {
+     *        return null;
+     *    }
+     * }
+     *
+     * @param  data  the array to copy, or {@code null}.
+     * @return a copy of the given array with values casted to the {@code float} type, or
+     *         {@code null} if the given array was null or if a cast would cause data lost.
+     *
+     * @since 1.0
+     */
+    public static float[] copyAsFloatsIfLossless(final double[] data) {
+        if (data == null) return null;
+        /*
+         * Before to allocate a new array, performs a quick sampling of a few values.
+         * Basically the first value, the last value, a value in the middle and a few others.
+         */
+        int i = data.length - 1;
+        if (i < 0) {
+            return ArraysExt.EMPTY_FLOAT;
+        }
+        for (;;) {
+            final double d = data[i];
+            if (Double.doubleToRawLongBits(d) != Double.doubleToRawLongBits((float) d)) {
+                return null;
+            }
+            if (i == 0) break;
+            i >>>= 1;
+        }
+        /*
+         * At this point the quick sampling found no data loss. We can now allocate the array,
+         * but we will still need to check for each value, which may interrupt the copy at any time.
+         */
+        final float[] result = new float[data.length];
+        for (i = data.length; --i >= 0;) {
+            final double d = data[i];
+            final float  f = (float) d;
+            if (Double.doubleToRawLongBits(d) != Double.doubleToRawLongBits(f)) {
+                return null;
+            }
+            result[i] = f;
+        }
+        return result;
+    }
+
+    /**
+     * Returns {@code true} if every values in the given {@code double} array could be casted to the
+     * {@code float} type without data lost. If this method returns {@code true}, then the array can
+     * be converted to the {@code float[]} type with {@link #copyAsFloats(double[])} and the exact
+     * same {@code double} values can still be obtained by casting back each {@code float} value
+     * to {@code double}.
+     *
+     * @param  values  the values to test for their precision, or {@code null}.
+     * @return {@code true} if every values can be casted to the {@code float} type without data lost.
+     *
+     * @since 1.0
+     */
+    public static boolean isSinglePrecision(final double... values) {
+        if (values != null) {
+            for (final double value : values) {
+                if (Double.doubleToRawLongBits(value) != Double.doubleToRawLongBits((float) value)) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Returns {@code true} if the specified array contains at least one {@link Double#NaN NaN} value.
+     *
+     * @param  array  the array to check, or {@code null}.
+     * @return {@code true} if the given array is non-null and contains at least one NaN value.
+     */
+    public static boolean hasNaN(final double[] array) {
+        if (array != null) {
+            for (int i=0; i<array.length; i++) {
+                if (Double.isNaN(array[i])) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Returns {@code true} if the specified array contains at least one {@link Float#NaN NaN} value.
+     *
+     * @param  array  the array to check, or {@code null}.
+     * @return {@code true} if the given array is non-null and contains at least one NaN value.
+     */
+    public static boolean hasNaN(final float[] array) {
+        if (array != null) {
+            for (int i=0; i<array.length; i++) {
+                if (Float.isNaN(array[i])) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
      * Returns {@code true} if all values in the specified array are equal to the specified value,
      * which may be {@code null}.
      *
@@ -1780,40 +1977,6 @@ public final class ArraysExt extends Static {
     }
 
     /**
-     * Returns {@code true} if the specified array contains at least one {@link Double#NaN NaN} value.
-     *
-     * @param  array  the array to check, or {@code null}.
-     * @return {@code true} if the given array is non-null and contains at least one NaN value.
-     */
-    public static boolean hasNaN(final double[] array) {
-        if (array != null) {
-            for (int i=0; i<array.length; i++) {
-                if (Double.isNaN(array[i])) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    /**
-     * Returns {@code true} if the specified array contains at least one {@link Float#NaN NaN} value.
-     *
-     * @param  array  the array to check, or {@code null}.
-     * @return {@code true} if the given array is non-null and contains at least one NaN value.
-     */
-    public static boolean hasNaN(final float[] array) {
-        if (array != null) {
-            for (int i=0; i<array.length; i++) {
-                if (Float.isNaN(array[i])) {
-                    return true;
-                }
-            }
-        }
-        return false;
-    }
-
-    /**
      * Returns {@code true} if the specified array contains the specified value, ignoring case.
      * This method should be used only for very small arrays.
      *
diff --git a/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java b/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java
index 6e880b8..a7c7284 100644
--- a/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java
+++ b/core/sis-utility/src/test/java/org/apache/sis/internal/util/NumericsTest.java
@@ -84,28 +84,6 @@ public final strictfp class NumericsTest extends TestCase {
     }
 
     /**
-     * Tests {@link Numerics#copyAsFloatsIfLossless(double[])}.
-     */
-    @Test
-    public void testCopyAsFloatsIfLossless() {
-        double[] array = {2, 0.5, 0.25, Double.NaN, Double.POSITIVE_INFINITY};
-        float[] result = Numerics.copyAsFloatsIfLossless(array);
-        assertNotNull(result);
-        assertArrayEquals(new float[] {2f, 0.5f, 0.25f, Float.NaN, Float.POSITIVE_INFINITY}, result, 0f);
-        array[3] = 0.3333333333333;
-        assertNull(Numerics.copyAsFloatsIfLossless(array));
-    }
-
-    /**
-     * Tests {@link Numerics#isSimplePrecision(double[])}.
-     */
-    @Test
-    public void testIsSimplePrecision() {
-        assertTrue (Numerics.isSimplePrecision(2, 0.5, 0.25, Double.NaN, Double.POSITIVE_INFINITY));
-        assertFalse(Numerics.isSimplePrecision(2, 0.5, 1.0 / 3));
-    }
-
-    /**
      * Tests {@link Numerics#isUnsignedInteger(String)}.
      */
     @Test
diff --git a/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java b/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java
index bece8ac..295eb80 100644
--- a/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java
+++ b/core/sis-utility/src/test/java/org/apache/sis/util/ArraysExtTest.java
@@ -27,7 +27,7 @@ import static org.junit.Assert.*;
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Johann Sorel (Geomatys)
- * @version 0.4
+ * @version 1.0
  * @since   0.3
  * @module
  */
@@ -259,4 +259,26 @@ public final strictfp class ArraysExtTest extends TestCase {
         ArraysExt.swap(array, 1, 3);
         assertArrayEquals(new char[] {4, 15, 12, 8, 18}, array);
     }
+
+    /**
+     * Tests {@link ArraysExt#copyAsFloatsIfLossless(double[])}.
+     */
+    @Test
+    public void testCopyAsFloatsIfLossless() {
+        double[] array = {2, 0.5, 0.25, Double.NaN, Double.POSITIVE_INFINITY};
+        float[] result = ArraysExt.copyAsFloatsIfLossless(array);
+        assertNotNull(result);
+        assertArrayEquals(new float[] {2f, 0.5f, 0.25f, Float.NaN, Float.POSITIVE_INFINITY}, result, 0f);
+        array[3] = 0.3333333333333;
+        assertNull(ArraysExt.copyAsFloatsIfLossless(array));
+    }
+
+    /**
+     * Tests {@link ArraysExt#isSinglePrecision(double[])}.
+     */
+    @Test
+    public void testIsSinglePrecision() {
+        assertTrue (ArraysExt.isSinglePrecision(2, 0.5, 0.25, Double.NaN, Double.POSITIVE_INFINITY));
+        assertFalse(ArraysExt.isSinglePrecision(2, 0.5, 1.0 / 3));
+    }
 }
diff --git a/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/Variable.java b/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/Variable.java
index 2d4b8ff..9914d10 100644
--- a/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/Variable.java
+++ b/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/Variable.java
@@ -29,10 +29,13 @@ import org.opengis.referencing.operation.Matrix;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.coverage.grid.GridExtent;
 import org.apache.sis.math.Vector;
+import org.apache.sis.math.MathFunctions;
 import org.apache.sis.math.DecimalFunctions;
 import org.apache.sis.measure.NumberRange;
 import org.apache.sis.util.Numbers;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.collection.WeakHashSet;
+import org.apache.sis.internal.util.CollectionsExt;
 import org.apache.sis.util.logging.WarningListeners;
 import org.apache.sis.util.resources.Errors;
 import ucar.nc2.constants.CDM;                      // We use only String constants.
@@ -112,6 +115,12 @@ public abstract class Variable extends NamedElement {
     private boolean unitParsed;
 
     /**
+     * All no-data values declared for this variable, or an empty map if none.
+     * This is computed by {@link #getNodataValues()} and cached for efficiency and stability.
+     */
+    private Map<Number,Integer> nodataValues;
+
+    /**
      * Where to report warnings, if any.
      */
     private final WarningListeners<?> listeners;
@@ -227,6 +236,29 @@ public abstract class Variable extends NamedElement {
     }
 
     /**
+     * Returns {@code true} if this variable contains data that are already in the unit of measurement represented by
+     * {@link #getUnit()}, except for the fill/missing values. If {@code true}, then replacing fill/missing values by
+     * {@code NaN} is the only action needed for having converted values.
+     *
+     * <p>This method is for detecting when {@link org.apache.sis.storage.netcdf.GridResource#getSampleDimensions()}
+     * should return sample dimensions for already converted values. But to be consistent with {@code SampleDimension}
+     * contract, it requires fill/missing values to be replaced by NaN. This is done by {@link #replaceNaN(Object)}.</p>
+     *
+     * @return whether this variable contains values in unit of measurement, ignoring fill and missing values.
+     */
+    public final boolean hasRealValues() {
+        final int n = getDataType().number;
+        if (n == Numbers.FLOAT | n == Numbers.DOUBLE) {
+            double c = getAttributeAsNumber(CDM.SCALE_FACTOR);
+            if (Double.isNaN(c) || c == 1) {
+                c = getAttributeAsNumber(CDM.ADD_OFFSET);
+                return Double.isNaN(c) || c == 0;
+            }
+        }
+        return false;
+    }
+
+    /**
      * Returns the variable data type.
      *
      * @return the variable data type, or {@link DataType#UNKNOWN} if unknown.
@@ -508,20 +540,28 @@ public abstract class Variable extends NamedElement {
      *   <li>If bit 1 is set, then the value is a missing value.</li>
      * </ul>
      *
+     * Pad values are first in the map, followed by missing values.
      * The same value may have more than one role.
+     * The map returned by this method shall be stable, i.e. two invocations of this method shall return the
+     * same entries in the same order. This is necessary for mapping "no data" values to the same NaN values,
+     * since their {@linkplain MathFunctions#toNanFloat(int) ordinal values} are based on order.
      *
      * @return pad/missing values with bitmask of their role.
      */
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public final Map<Number,Integer> getNodataValues() {
-        final Map<Number,Integer> pads = new LinkedHashMap<>();
-        for (int i=0; i < NODATA_ATTRIBUTES.length; i++) {
-            for (final Object value : getAttributeValues(NODATA_ATTRIBUTES[i], true)) {
-                if (value instanceof Number) {
-                    pads.merge((Number) value, 1 << i, (v1, v2) -> v1 | v2);
+        if (nodataValues == null) {
+            final Map<Number,Integer> pads = new LinkedHashMap<>();
+            for (int i=0; i < NODATA_ATTRIBUTES.length; i++) {
+                for (final Object value : getAttributeValues(NODATA_ATTRIBUTES[i], true)) {
+                    if (value instanceof Number) {
+                        pads.merge((Number) value, 1 << i, (v1, v2) -> v1 | v2);
+                    }
                 }
             }
+            nodataValues = CollectionsExt.unmodifiableOrCopy(pads);
         }
-        return pads;
+        return nodataValues;
     }
 
     /**
@@ -563,7 +603,11 @@ public abstract class Variable extends NamedElement {
      *     (2,1,0) (2,1,1) (2,1,2) (2,1,3)
      * }
      *
-     * This method may cache the returned vector, at implementation choice.
+     * If {@link #hasRealValues()} returns {@code true}, then this method shall
+     * {@linkplain #replaceNaN(Object) replace fill values and missing values by NaN values}.
+     * This method should cache the returned vector since this method may be invoked often.
+     * Because of caching, this method should not be invoked for large data array.
+     * Callers shall not modify the returned vector.
      *
      * @return the data as an array of a Java primitive type.
      * @throws IOException if an error occurred while reading the data.
@@ -584,7 +628,8 @@ public abstract class Variable extends NamedElement {
      * </ul>
      *
      * If the variable has more than one dimension, then the data are packed in a one-dimensional vector
-     * in the same way than {@link #read()}.
+     * in the same way than {@link #read()}. If {@link #hasRealValues()} returns {@code true}, then this
+     * method shall {@linkplain #replaceNaN(Object) replace fill/missing values by NaN values}.
      *
      * @param  area         indices of cell values to read along each dimension, in "natural" order.
      * @param  subsampling  sub-sampling along each dimension. 1 means no sub-sampling.
@@ -612,6 +657,28 @@ public abstract class Variable extends NamedElement {
     }
 
     /**
+     * Maybe replace fill values and missing values by {@code NaN} values in the given array.
+     * This method does nothing if {@link #hasRealValues()} returns {@code false}.
+     * The NaN values used by this method must be consistent with the NaN values declared in
+     * the sample dimensions created by {@link org.apache.sis.storage.netcdf.GridResource}.
+     *
+     * @param  array  the array in which to replace fill and missing values.
+     */
+    protected final void replaceNaN(final Object array) {
+        if (hasRealValues()) {
+            int ordinal = 0;
+            for (final Number value : getNodataValues().keySet()) {
+                final float pad = MathFunctions.toNanFloat(ordinal++);      // Must be consistent with GridResource.createSampleDimension(…).
+                if (array instanceof float[]) {
+                    ArraysExt.replace((float[]) array, value.floatValue(), pad);
+                } else if (array instanceof double[]) {
+                    ArraysExt.replace((double[]) array, value.doubleValue(), pad);
+                }
+            }
+        }
+    }
+
+    /**
      * Sets the scale and offset coefficients in the given "grid to CRS" transform if possible.
      * Source and target dimensions given to this method are in "natural" order (reverse of netCDF order).
      * This method is invoked only for variables that represent a coordinate system axis.
diff --git a/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/impl/VariableInfo.java b/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/impl/VariableInfo.java
index 03203a6..6f8c96d 100644
--- a/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/impl/VariableInfo.java
+++ b/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/impl/VariableInfo.java
@@ -39,12 +39,12 @@ import org.apache.sis.internal.storage.io.ChannelDataInput;
 import org.apache.sis.internal.storage.io.HyperRectangleReader;
 import org.apache.sis.internal.storage.io.Region;
 import org.apache.sis.internal.util.StandardDateFormat;
-import org.apache.sis.internal.util.Numerics;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.netcdf.AttributeNames;
 import org.apache.sis.util.logging.WarningListeners;
 import org.apache.sis.util.CharSequences;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.Numbers;
 import org.apache.sis.measure.Units;
 import org.apache.sis.math.Vector;
@@ -698,6 +698,7 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
     /**
      * Reads all the data for this variable and returns them as an array of a Java primitive type.
      * Multi-dimensional variables are flattened as a one-dimensional array (wrapped in a vector).
+     * Fill values/missing values are replaced by NaN if {@link #hasRealValues()} is {@code true}.
      * The vector is cached and returned as-is in all future invocation of this method.
      *
      * @throws ArithmeticException if the size of the variable exceeds {@link Integer#MAX_VALUE}, or other overflow occurs.
@@ -720,6 +721,7 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
             final Region region = new Region(upper, lower, upper, subsampling);
             applyUnlimitedDimensionStride(region);
             Object array = reader.read(region);
+            replaceNaN(array);
             /*
              * If we can convert a double[] array to a float[] array, we should do that before
              * to invoke 'setValues(array)' - we can not rely on data.compress(tolerance). The
@@ -729,7 +731,7 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
              * this assumption, we need to convert to float[] before createDecimalVector(…).
              */
             if (array instanceof double[]) {
-                final float[] copy = Numerics.copyAsFloatsIfLossless((double[]) array);
+                final float[] copy = ArraysExt.copyAsFloatsIfLossless((double[]) array);
                 if (copy != null) array = copy;
             }
             setValues(array);
@@ -803,7 +805,9 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
         }
         final Region region = new Region(size, lower, upper, subsampling);
         applyUnlimitedDimensionStride(region);
-        return Vector.create(reader.read(region), dataType.isUnsigned);
+        final Object array = reader.read(region);
+        replaceNaN(array);
+        return Vector.create(array, dataType.isUnsigned);
     }
 
     /**
diff --git a/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/ucar/VariableWrapper.java b/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/ucar/VariableWrapper.java
index bed9c0c..0d2d818 100644
--- a/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/ucar/VariableWrapper.java
+++ b/storage/sis-netcdf/src/main/java/org/apache/sis/internal/netcdf/ucar/VariableWrapper.java
@@ -366,14 +366,14 @@ final class VariableWrapper extends Variable {
     /**
      * Reads all the data for this variable and returns them as an array of a Java primitive type.
      * Multi-dimensional variables are flattened as a one-dimensional array (wrapped in a vector).
-     * This method caches the returned vector.
+     * This method may replace fill/missing values by NaN values and caches the returned vector.
      */
     @Override
     @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public Vector read() throws IOException {
         if (values == null) {
             final Array array = variable.read();                // May be already cached by the UCAR library.
-            values = createDecimalVector(array.get1DJavaArray(array.getElementType()), variable.isUnsigned());
+            values = createDecimalVector(get1DJavaArray(array), variable.isUnsigned());
             values = SHARED_VECTORS.unique(values);
         }
         return values;
@@ -405,7 +405,18 @@ final class VariableWrapper extends Variable {
         } catch (InvalidRangeException e) {
             throw new DataStoreException(e);
         }
-        return Vector.create(array.get1DJavaArray(array.getElementType()), variable.isUnsigned());
+        return Vector.create(get1DJavaArray(array), variable.isUnsigned());
+    }
+
+    /**
+     * Returns the one-dimensional Java array for the given UCAR array, avoiding copying if possible.
+     * If {@link #hasRealValues()} returns {@code true}, then this method replaces fill and missing
+     * values by {@code NaN} values.
+     */
+    private Object get1DJavaArray(final Array array) {
+        final Object data = array.get1DJavaArray(array.getElementType());
+        replaceNaN(data);
+        return data;
     }
 
     /**
diff --git a/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/GridResource.java b/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/GridResource.java
index 8737d0e..88afe0b 100644
--- a/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/GridResource.java
+++ b/storage/sis-netcdf/src/main/java/org/apache/sis/storage/netcdf/GridResource.java
@@ -43,6 +43,7 @@ import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.DataStoreReferencingException;
 import org.apache.sis.storage.Resource;
+import org.apache.sis.math.MathFunctions;
 import org.apache.sis.measure.NumberRange;
 import org.apache.sis.util.Numbers;
 import org.apache.sis.util.resources.Errors;
@@ -301,27 +302,38 @@ final class GridResource extends AbstractGridResource implements ResourceOnFileS
         /*
          * Adds the "missing value" or "fill value" as qualitative categories.
          * If a value has both roles, use "missing value" for category name.
+         * If the values are already real values, then the "no data" values
+         * have been replaced by NaN values by Variable.replaceNaN(Object).
+         * The qualitative categories constructed below must be consistent
+         * with the NaN values used by 'replaceNaN'.
          */
         boolean setBackground = true;
+        int ordinal = data.hasRealValues() ? 0 : -1;
         final InternationalString[] names = new InternationalString[2];
         for (final Map.Entry<Number,Integer> entry : data.getNodataValues().entrySet()) {
-            final Number n = entry.getKey();
-            final double fp = n.doubleValue();
-            if (!builder.rangeCollides(fp, fp)) {
-                final int role = entry.getValue();          // Bit 0 set (value 1) = pad value, bit 1 set = missing value.
-                final int i = (role == 1) ? 1 : 0;          // i=1 if role is only pad value, i=0 otherwise.
-                InternationalString name = names[i];
-                if (name == null) {
-                    name = Vocabulary.formatInternational(i == 0 ? Vocabulary.Keys.MissingValue : Vocabulary.Keys.FillValue);
-                    names[i] = name;
-                }
-                if (setBackground & (role & 1) != 0) {
-                    setBackground = false;                  // Declare only one fill value.
-                    builder.setBackground(name, n);
-                } else {
-                    builder.addQualitative(name, n, n);
+            final Number n;
+            if (ordinal >= 0) {
+                n = MathFunctions.toNanFloat(ordinal++);        // Must be consistent with Variable.replaceNaN(Object).
+            } else {
+                n = entry.getKey();
+                final double fp = n.doubleValue();
+                if (builder.rangeCollides(fp, fp)) {
+                    continue;
                 }
             }
+            final int role = entry.getValue();          // Bit 0 set (value 1) = pad value, bit 1 set = missing value.
+            final int i = (role == 1) ? 1 : 0;          // i=1 if role is only pad value, i=0 otherwise.
+            InternationalString name = names[i];
+            if (name == null) {
+                name = Vocabulary.formatInternational(i == 0 ? Vocabulary.Keys.MissingValue : Vocabulary.Keys.FillValue);
+                names[i] = name;
+            }
+            if (setBackground & (role & 1) != 0) {
+                setBackground = false;                  // Declare only one fill value.
+                builder.setBackground(name, n);
+            } else {
+                builder.addQualitative(name, n, n);
+            }
         }
         return builder.build();
     }