sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] branch geoapi-4.0 updated: More complete port of SampleDimension built from a list of Categories.
Date Mon, 03 Dec 2018 23:43:16 GMT
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 04cf24e  More complete port of SampleDimension built from a list of Categories.
04cf24e is described below

commit 04cf24e214bedeced5852a1fe32f7017743388a5
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Tue Dec 4 00:42:11 2018 +0100

    More complete port of SampleDimension built from a list of Categories.
---
 .../java/org/apache/sis/coverage/Category.java     | 302 +++++++--
 .../java/org/apache/sis/coverage/CategoryList.java | 673 ++++++++++-----------
 .../org/apache/sis/coverage/GeophysicsRange.java   |  79 +++
 .../org/apache/sis/coverage/SampleDimension.java   | 451 ++++++++++++--
 .../org/apache/sis/internal/raster/Resources.java  |  15 +
 .../sis/internal/raster/Resources.properties       |   3 +
 .../sis/internal/raster/Resources_fr.properties    |   3 +
 .../org/apache/sis/coverage/CategoryListTest.java  | 118 ++++
 .../org/apache/sis/test/suite/RasterTestSuite.java |   3 +-
 .../operation/transform/ConstantTransform1D.java   |   4 +
 .../operation/transform/TransferFunction.java      |   9 +
 .../java/org/apache/sis/math/MathFunctions.java    |  26 +-
 .../java/org/apache/sis/measure/NumberRange.java   |  77 +--
 .../src/main/java/org/apache/sis/util/Numbers.java |   9 +-
 ide-project/NetBeans/nbproject/genfiles.properties |   2 +-
 ide-project/NetBeans/nbproject/project.xml         |   1 +
 16 files changed, 1239 insertions(+), 536 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 8da20d4..b9b6c3f 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
@@ -16,25 +16,30 @@
  */
 package org.apache.sis.coverage;
 
+import java.util.Set;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.Comparator;
 import java.io.Serializable;
+import javax.measure.Unit;
 import org.opengis.util.InternationalString;
 import org.opengis.referencing.operation.MathTransform1D;
+import org.opengis.referencing.operation.TransformException;
+import org.apache.sis.measure.MeasurementRange;
 import org.apache.sis.measure.NumberRange;
 import org.apache.sis.math.MathFunctions;
+import org.apache.sis.referencing.operation.transform.MathTransforms;
+import org.apache.sis.internal.raster.Resources;
 import org.apache.sis.util.ArgumentChecks;
-import org.apache.sis.util.resources.Errors;
-import org.apache.sis.util.resources.Vocabulary;
+import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.iso.Types;
 
 
 /**
  * A category delimited by a range of sample values. A category may be either <em>qualitative</em> or <em>quantitative</em>.
- * For example, a classified image may have a qualitative category defining sample value {@code 0} as water.
- * An other qualitative category may defines sample value {@code 1} as forest, <i>etc</i>.
- * An other image may define elevation data as sample values in the range [0…100].
+ * For example an image may have a qualitative category defining sample value {@code 0} as water,
+ * another qualitative category defining sample value {@code 1} as forest, <i>etc</i>.
+ * Another image may define elevation data as sample values in the range [0…100].
  * The later is a <em>quantitative</em> category because sample values are related to measurements in the real world.
  * For example, elevation data may be related to an altitude in metres through the following linear relation:
  *
@@ -73,7 +78,7 @@ final class Category implements Serializable {
 
     /**
      * Compares two {@code double} values. This method is similar to {@link Double#compare(double,double)}
-     * except that it also orders NaN values from raw bit patterns. Remind that NaN values are sorted last.
+     * except that it also orders NaN values from raw bit patterns. Reminder: NaN values are sorted last.
      */
     static int compare(final double v1, final double v2) {
         if (Double.isNaN(v1) && Double.isNaN(v2)) {
@@ -86,71 +91,209 @@ final class Category implements Serializable {
     }
 
     /**
-     * A default category for "no data" values. This default qualitative category uses sample value 0,
-     * which is mapped to geophysics value {@link Float#NaN}. The name is "no data".
-     */
-    static final Category NODATA = new Category(Vocabulary.formatInternational(Vocabulary.Keys.Nodata),
-                                                NumberRange.create(0, true, 0, true), null);
-
-    /**
      * The category name.
+     *
+     * @see #getName()
      */
-    private final InternationalString name;
+    final InternationalString name;
 
     /**
-     * The minimal and maximal sample value (inclusive).
-     * This category is made of all values in the range {@code minimum} to {@code maximum} inclusive.
-     * This value may be one of the multiple possible {@code NaN} values if this category stands for
-     * "no data" after all values have been converted to geophysics values.
+     * The minimal and maximal sample value (inclusive). If {@link #range} is non-null, then
+     * those fields are equal to the following values, extracted for performance reasons:
+     *
+     * <ul>
+     *   <li>{@code minimum == range.getMinDouble(true)}</li>
+     *   <li>{@code maximum == range.getMaxDouble(true)}</li>
+     * </ul>
+     *
+     * If {@link #range} is null, then those values shall be one of the multiple possible {@code NaN} values.
+     * This means that this category stands for "no data" after all values have been converted to geophysics values.
      */
     final double minimum, maximum;
 
     /**
-     * The [{@linkplain #minimum} … {@linkplain #maximum}] range of values.
-     * May be computed only when first requested, or may be user-supplied (which is why it must be serialized).
+     * The [{@linkplain #minimum} … {@linkplain #maximum}] range of values, or {@code null} if that range would
+     * contain {@link Float#NaN} bounds. This is partially redundant with the minimum and maximum fields, except
+     * for the following differences:
+     *
+     * <ul>
+     *   <li>This field is {@code null} if the minimum and maximum values are NaN (qualitative "geophysics" category).</li>
+     *   <li>The value type may be different than {@link Double} (typically {@link Integer}).</li>
+     *   <li>The bounds may be exclusive instead than inclusive.</li>
+     *   <li>The range may be an instance of {@link MeasurementRange} if the {@link #transferFunction}
+     *       is identity and the units of measurement are known.</li>
+     * </ul>
+     *
+     * The range is null if this category is a "qualitative geophysics" category.
+     * 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>
+     *   <li>The {@linkplain #getTransferFunction() transfer function} of this category
+     *       is absent (because this category is qualitative).</li>
+     * </ul>
+     *
+     * @see #getSampleRange()
      */
-    private final NumberRange<?> range;
+    final NumberRange<?> range;
 
     /**
-     * The conversion from sample values to geophysics values, or {@code null} if this category is qualitative.
+     * The conversion from sample values to geophysics values (or conversely), never {@code null} even for qualitative
+     * categories. In the case of qualitative categories, this transfer function shall map to {@code NaN} values.
+     * In the case of sample values that are already geophysics, this transfer function shall be the identity function.
      */
     final MathTransform1D transferFunction;
 
     /**
-     * Constructs a category with the specified transfer function.
+     * The category that describes sample values after {@link #transferFunction} has been applied.
+     * Never null, but may be {@code this} if the transfer function is the identity function.
+     */
+    final Category converted;
+
+    /**
+     * Constructs a qualitative of quantitative category.
      *
-     * @param  name              the category name.
-     * @param  range             the minimum and maximum sample values.
-     * @param  transferFunction  the conversion from sample values to geophysics values, or {@code null}.
+     * @param  name       the category name (mandatory).
+     * @param  samples    the minimum and maximum sample values (mandatory).
+     * @param  toUnits    the conversion from sample values to geophysics values,
+     *                    or {@code null} for constructing a qualitative category.
+     * @param  units      the units of measurement, or {@code null} if not applicable.
+     *                    This is the target units after conversion by {@code toUnits}.
+     * @param  padValues  an initially empty set to be filled by this constructor for avoiding pad value collisions.
+     *                    The same set shall be given to all {@code Category} created for the same sample dimension.
      */
-    Category(final CharSequence name, final NumberRange<?> range, final MathTransform1D transferFunction) {
-        ArgumentChecks.ensureNonNull("name",  name);
-        ArgumentChecks.ensureNonNull("range", range);
+    Category(final CharSequence name, final NumberRange<?> samples, final MathTransform1D toUnits, final Unit<?> units,
+             final Set<Integer> padValues)
+    {
+        ArgumentChecks.ensureNonEmpty("name", name);
+        ArgumentChecks.ensureNonNull("samples", samples);
         this.name    = Types.toInternationalString(name);
-        this.range   = range;
-        this.minimum = range.getMinDouble(true);
-        this.maximum = range.getMaxDouble(true);
-        this.transferFunction = transferFunction;
+        this.range   = samples;
+        this.minimum = samples.getMinDouble(true);
+        this.maximum = samples.getMaxDouble(true);
         /*
-         * If we are constructing a qualitative category for a single NaN value,
-         * accepts it as a valid one.
+         * Following arguments check uses '!' in comparison in order to reject NaN values.
          */
-        if (transferFunction == null && Double.isNaN(minimum) &&
-                Double.doubleToRawLongBits(minimum) == Double.doubleToRawLongBits(maximum))
-        {
-            return;
+        if (!(minimum <= maximum) || (minimum == Double.NEGATIVE_INFINITY) || (maximum == Double.POSITIVE_INFINITY)) {
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalCategoryRange_2, name, samples));
         }
         /*
-         * Check the arguments. Use '!' in comparison in order to reject NaN values,
-         * except for the legal case catched by the "if" block just above.
+         * Creates the transform doing the inverse conversion (from geophysics 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.
          */
-        if (!(minimum <= maximum) || (minimum == Double.NEGATIVE_INFINITY) || (maximum == Double.POSITIVE_INFINITY)) {
-            throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalRange_2,
-                                               range.getMinValue(), range.getMaxValue()));
+        try {
+            final MathTransform1D toSamples;
+            if (toUnits != null) {
+                transferFunction = toUnits;
+                if (toUnits.isIdentity()) {
+                    converted = this;
+                    return;
+                }
+                toSamples = toUnits.inverse();
+            } else {
+                /*
+                 * For qualitative category, we need an ordinal in the [MIN_NAN_ORDINAL … MAX_NAN_ORDINAL] range.
+                 * This range is quite large (a few million of values) so using the sample directly usually work.
+                 * If it does not work, we will use an arbitrary value in that range.
+                 */
+                int ordinal = Math.round((float) minimum);
+                if (ordinal > MathFunctions.MAX_NAN_ORDINAL) {
+                    ordinal = (MathFunctions.MAX_NAN_ORDINAL + 1) / 2;
+                } else if (ordinal < MathFunctions.MIN_NAN_ORDINAL) {
+                    ordinal = MathFunctions.MIN_NAN_ORDINAL / 2;
+                }
+search:         if (!padValues.add(ordinal)) {
+                    /*
+                     * Following algorithms are inefficient, but those loops should be rarely needed.
+                     * They are executed only if many qualitative sample values are outside the range
+                     * of ordinal NaN values. The range allows a few million of values.
+                     */
+                    if (ordinal >= 0) {
+                        do if (padValues.add(++ordinal)) break search;
+                        while (ordinal < MathFunctions.MAX_NAN_ORDINAL);
+                    } else {
+                        do if (padValues.add(--ordinal)) break search;
+                        while (ordinal > MathFunctions.MIN_NAN_ORDINAL);
+                    }
+                    throw new IllegalStateException(Resources.format(Resources.Keys.TooManyQualitatives));
+                }
+                /*
+                 * 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.
+                 */
+                transferFunction = (MathTransform1D) MathTransforms.linear(0, MathFunctions.toNanFloat(ordinal));
+                final double value = (minimum > 0) ? minimum : (maximum <= 0) ? maximum : 0d;
+                toSamples = (MathTransform1D) MathTransforms.linear(0, value);
+            }
+            converted = new Category(this, toSamples, toUnits != null, units);
+        } catch (TransformException e) {
+            throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalTransferFunction_1, name), e);
         }
     }
 
     /**
+     * Creates a category storing the inverse of the "sample to geophysics" transfer function. The {@link #transferFunction}
+     * of this category will convert geophysics value in specified {@code units} to the sample (packed) value.
+     *
+     * @param  original        the category storing the conversion from sample to geophysics value.
+     * @param  toSamples       the "geophysics to sample values" conversion, as the inverse of {@code original.transferFunction}.
+     *                         For qualitative category, this function is a constant mapping NaN to the original sample value.
+     * @param  isQuantitative  {@code true} if we are construction a quantitative category, or {@code false} for qualitative.
+     * @param  units           the units of measurement, or {@code null} if not applicable.
+     *                         This is the source units before conversion by {@code toSamples}.
+     */
+    private Category(final Category original, final MathTransform1D toSamples, final boolean isQuantitative, final Unit<?> units)
+            throws TransformException
+    {
+        converted        = original;
+        name             = original.name;
+        transferFunction = Objects.requireNonNull(toSamples);
+        /*
+         * Compute 'minimum' and 'maximum' (which must be real numbers) using the conversion from sample to
+         * geophysics values. To be strict, we should use some numerical algorithm for finding a function's
+         * minimum and maximum. For linear and logarithmic functions, minimum and maximum are always at the
+         * bounding input values, so we are using a very simple algorithm for now.
+         *
+         * Note: we could move this code in GeophysicsRange constructor if RFE #4093999
+         * ("Relax constraint on placement of this()/super() call in constructors") was fixed.
+         */
+        final NumberRange<?> r = original.range;
+        boolean minIncluded = r.isMinIncluded();
+        boolean maxIncluded = r.isMaxIncluded();
+        final double[] extremums = {
+                r.getMinDouble(),
+                r.getMaxDouble(),
+                r.getMinDouble(!minIncluded),
+                r.getMaxDouble(!maxIncluded)};
+        original.transferFunction.transform(extremums, 0, extremums, 0, extremums.length);
+        if (extremums[minIncluded ? 2 : 0] > extremums[maxIncluded ? 3 : 1]) {              // Compare exclusive min/max.
+            ArraysExt.swap(extremums, 0, 1);                                                // Swap minimum and maximum.
+            ArraysExt.swap(extremums, 2, 3);
+            final boolean tmp = minIncluded;
+            minIncluded = maxIncluded;
+            maxIncluded = tmp;
+        }
+        minimum = extremums[minIncluded ? 0 : 2];                                           // Store inclusive values.
+        maximum = extremums[maxIncluded ? 1 : 3];
+        if (isQuantitative) {
+            range = new GeophysicsRange(extremums, minIncluded, maxIncluded, units);
+        } else {
+            range = null;
+        }
+    }
+
+    /**
+     * Returns {@code false} if this instance has been created by above private constructor for geophysics values.
+     * This method is for assertions only. We use the range type as a signature for category representing result
+     * of conversion by the transfer function.
+     */
+    final boolean isPublic() {
+        return (range != null) && !(range instanceof GeophysicsRange);
+    }
+
+    /**
      * Returns the category name.
      *
      * @return the category name.
@@ -160,29 +303,69 @@ final class Category implements Serializable {
     }
 
     /**
-     * Returns the range of values occurring in this category.
-     * The range are sample values than can be converted into geophysics values using the
-     * {@linkplain #getTransferFunction() transfer function}. If that function is identity,
-     * then the sample values are already geophysics values and are in the units of the
-     * {@link SampleDimension} containing this category.
+     * Returns {@code true} if this category is quantitative. A quantitative category has a
+     * {@linkplain #getTransferFunction() transfer function} mapping sample values to values
+     * in some units of measurement. By contrast, a qualitative category maps sample values
+     * to a label, for example “2 = forest”. That later mapping can not be represented by a
+     * transfer function.
+     *
+     * @return {@code true} if this category is quantitative, or
+     *         {@code false} if this category is qualitative.
+     */
+    public final boolean isQuantitative() {
+        /*
+         * 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 "geophysics category", then we would need to test for
+         * 'range' directly instead of 'converted.range'.
+         */
+        assert isPublic() : this;
+        return converted.range != null;
+    }
+
+    /**
+     * Returns the range of values occurring in this category. The range are sample values than can be
+     * converted into geophysics values using the {@linkplain #getTransferFunction() transfer function}.
+     * If that function is {@linkplain MathTransform1D#isIdentity() identity}, then the values are already
+     * geophysics values and the range may be an instance of {@link MeasurementRange} (i.e. a number range
+     * with units of measurement).
      *
-     * @return the range of sample values.
+     * @return the range of values in this category.
      *
+     * @see SampleDimension#getSampleRange()
      * @see NumberRange#getMinValue()
      * @see NumberRange#getMaxValue()
-     * @see SampleDimension#getRange()
      */
-    public NumberRange<?> getRange() {
+    public NumberRange<?> getSampleRange() {
+        // Same assumption than in 'isQuantitative()'.
+        assert isPublic() : this;
         return range;
     }
 
     /**
+     * Returns the range of values after conversions by the transfer function.
+     * This range is absent if there is no transfer function, i.e. this category is qualitative.
+     *
+     * @return the range of values after conversion by the transfer function.
+     *
+     * @see SampleDimension#getMeasurementRange()
+     */
+    public Optional<NumberRange<?>> getMeasurementRange() {
+        // Same assumption than in 'isQuantitative()'.
+        assert isPublic() : this;
+        return Optional.ofNullable(converted.range);
+    }
+
+    /**
      * Returns an object to format for representing the range of values for display purpose only.
-     * It may be either the {@link NumberRange} or a {@link String} with a text like "NaN #0".
+     * It may be either the {@link NumberRange}, a single {@link Number} or a {@link String} with
+     * a text like "NaN #0".
      */
     final Object getRangeLabel() {
         if (Double.isNaN(minimum)) {
             return "NaN #" + MathFunctions.toNanOrdinal((float) minimum);
+        } else if (minimum == maximum) {
+            return range.getMinValue();
         } else {
             return range;
         }
@@ -190,14 +373,15 @@ final class Category implements Serializable {
 
     /**
      * Returns the <cite>transfer function</cite> from sample values to geophysics values.
-     * The function is absent if this category is not a quantitative category.
+     * The function is absent if this category is not a {@linkplain #isQuantitative() quantitative} category.
      *
      * @return the <cite>transfer function</cite> from sample values to geophysics values.
      *
      * @see SampleDimension#getTransferFunction()
      */
     public Optional<MathTransform1D> getTransferFunction() {
-        return Optional.ofNullable(transferFunction);
+        assert isPublic() : this;
+        return isQuantitative() ? Optional.of(transferFunction) : Optional.empty();
     }
 
     /**
@@ -223,10 +407,10 @@ final class Category implements Serializable {
         }
         if (object instanceof Category) {
             final Category that = (Category) object;
-            return name.equals(that.name) && range.equals(that.range) &&
+            return name.equals(that.name) && Objects.equals(range, that.range) &&
                    Double.doubleToRawLongBits(minimum) == Double.doubleToRawLongBits(that.minimum) &&
                    Double.doubleToRawLongBits(maximum) == Double.doubleToRawLongBits(that.maximum) &&
-                   Objects.equals(transferFunction, that.transferFunction);
+                   transferFunction.equals(that.transferFunction);
         }
         return false;
     }
@@ -239,7 +423,7 @@ final class Category implements Serializable {
      */
     @Override
     public String toString() {
-        return new StringBuilder(getClass().getSimpleName()).append("(“").append(name)
-                .append("”:").append(getRangeLabel()).append(')').toString();
+        return new StringBuilder(getClass().getSimpleName()).append("[“").append(name)
+                .append("”: ").append(getRangeLabel()).append(']').toString();
     }
 }
diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/CategoryList.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/CategoryList.java
index 93fc709..ca6bd95 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/coverage/CategoryList.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/CategoryList.java
@@ -18,16 +18,13 @@ package org.apache.sis.coverage;
 
 import java.util.Arrays;
 import java.util.AbstractList;
-import java.util.Comparator;
 import java.io.Serializable;
 import java.io.IOException;
 import java.io.ObjectInputStream;
-import javax.measure.Unit;
 import org.opengis.geometry.DirectPosition;
 import org.opengis.referencing.operation.Matrix;
 import org.opengis.referencing.operation.MathTransform1D;
 import org.opengis.referencing.operation.TransformException;
-import org.opengis.referencing.operation.NoninvertibleTransformException;
 import org.apache.sis.referencing.operation.matrix.Matrix1;
 import org.apache.sis.io.wkt.UnformattableObjectException;
 import org.apache.sis.geometry.GeneralDirectPosition;
@@ -41,8 +38,8 @@ import static java.lang.Double.doubleToRawLongBits;
 
 /**
  * An immutable list of categories. Categories are sorted by their sample values.
- * Overlapping ranges of sample values are not allowed. A {@code CategoryList} can contains a mix of
- * qualitative and quantitative categories. The {@link #getCategory(double)} method is responsible
+ * Overlapping ranges of sample values are not allowed. A {@code CategoryList} can contains a mix
+ * of qualitative and quantitative categories.  The {@link #search(double)} method is responsible
  * for finding the right category for an arbitrary sample value.
  *
  * <p>Instances of {@link CategoryList} are immutable and thread-safe.</p>
@@ -59,19 +56,18 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     private static final long serialVersionUID = 2647846361059903365L;
 
     /**
-     * The policy when {@link #getCategory(double)} does not find a match for a sample value.
-     * {@code true} means that it should search for the nearest category, while {@code false}
-     * means that it should returns {@code null}.
+     * The result of converting sample values to geophysics values, never {@code null}.
      */
-    private static final boolean SEARCH_NEAREST = false;
+    final CategoryList converted;
 
     /**
-     * The range of values in this category list. This is the union of the range of values of every categories,
-     * excluding {@code NaN} values. This field will be computed only when first requested.
+     * The union of the ranges of every categories, excluding {@code NaN} values.
+     * May be {@code null} if this list has no non-{@code NaN} category.
      *
-     * @see #getRange()
+     * <p>A {@link NumberRange} object gives more information than a (minimum, maximum) tuple since
+     * it contains also the type (integer, float, etc.) and inclusion/exclusion information.</p>
      */
-    private transient volatile NumberRange<?> range;
+    final NumberRange<?> range;
 
     /**
      * List of {@link Category#minimum} values for each category in {@link #categories}.
@@ -81,9 +77,8 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     private final double[] minimums;
 
     /**
-     * The list of categories to use for decoding samples. This list must be sorted in increasing order
-     * of {@link Category#minimum}. This {@code CategoryList} object may be used as a {@link Comparator}
-     * for that purpose. Qualitative categories (with NaN values) are last.
+     * The list of categories to use for decoding samples. This list must be sorted in increasing
+     * order of {@link Category#minimum}. Qualitative categories with NaN values are last.
      */
     private final Category[] categories;
 
@@ -94,11 +89,15 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     private final Category main;
 
     /**
-     * The "no data" category (never {@code null}). The "no data" category is a category mapping the {@link Double#NaN} value.
-     * If none has been found, a default "no data" category is used. This category is used to transform geophysics values to
-     * sample values into rasters when no suitable category has been found for a given geophysics value.
+     * The category to use if {@link #search(double)} is invoked with a sample value greater than all ranges in this list.
+     * This is usually a reference to the last category to have a range of real values. A {@code null} value means that no
+     * extrapolation should be used. By extension, a {@code null} value also means that {@link #search(double)} should not
+     * try to find any fallback at all if the requested sample value does not fall in a category range.
+     *
+     * <p>There is no explicit extrapolation field for values less than all ranges in this list because the extrapolation
+     * to use in such case is {@code categories[0]}.</p>
      */
-    private final Category nodata;
+    private final Category extrapolation;
 
     /**
      * The last used category. We assume that this category is the most likely to be requested in the next
@@ -111,63 +110,84 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     private transient Category last;
 
     /**
-     * {@code true} if there is gaps between categories, or {@code false} otherwise. A gap is found if for
-     * example the range of value is [-9999 … -9999] for the first category and [0 … 1000] for the second one.
-     */
-    private final boolean hasGaps;
-
-
-    /**
      * Constructs a category list using the specified array of categories.
      *
      * @param  categories  the list of categories. May be empty, but can not be null. This array is not cloned.
-     * @param  units       the geophysics unit, or {@code null} if none.
+     * @param  inverse     if we are creating the list of categories after conversion from sample to geophysics,
+     *                     the original list before conversion. Otherwise {@code null}.
      * @throws IllegalArgumentException if two or more categories have overlapping sample value range.
      */
-    CategoryList(final Category[] categories, final Unit<?> units) {
+    CategoryList(final Category[] categories, CategoryList inverse) {
         this.categories = categories;
         Arrays.sort(categories, Category.COMPARATOR);
         /*
          * Constructs the array of Category.minimum values. During the loop, we make sure there is no overlapping ranges.
-         * We also take the "no data" category mapped to the sample value 0 if it exists, or the first "no data" category
-         * otherwise.
+         * We also take the "main" category as the category with the widest range of values.
          */
-        double   range   = 0;
-        Category main    = null;
-        Category nodata  = null;
-        boolean  hasGaps = false;
+        double widest = 0;
+        Category main = null;
+        NumberRange<?> range = null;
         minimums = new double[categories.length];
         for (int i=0; i < categories.length; i++) {
             final Category category = categories[i];
+            final NumberRange<?> extent = category.range;
+            if (extent != null) {
+                range = (range != null) ? range.unionAny(extent) : extent;
+            }
             final double minimum = category.minimum;
             minimums[i] = minimum;
-            if (category.transferFunction != null) {
-                final double r = category.maximum - category.minimum;
-                if (r >= range) {
-                    range = r;
-                    main  = category;
+            if (category.isQuantitative()) {
+                final double span = category.maximum - minimum;
+                if (span >= widest) {
+                    widest = span;
+                    main = category;
                 }
-            } else if (nodata == null || minimum == 0) {
-                nodata = category;
             }
             if (i != 0) {
                 assert !(minimum <= minimums[i-1]) : minimum;                   // Use '!' to accept NaN.
                 final Category previous = categories[i-1];
-                if (!hasGaps && !isNaN(minimum) && minimum != previous.getRange().getMaxDouble(false)) {
-                    hasGaps = true;
-                }
                 if (Category.compare(minimum, previous.maximum) <= 0) {
                     throw new IllegalArgumentException(Resources.format(Resources.Keys.CategoryRangeOverlap_4, new Object[] {
-                                previous.getName(), previous.getRangeLabel(),
-                                category.getName(), category.getRangeLabel()}));
+                                previous.name, previous.getRangeLabel(),
+                                category.name, category.getRangeLabel()}));
                 }
             }
         }
-        this.main    = main;
-        this.last    = (main != null || categories.length == 0) ? main : categories[0];
-        this.nodata  = (nodata != null) ? nodata : Category.NODATA;
-        this.hasGaps = hasGaps;
-        assert isSorted(categories);
+        this.main  = main;
+        this.last  = (main != null || categories.length == 0) ? main : categories[0];
+        this.range = range;
+        /*
+         * At this point we have two branches:
+         *
+         *   - If we are creating the list of "sample to geophysics" conversions, then we do not allow extrapolations
+         *     outside the ranges or categories given to this constructor (extrapolation = null). In addition we need
+         *     to create the list of categories after conversion to geophysics value.
+         *
+         *   - If we are creating the list of "geophysics to sample" conversions, then we need to search for the
+         *     extrapolation to use when 'search(double)' is invoked with a value greater than all ranges in this
+         *     list. This is the last category to have a range of real numbers.
+         */
+        Category extrapolation = null;
+        if (inverse == null) {
+            boolean hasConversion = false;
+            final Category[] convertedCategories = new Category[categories.length];
+            for (int i=0; i < convertedCategories.length; i++) {
+                final Category category = categories[i];
+                hasConversion |= (category != category.converted);
+                convertedCategories[i] = category.converted;
+            }
+            inverse = hasConversion ? new CategoryList(convertedCategories, this) : this;
+        } else {
+            for (int i=categories.length; --i >= 0;) {
+                final Category category = categories[i];
+                if (!isNaN(category.maximum)) {
+                    extrapolation = category;
+                    break;
+                }
+            }
+        }
+        this.extrapolation = extrapolation;
+        converted = inverse;
     }
 
     /**
@@ -183,27 +203,34 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
-     * Returns {@code true} if the specified categories are sorted. This method
-     * ignores {@code NaN} values. This method is used for assertions only.
+     * Returns {@code false} if this instance contains private categories.
+     * This method is for assertions only.
      */
-    private static boolean isSorted(final Category[] categories) {
-        for (int i=1; i<categories.length; i++) {
-            Category c;
-            assert !((c=categories[i  ]).minimum > c.maximum) : c;
-            assert !((c=categories[i-1]).minimum > c.maximum) : c;
-            if (Category.compare(categories[i-1].maximum, categories[i].minimum) > 0) {
-                return false;
-            }
+    private boolean isPublic() {
+        for (final Category c : categories) {
+            if (!c.isPublic()) return false;
         }
         return true;
     }
 
     /**
+     * Returns {@code true} if this list contains at least one quantitative category.
+     * We use the converted range has a criterion, since it shall be null if the result
+     * of all conversions is NaN.
+     *
+     * @see Category#isQuantitative()
+     */
+    final boolean hasQuantitative() {
+        assert isPublic();
+        return converted.range != null;
+    }
+
+    /**
      * Performs a bi-linear search of the specified value. This method is similar to
      * {@link Arrays#binarySearch(double[],double)} except that it can differentiate
-     * NaN values.
+     * the various NaN values.
      */
-    private static int binarySearch(final double[] array, final double key) {
+    static int binarySearch(final double[] array, final double key) {
         int low  = 0;
         int high = array.length - 1;
         final boolean keyIsNaN = isNaN(key);
@@ -226,12 +253,16 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
             final boolean midIsNaN = isNaN(midVal);
             final boolean adjustLow;
             if (keyIsNaN) {
-                // If (mid,key)==(!NaN, NaN): mid is lower.
-                // If two NaN arguments, compare NaN bits.
+                /*
+                 * If (mid,key)==(!NaN, NaN): mid is lower.
+                 * If two NaN arguments, compare NaN bits.
+                 */
                 adjustLow = (!midIsNaN || midRawBits < keyRawBits);
             } else {
-                // If (mid,key)==(NaN, !NaN): mid is greater.
-                // Otherwise, case for (-0.0, 0.0) and (0.0, -0.0).
+                /*
+                 * If (mid,key)==(NaN, !NaN): mid is greater.
+                 * Otherwise, case for (-0.0, 0.0) and (0.0, -0.0).
+                 */
                 adjustLow = (!midIsNaN && midRawBits < keyRawBits);
             }
             if (adjustLow) low = mid + 1;
@@ -247,7 +278,7 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
      * @param  sample  the value.
      * @return the category of the supplied value, or {@code null}.
      */
-    public final Category getCategory(final double sample) {
+    private Category search(final double sample) {
         /*
          * Search which category contains the given value.
          * Note: NaN values are at the end of 'minimums' array, so:
@@ -262,17 +293,17 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
             return categories[i];
         }
         /*
-         * If we reach this point and the value is NaN, then it is not one of the
-         * registered NaN values. Consequently we can not map a category to this value.
+         * If we reach this point and the value is NaN, then it is not one of the NaN values known
+         * to CategoryList constructor. Consequently we can not map a category to this value.
          */
         if (isNaN(sample)) {
             return null;
         }
         assert i == Arrays.binarySearch(minimums, sample) : i;
         /*
-         * 'binarySearch' found the index of "insertion point" (~i). This means that
-         * 'sample' is lower than 'Category.minimum' at this index. Consequently, if
-         * this value fits in a category's range, it fits in the previous category (~i-1).
+         * 'binarySearch' found the index of "insertion point" (~i). This means that 'sample' is lower
+         * than 'Category.minimum' at that index. Consequently if the sample value is inside the range
+         * of some category, it can only be the previous category (~i-1).
          */
         i = ~i - 1;
         if (i >= 0) {
@@ -281,27 +312,25 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
             if (sample <= category.maximum) {
                 return category;
             }
-            if (SEARCH_NEAREST) {
+            /*
+             * At this point we determined that 'sample' is between two categories. If extrapolations
+             * are allowed, returns the category for the range closest to the sample value.
+             *
+             * Assertion: 'next.minimum' shall not be smaller than 'sample', otherwise it should have
+             * been found by 'binarySearch'.
+             */
+            if (extrapolation != null) {
                 if (++i < categories.length) {
-                    final Category upper = categories[i];
-                    /*
-                     * ASSERT: if 'upper.minimum' was smaller than 'value', it should has been
-                     *         found by 'binarySearch'. We use '!' in order to accept NaN values.
-                     */
-                    assert !(upper.minimum <= sample) : sample;
-                    return (upper.minimum-sample < sample-category.maximum) ? upper : category;
-                }
-                while (--i >= 0) {
-                    final Category previous = categories[i];
-                    if (!isNaN(previous.minimum)) {
-                        return previous;
-                    }
+                    final Category next = categories[i];
+                    assert !(next.minimum <= sample) : sample;         // '!' for accepting NaN.
+                    return (next.minimum - sample < sample - category.maximum) ? next : category;
                 }
+                return extrapolation;
             }
-        } else if (SEARCH_NEAREST) {
+        } else if (extrapolation != null) {
             /*
              * If the value is smaller than the smallest Category.minimum, returns
-             * the first category (except if there is only NaN categories).
+             * the first category (except if there is only qualitative categories).
              */
             if (categories.length != 0) {
                 final Category category = categories[0];
@@ -314,303 +343,88 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
-     * Returns the range of values in this category list. This is the union of the ranges of every categories,
-     * excluding {@code NaN} values. A {@link NumberRange} object give more information than a (minimum, maximum)
-     * tuple since it contains also the type (integer, float, etc.) and inclusion/exclusion information.
-     *
-     * @return The range of values. May be {@code null} if this category list has no quantitative category.
-     *
-     * @see Category#getRange()
-     */
-    public final NumberRange<?> getRange() {
-        NumberRange<?> range = this.range;
-        if (range == null) {
-            for (final Category category : categories) {
-                final NumberRange<?> extent = category.getRange();
-                if (!isNaN(extent.getMinDouble()) && !isNaN(extent.getMaxDouble())) {
-                    if (range != null) {
-                        range = range.unionAny(extent);
-                    } else {
-                        range = extent;
-                    }
-                }
-            }
-            this.range = range;
-        }
-        return range;
-    }
-
-
-
-
-    //////////////////////////////////////////////////////////////////////////////////////////
-    ////////                                                                          ////////
-    ////////       I M P L E M E N T A T I O N   O F   List   I N T E R F A C E       ////////
-    ////////                                                                          ////////
-    //////////////////////////////////////////////////////////////////////////////////////////
-
-    /**
-     * Returns the number of categories in this list.
-     */
-    @Override
-    public final int size() {
-        return categories.length;
-    }
-
-    /**
-     * Returns the element at the specified position in this list.
-     */
-    @Override
-    public final Category get(final int i) {
-        return categories[i];
-    }
-
-    /**
-     * Returns all categories in this {@code CategoryList}.
-     */
-    @Override
-    public final Category[] toArray() {
-        Category[] array = categories;
-        if (array.length != 0) {
-            array = array.clone();
-        }
-        return array;
-    }
-
-    /**
-     * Compares the specified object with this category list for equality.
-     * If the two objects are instances of {@link CategoryList}, then the
-     * test is a stricter than the default {@link AbstractList#equals(Object)}.
-     */
-    @Override
-    public boolean equals(final Object object) {
-        if (object instanceof CategoryList) {
-            final CategoryList that = (CategoryList) object;
-            if (Arrays.equals(categories, that.categories)) {
-                assert Arrays.equals(minimums, that.minimums);
-            } else {
-                return false;
-            }
-        }
-        return super.equals(object);
-    }
-
-
-
-
-    ///////////////////////////////////////////////////////////////////////////////////////////////
-    ////////                                                                               ////////
-    ////////    I M P L E M E N T A T I O N   O F   MathTransform1D   I N T E R F A C E    ////////
-    ////////                                                                               ////////
-    ///////////////////////////////////////////////////////////////////////////////////////////////
-
-    /**
-     * Gets the dimension of input points, which is 1.
-     */
-    @Override
-    public final int getSourceDimensions() {
-        return 1;
-    }
-
-    /**
-     * Gets the dimension of output points, which is 1.
-     */
-    @Override
-    public final int getTargetDimensions() {
-        return 1;
-    }
-
-    /**
-     * Tests whether this transform does not move any points.
-     */
-    @Override
-    public boolean isIdentity() {
-        for (final Category category : categories) {
-            final MathTransform1D tr = category.transferFunction;
-            if (tr == null || !tr.isIdentity()) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    /**
-     * Returns the inverse transform of this object.
-     *
-     * @todo Not yet implemented.
-     */
-    @Override
-    public final MathTransform1D inverse() throws NoninvertibleTransformException {
-        throw new NoninvertibleTransformException();
-    }
-
-    /**
-     * Transforms the specified {@code ptSrc} and stores the result in {@code ptDst}.
-     */
-    @Override
-    public final DirectPosition transform(final DirectPosition ptSrc, DirectPosition ptDst) throws TransformException {
-        ArgumentChecks.ensureNonNull("ptSrc", ptSrc);
-        ArgumentChecks.ensureDimensionMatches("ptSrc", 1, ptSrc);
-        if (ptDst == null) {
-            ptDst = new GeneralDirectPosition(1);
-        } else {
-            ArgumentChecks.ensureDimensionMatches("ptDst", 1, ptDst);
-        }
-        ptDst.setOrdinate(0, transform(ptSrc.getOrdinate(0)));
-        return ptDst;
-    }
-
-    /**
-     * Gets the derivative of this transform at a point.
-     */
-    @Override
-    public final Matrix derivative(final DirectPosition point) throws TransformException {
-        ArgumentChecks.ensureNonNull("point", point);
-        ArgumentChecks.ensureDimensionMatches("ptSrc", 1, point);
-        return new Matrix1(derivative(point.getOrdinate(0)));
-    }
-
-    /**
-     * Gets the derivative of this function at a value.
-     *
-     * @param  value  the value where to evaluate the derivative.
-     * @return the derivative at the specified point.
-     * @throws TransformException if the derivative can not be evaluated at the specified point.
-     */
-    @Override
-    public final double derivative(final double value) throws TransformException {
-        Category category = last;
-        if (!(value >= category.minimum  &&  value <= category.maximum) &&
-             doubleToRawLongBits(value) != doubleToRawLongBits(category.minimum))
-        {
-            category = getCategory(value);
-            if (category == null) {
-                throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
-            }
-            last = category;
-        }
-        return category.transferFunction.derivative(value);
-    }
-
-    /**
-     * Transforms the specified value.
-     *
-     * @param value The value to transform.
-     * @return the transformed value.
-     * @throws TransformException if the value can't be transformed.
-     */
-    @Override
-    public final double transform(double value) throws TransformException {
-        Category category = last;
-        if (!(value >= category.minimum  &&  value <= category.maximum) &&
-             doubleToRawLongBits(value) != doubleToRawLongBits(category.minimum))
-        {
-            category = getCategory(value);
-            if (category == null) {
-                throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
-            }
-            last = category;
-        }
-        value = category.transferFunction.transform(value);
-        if (SEARCH_NEAREST) {
-//          if (value < category.inverse.minimum) return category.inverse.minimum;
-//          if (value > category.inverse.maximum) return category.inverse.maximum;
-        }
-//      assert category == inverse.getCategory(value).inverse : category;
-        return value;
-    }
-
-    /**
      * Transforms a list of coordinate point ordinal values. This implementation accepts
      * float or double arrays, since the quasi-totality of the implementation is the same.
      * Locale variables still of the {@code double} type because this is the type used in
      * {@link Category} objects.
-     *
-     * @todo We could add an optimization after the loops checking for category change:
-     *       if we were allowed to search for nearest category (overflowFallback!=null),
-     *       then make sure that the category really changed. There is already a slight
-     *       optimization for the most common cases, but maybe we could go a little bit
-     *       further.
      */
     private void transform(final double[] srcPts, final float[] srcFloat, int srcOff,
                            final double[] dstPts, final float[] dstFloat, int dstOff,
                            int numPts) throws TransformException
     {
         final int srcToDst = dstOff - srcOff;
-        Category  category = last;
-        double     maximum = category.maximum;
-        double     minimum = category.minimum;
-        long       rawBits = doubleToRawLongBits(minimum);
         final int direction;
         if (srcOff >= dstOff || (srcFloat != null ? srcFloat != dstFloat : srcPts != dstPts)) {
             direction = +1;
         } else {
             direction = -1;
-            dstOff += numPts-1;             // Updated for safety, but not used.
+//          dstOff += numPts-1;             // Not updated because not used.
             srcOff += numPts-1;
         }
         /*
-         * Scan every points. Transforms will be performed by blocks, each time
-         * the loop detects that the category has changed. The break point is near
-         * the end of the loop, after we have done the transformation but before
-         * to change category.
+         * Scan every points.  Transforms will be applied by blocks, each time the loop detects that
+         * the category has changed. The break condition (numPts >= 0) is near the end of the loop,
+         * after we have done the conversion but before to change category.
          */
-        for (int peekOff=srcOff; true; peekOff += direction) {
-            double value = 0;
+        Category category = last;
+        double value = Double.NaN;
+        for (int peekOff = srcOff; /* numPts >= 0 */; peekOff += direction) {
+            final double minimum = category.minimum;
+            final double maximum = category.maximum;
+            final long   rawBits = doubleToRawLongBits(minimum);
             while (--numPts >= 0) {
                 value = (srcFloat != null) ? srcFloat[peekOff] : srcPts[peekOff];
-                if ((value >= minimum && value <= maximum) ||
-                    doubleToRawLongBits(value) == rawBits)
+                if (value >= minimum) {
+                    if (!(value <= maximum || category == extrapolation)) {
+                        /*
+                         * If the value is greater than the [minimum … maximum] range and extrapolation
+                         * is not allowed, then consider that the category has changed; stop the search.
+                         */
+                        break;
+                    }
+                } else if (doubleToRawLongBits(value) != rawBits &&
+                        (isNaN(value) || extrapolation == null || category != categories[0]))
                 {
-                    peekOff += direction;
-                    continue;
-                }
-                break;                          // The category has changed. Stop the search.
-            }
-            if (SEARCH_NEAREST) {
-                /*
-                 * TODO: Slight optimization. We could go further by checking if 'value' is closer
-                 *       to this category than to the previous category or the next category.  But
-                 *       we may need the category index, and binarySearch is a costly operation...
-                 */
-//              if (value > maximum && category == overflowFallback) {
-//                  continue;
-//              }
-                if (value < minimum && category == categories[0]) {
-                    continue;
+                    /*
+                     * If the value is not the expected NaN value, or the value is a real number less than
+                     * the [minimum … maximum] range with extrapolation not allowed, then consider that the
+                     * category has changed; stop the search.
+                     */
+                    break;
                 }
+                peekOff += direction;
             }
             /*
-             * The category has changed. Compute the start point (which depends of 'direction')
-             * and performs the transformation. If 'getCategory' was allowed to search for the
-             * nearest category, clamp all output values in their category range.
+             * The category has changed. Compute the start point (which depends on 'direction') and perform
+             * the conversion. If 'search' was allowed to search for the nearest category, clamp all output
+             * values in their category range.
              */
-            int count = peekOff - srcOff;  // May be negative if we are going backward.
+            int count = peekOff - srcOff;                       // May be negative if we are going backward.
             if (count < 0) {
                 count  = -count;
-                srcOff -= count-1;
+                srcOff -= count - 1;
             }
             final int stepOff = srcOff + srcToDst;
-            final MathTransform1D step = category.transferFunction;
+            final MathTransform1D piece = category.transferFunction;
             if (srcFloat != null) {
                 if (dstFloat != null) {
-                    step.transform(srcFloat, srcOff, dstFloat, stepOff, count);
+                    piece.transform(srcFloat, srcOff, dstFloat, stepOff, count);
                 } else {
-                    step.transform(srcFloat, srcOff, dstPts, stepOff, count);
+                    piece.transform(srcFloat, srcOff, dstPts, stepOff, count);
                 }
             } else {
                 if (dstFloat != null) {
-                    step.transform(srcPts, srcOff, dstFloat, stepOff, count);
+                    piece.transform(srcPts, srcOff, dstFloat, stepOff, count);
                 } else {
-                    step.transform(srcPts, srcOff, dstPts, stepOff, count);
+                    piece.transform(srcPts, srcOff, dstPts, stepOff, count);
                 }
             }
-            if (SEARCH_NEAREST) {
+            if (extrapolation != null) {
                 dstOff = srcOff + srcToDst;
-                final Category inverse = null;  // TODO category.inverse;
-                if (dstFloat != null) { // Loop for the 'float' version.
-                    final float min = (float) inverse.minimum;
-                    final float max = (float) inverse.maximum;
+                final Category cnv = category.converted;
+                if (dstFloat != null) {                                 // Loop for the 'float' version.
+                    final float min = (float) cnv.minimum;
+                    final float max = (float) cnv.maximum;
                     while (--count >= 0) {
                         final float check = dstFloat[dstOff];
                         if (check < min) {
@@ -620,9 +434,9 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
                         }
                         dstOff++;
                     }
-                } else { // Loop for the 'double' version.
-                    final double min = inverse.minimum;
-                    final double max = inverse.maximum;
+                } else {                                                // Loop for the 'double' version.
+                    final double min = cnv.minimum;
+                    final double max = cnv.maximum;
                     while (--count >= 0) {
                         final double check = dstPts[dstOff];
                         if (check < min) {
@@ -635,21 +449,16 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
                 }
             }
             /*
-             * Transformation is now finished for all points in the range [srcOff..peekOff]
-             * (not including 'peekOff'). If there is more points to examine, gets the new
+             * Transformation is now finished for all points in the range [srcOff … peekOff]
+             * (not including 'peekOff'). If there is more points to examine, get the new
              * category for the next points.
              */
-            if (numPts < 0) {
-                break;
-            }
-            category = getCategory(value);
+            if (numPts < 0) break;
+            category = search(value);
             if (category == null) {
                 throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
             }
-            maximum = category.maximum;
-            minimum = category.minimum;
-            rawBits = doubleToRawLongBits(minimum);
-            srcOff  = peekOff;
+            srcOff = peekOff;
         }
         last = category;
     }
@@ -687,8 +496,150 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
+     * Transforms the specified value.
+     *
+     * @param  value  the value to transform.
+     * @return the transformed value.
+     * @throws TransformException if the value can not be transformed.
+     */
+    @Override
+    public final double transform(double value) throws TransformException {
+        Category category = last;
+        if (!(value >= category.minimum  &&  value <= category.maximum) &&
+             doubleToRawLongBits(value) != doubleToRawLongBits(category.minimum))
+        {
+            category = search(value);
+            if (category == null) {
+                throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
+            }
+            last = category;
+        }
+        value = category.transferFunction.transform(value);
+        if (extrapolation != null) {
+            double bound;
+            if (value < (bound = category.converted.minimum)) return bound;
+            if (value > (bound = category.converted.maximum)) return bound;
+        }
+        assert category == converted.search(value).converted : category;
+        return value;
+    }
+
+    /**
+     * Gets the derivative of this function at a value.
+     *
+     * @param  value  the value where to evaluate the derivative.
+     * @return the derivative at the specified point.
+     * @throws TransformException if the derivative can not be evaluated at the specified point.
+     */
+    @Override
+    public final double derivative(final double value) throws TransformException {
+        Category category = last;
+        if (!(value >= category.minimum  &&  value <= category.maximum) &&
+             doubleToRawLongBits(value) != doubleToRawLongBits(category.minimum))
+        {
+            category = search(value);
+            if (category == null) {
+                throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
+            }
+            last = category;
+        }
+        return category.transferFunction.derivative(value);
+    }
+
+    /**
+     * Transforms the specified {@code ptSrc} and stores the result in {@code ptDst}.
+     */
+    @Override
+    public final DirectPosition transform(final DirectPosition ptSrc, DirectPosition ptDst) throws TransformException {
+        ArgumentChecks.ensureNonNull("ptSrc", ptSrc);
+        ArgumentChecks.ensureDimensionMatches("ptSrc", 1, ptSrc);
+        if (ptDst == null) {
+            ptDst = new GeneralDirectPosition(1);
+        } else {
+            ArgumentChecks.ensureDimensionMatches("ptDst", 1, ptDst);
+        }
+        ptDst.setOrdinate(0, transform(ptSrc.getOrdinate(0)));
+        return ptDst;
+    }
+
+    /**
+     * Gets the derivative of this transform at a point.
+     */
+    @Override
+    public final Matrix derivative(final DirectPosition point) throws TransformException {
+        ArgumentChecks.ensureNonNull("point", point);
+        ArgumentChecks.ensureDimensionMatches("point", 1, point);
+        return new Matrix1(derivative(point.getOrdinate(0)));
+    }
+
+    /**
+     * Tests whether this transform does not move any points.
+     */
+    @Override
+    public boolean isIdentity() {
+        return converted == this;
+    }
+
+    /**
+     * Returns the inverse transform of this object, which may be {@code this} if this transform is identity.
+     */
+    @Override
+    @SuppressWarnings("ReturnOfCollectionOrArrayField")
+    public final MathTransform1D inverse() {
+        return converted;
+    }
+
+    /**
+     * Gets the dimension of input points, which is 1.
+     */
+    @Override
+    public final int getSourceDimensions() {
+        return 1;
+    }
+
+    /**
+     * Gets the dimension of output points, which is 1.
+     */
+    @Override
+    public final int getTargetDimensions() {
+        return 1;
+    }
+
+    /**
+     * Returns the number of categories in this list.
+     */
+    @Override
+    public final int size() {
+        return categories.length;
+    }
+
+    /**
+     * Returns the element at the specified position in this list.
+     */
+    @Override
+    public final Category get(final int i) {
+        return categories[i];
+    }
+
+    /**
+     * Compares the specified object with this category list for equality.
+     */
+    @Override
+    public boolean equals(final Object object) {
+        if (object instanceof CategoryList) {
+            final CategoryList that = (CategoryList) object;
+            if (Arrays.equals(categories, that.categories)) {
+                assert Arrays.equals(minimums, that.minimums);
+            } else {
+                return false;
+            }
+        }
+        return super.equals(object);
+    }
+
+    /**
      * Returns a <cite>Well Known Text</cite> (WKT) for this object. This operation
-     * may fails if an object is too complex for the WKT format capability.
+     * may fail if an object is too complex for the WKT format capability.
      *
      * @return the Well Know Text for this object.
      * @throws UnsupportedOperationException if this object can not be formatted as WKT.
diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/GeophysicsRange.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/GeophysicsRange.java
new file mode 100644
index 0000000..e57832f
--- /dev/null
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/GeophysicsRange.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.coverage;
+
+import javax.measure.Unit;
+import org.apache.sis.measure.MeasurementRange;
+
+
+/**
+ * Range of geophysics values computed from the range of the sample values.
+ * The {@link Category#transferFunction} conversion is used by the caller for computing the inclusive and exclusive
+ * minimum and maximum values of this range. We compute both the inclusive and exclusive values because we can not
+ * rely on the default implementation, which looks for the nearest representable number. For example is the range of
+ * index values is 0 to 10 exclusive (or 0 to 9 inclusive) and the scale is 2, then the range of geophysics values
+ * is 0 to 20 exclusive or 0 to 18 inclusive, not 0 to 19.9999… The numbers between 18 and 20 is a "gray area" where
+ * we don't know for sure what the user intents to do.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+final class GeophysicsRange extends MeasurementRange<Double> {
+    /**
+     * Serial number for inter-operability with different versions.
+     */
+    private static final long serialVersionUID = -1416908614729956928L;
+
+    /**
+     * The minimal value to be returned by {@link #getMinDouble(boolean)} when
+     * the {@code inclusive} flag is the opposite of {@link #isMinIncluded()}.
+     */
+    private final double altMinimum;
+
+    /**
+     * The maximal value to be returned by {@link #getMaxDouble(boolean)} when
+     * the {@code inclusive} flag is the opposite of {@link #isMaxIncluded()}.
+     */
+    private final double altMaximum;
+
+    /**
+     * Constructs a range of {@code double} values.
+     */
+    GeophysicsRange(final double[] extremums, final boolean isMinIncluded, final boolean isMaxIncluded, final Unit<?> unit) {
+        super(Double.class, extremums[0], isMinIncluded, extremums[1], isMaxIncluded, unit);
+        altMinimum = extremums[2];
+        altMaximum = extremums[3];
+    }
+
+    /**
+     * Returns the minimum value with the specified inclusive or exclusive state.
+     */
+    @Override
+    public double getMinDouble(final boolean inclusive) {
+        return (inclusive == isMinIncluded()) ? getMinDouble() : altMinimum;
+    }
+
+    /**
+     * Returns the maximum value with the specified inclusive or exclusive state.
+     */
+    @Override
+    public double getMaxDouble(final boolean inclusive) {
+        return (inclusive == isMaxIncluded()) ? getMaxDouble() : altMaximum;
+    }
+}
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 b38b6a4..ef9aa46 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
@@ -18,19 +18,42 @@ package org.apache.sis.coverage;
 
 import java.util.List;
 import java.util.ArrayList;
+import java.util.Set;
+import java.util.HashSet;
 import java.util.Optional;
+import java.util.Collections;
+import java.io.Serializable;
 import javax.measure.Unit;
 import org.opengis.util.InternationalString;
 import org.opengis.referencing.operation.MathTransform1D;
 import org.apache.sis.referencing.operation.transform.TransferFunction;
+import org.apache.sis.measure.MeasurementRange;
 import org.apache.sis.measure.NumberRange;
+import org.apache.sis.util.resources.Vocabulary;
+import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.iso.Types;
 import org.apache.sis.util.Classes;
+import org.apache.sis.util.Numbers;
 
 
 /**
- * Describes the data values in a coverage (the range) when those values are numbers.
- * For a grid coverage or a raster, a sample dimension may be a band.
+ * Describes the data values in a coverage (the range). For a raster, a sample dimension is a band.
+ * A sample dimension can reserve some values for <cite>qualitative</cite> information like  “this
+ * is a forest” and some other values for <cite>quantitative</cite> information like a temperature
+ * measurements.
+ *
+ * <div class="note"><b>Example:</b>
+ * an image of sea surface temperature (SST) could define the following categories:
+ * <table class="sis">
+ *   <caption>Example of categories in a sample dimension</caption>
+ *   <tr><th>Values range</th> <th>Meaning</th></tr>
+ *   <tr><td>[0]</td>          <td>No data</td></tr>
+ *   <tr><td>[1]</td>          <td>Cloud</td></tr>
+ *   <tr><td>[2]</td>          <td>Land</td></tr>
+ *   <tr><td>[10…210]</td>     <td>Temperature to be converted into Celsius degrees through a linear equation</td></tr>
+ * </table>
+ * In this example, sample values in range [10…210] define a quantitative category, while all others categories are qualitative.
+ * </div>
  *
  * <div class="section">Relationship with metadata</div>
  * This class provides the same information than ISO 19115 {@link org.opengis.metadata.content.SampleDimension},
@@ -42,56 +65,58 @@ import org.apache.sis.util.Classes;
  * @since   1.0
  * @module
  */
-public class SampleDimension {
+public final class SampleDimension implements Serializable {
+    /**
+     * Serial number for inter-operability with different versions.
+     */
+    private static final long serialVersionUID = 6026936545776852758L;
+
     /**
      * Description for this sample dimension. Typically used as a way to perform a band select by
      * using human comprehensible descriptions instead of just numbers. Web Coverage Service (WCS)
      * can use this name in order to perform band sub-setting as directed from a user request.
+     *
+     * @see #getName()
      */
     private final InternationalString name;
 
     /**
-     * The range of sample values.
-     * May be {@code null} if this sample dimension has no non-{@code NaN} value.
-     */
-    private final NumberRange<?> range;
-
-    /**
-     * The values to indicate "no data" for this sample dimension.
+     * The list of categories making this sample dimension. May be empty but shall never be null.
      */
-    private final Number[] noDataValues;
+    private final CategoryList categories;
 
     /**
-     * The transform from sample to geophysics value. May be {@code null} if this sample dimension
-     * do not defines any transform (which is not the same that defining an identity transform).
+     * The transform from sample to geophysics values. May be {@code null} if this sample dimension
+     * does not defines any transform (which is not the same that defining an identity transform).
+     *
+     * @see #getTransferFunction()
      */
     private final MathTransform1D transferFunction;
 
     /**
-     * The units of measurement for this sample dimension, or {@code null} if not applicable.
-     */
-    private final Unit<?> units;
-
-    /**
      * Creates a sample dimension with the specified properties.
      *
-     * @param name     the sample dimension title or description.
-     * @param nodata   the values to indicate "no data".
-     * @param range    the range of sample values.
-     * @param toUnit   the transfer function for converting sample values to geophysics values in {@code units}.
-     * @param units    the units of measurement for this sample dimension, or {@code null} if not applicable.
+     * @param name        the sample dimension title or description, or {@code null} for default.
+     * @param categories  the list of categories. This list is not cloned and may be modified in-place.
      */
-    SampleDimension(final InternationalString name,
-                    final NumberRange<?>      range,
-                    final Number[]            nodata,
-                    final MathTransform1D     toUnit,
-                    final Unit<?>             units)
-    {
-        this.name             = name;
-        this.range            = range;
-        this.noDataValues     = nodata;
-        this.transferFunction = toUnit;
-        this.units            = units;
+    SampleDimension(InternationalString name, final Category[] categories) {
+        if (name == null) {
+            name = Vocabulary.formatInternational(Vocabulary.Keys.Untitled);
+        }
+        final CategoryList list = new CategoryList(categories, null);
+        this.name       = name;
+        this.categories = list;
+        MathTransform1D tr = null;
+        if (list.hasQuantitative()) {
+            tr = categories[0].transferFunction;
+            for (int i=1; i<categories.length; i++) {
+                if (!tr.equals(categories[i].transferFunction)) {
+                    tr = list;
+                    break;
+                }
+            }
+        }
+        transferFunction = tr;
     }
 
     /**
@@ -99,7 +124,7 @@ public class SampleDimension {
      * by using human comprehensible descriptions instead of just numbers. Web Coverage Service (WCS) can use this name
      * in order to perform band sub-setting as directed from a user request.
      *
-     * @return The title or description of this sample dimension.
+     * @return the title or description of this sample dimension.
      */
     public InternationalString getName() {
         return name;
@@ -108,20 +133,51 @@ public class SampleDimension {
     /**
      * Returns the values to indicate "no data" for this sample dimension.
      *
-     * @return the values to indicate no data values for this sample dimension, or an empty array if none.
+     * @return the values to indicate no data values for this sample dimension, or an empty list if none.
      */
-    public Number[] getNoDataValues() {
-        return noDataValues.clone();
+    public List<Number> getNoDataValues() {
+        if (!categories.hasQuantitative()) {
+            return Collections.emptyList();
+        }
+        final List<Number> noDataValues = new ArrayList<>(categories.size());
+        for (final Category c : categories) {
+            if (!c.isQuantitative()) {
+                final NumberRange<?> r = c.range;
+                final Number value;
+                if (r.isMinIncluded()) {
+                    value = r.getMinDouble();
+                } else if (r.isMaxIncluded()) {
+                    value = r.getMaxDouble();
+                } else {
+                    value = (c.minimum + c.maximum) / 2;
+                }
+                noDataValues.add(value);
+            }
+        }
+        return noDataValues;
     }
 
     /**
-     * Returns the range of values in this sample dimension.
-     * May be absent if this sample dimension has no non-{@code NaN} value.
+     * Returns the range of values occurring in this sample dimension. The range are sample values than can
+     * be converted into geophysics values using the {@linkplain #getTransferFunction() transfer function}.
+     * If that function is {@linkplain MathTransform1D#isIdentity() identity}, then the values are already
+     * geophysics values and the range may be an instance of {@link MeasurementRange} (i.e. a number range
+     * with units of measurement).
      *
      * @return the range of values.
      */
-    public Optional<NumberRange<?>> getRange() {
-        return Optional.ofNullable(range);
+    public Optional<NumberRange<?>> getSampleRange() {
+        return Optional.ofNullable(categories.range);
+    }
+
+    /**
+     * Returns the range of values after conversions by the transfer function.
+     * This range is absent if there is no transfer function.
+     *
+     * @return the range of values after conversion by the transfer function.
+     */
+    public Optional<NumberRange<?>> getMeasurementRange() {
+        return Optional.ofNullable(categories.converted.range);
     }
 
     /**
@@ -131,7 +187,7 @@ public class SampleDimension {
      * The <code>transferFunction.{@linkplain MathTransform1D#inverse() inverse()}</code> transform is capable to differentiate
      * {@code NaN} values to get back the original sample value.
      *
-     * @return The <cite>transfer function</cite> from sample to geophysics values. May be absent if this sample dimension
+     * @return the <cite>transfer function</cite> from sample to geophysics values. May be absent if this sample dimension
      *         do not defines any transform (which is not the same that defining an identity transform).
      *
      * @see TransferFunction
@@ -148,6 +204,21 @@ public class SampleDimension {
      * @return the units of measurement.
      */
     public Optional<Unit<?>> getUnits() {
+        Unit<?> units = null;
+        for (final Category c : categories.converted) {
+            final NumberRange<?> r = c.range;
+            if (r instanceof MeasurementRange<?>) {
+                final Unit<?> u = ((MeasurementRange<?>) r).unit();
+                if (u != null) {
+                    if (units == null) {
+                        units = u;
+                    } else if (!units.equals(u)) {
+                        units = null;                   // Different quantitative categories use different units.
+                        break;
+                    }
+                }
+            }
+        }
         return Optional.ofNullable(units);
     }
 
@@ -162,54 +233,301 @@ public class SampleDimension {
         return Classes.getShortClassName(this) + "[“" + name + "”]";
     }
 
+
+
+
     /**
-     * A mutable builder for a {@link SampleDimension}.
-     * After properties have been set, the sample dimension is created by {@link #build()}.
+     * A mutable builder for creating an immutable {@link SampleDimension}.
+     * The following properties can be set:
+     *
+     * <ul>
+     *   <li>An optional name for the {@code SampleDimension}.</li>
+     *   <li>An arbitrary amount of <cite>qualitative</cite> categories.</li>
+     *   <li>An arbitrary amount of <cite>quantitative</cite> categories.</li>
+     * </ul>
+     *
+     * A <cite>qualitative category</cite> is a range of sample values associated to a label (not numbers).
+     * For example 0 = cloud, 1 = sea, 2 = land, <i>etc</i>.
+     * A <cite>quantitative category</cite> is a range of sample values associated to numbers with units of measurement.
+     * For example 10 = 1.0°C, 11 = 1.1°C, 12 = 1.2°C, <i>etc</i>.
+     * Those two kind of categories are created by the following methods:
      *
-     * @param <T> the type of values in the sample dimension.
+     * <ul>
+     *   <li>{@link #addQualitative(CharSequence, NumberRange)}</li>
+     *   <li>{@link #addQuantitative(CharSequence, NumberRange, MathTransform1D, Unit)}</li>
+     * </ul>
+     *
+     * All other {@code addQualitative(…)} and {@code addQuantitative(…)} methods are convenience methods delegating
+     * to above-cited methods. Qualitative and quantitative categories can be mixed in the same {@link SampleDimension},
+     * provided that their ranges do not overlap.
+     * After properties have been set, the sample dimension is created by invoking {@link #build()}.
+     *
+     * @author  Martin Desruisseaux (IRD, Geomatys)
+     * @version 1.0
+     * @since   1.0
+     * @module
      */
-    public class Builder<T extends Number & Comparable<? super T>> {
+    public static class Builder {
         /**
          * Description for this sample dimension.
          */
-        private CharSequence name;
+        private CharSequence dimensionName;
 
         /**
-         * The range of sample values.
-         * May be {@code null} if this sample dimension has no non-{@code NaN} value.
+         * The categories for this sample dimension.
          */
-        private NumberRange<?> range;
+        private final List<Category> categories;
 
         /**
-         * The values to indicate "no data" for this sample dimension.
+         * The ordinal NaN values used for this sample dimension.
+         * The {@link Category} constructor uses this set for avoiding collisions.
          */
-        private final List<T> noDataValues = new ArrayList<>();
+        private final Set<Integer> padValues;
 
         /**
-         * Builder for the math transform from sample to geophysics values.
+         * Creates an initially empty builder for a sample dimension.
+         * Callers shall invoke at least one {@code addFoo(…)} method before {@link #build()}.
          */
-        private final TransferFunction transferFunction = new TransferFunction();
+        public Builder() {
+            categories = new ArrayList<>();
+            padValues  = new HashSet<>();
+        }
 
         /**
-         * The units of measurement for this sample dimension, or {@code null} if not applicable.
+         * Sets the name or description of the sample dimension.
+         * This is the value to be returned by {@link SampleDimension#getName()}.
+         *
+         * @param  name the name or description of the sample dimension.
+         * @return {@code this}, for method call chaining.
          */
-        private Unit<?> units;
+        public Builder setName(final CharSequence name) {
+            dimensionName = name;
+            return this;
+        }
 
         /**
-         * Creates a new, initially empty, builder.
+         * Adds a qualitative category for samples of the given boolean value.
+         * The {@code true} value is represented by 1 and the {@code false} value is represented by 0.
+         *
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQualitative(CharSequence, NumberRange)}.</div>
+         *
+         * @param  name    the category name as a {@link String} or {@link InternationalString} object,
+         *                 or {@code null} for a default "no data" name.
+         * @param  sample  the sample value as a boolean.
+         * @return {@code this}, for method call chaining.
          */
-        public Builder() {
+        public Builder addQualitative(final CharSequence name, final boolean sample) {
+            final byte value = sample ? (byte) 1 : 0;
+            return addQualitative(name, NumberRange.create(value, true, value, true));
         }
 
         /**
-         * Sets the name or description of the sample dimension.
-         * This is the value to be returned by {@link SampleDimension#getName()}.
+         * Adds a qualitative category for samples of the given tiny (8 bits) integer value.
+         * The argument is treated as a signed integer ({@value Byte#MIN_VALUE} to {@value Byte#MAX_VALUE}).
          *
-         * @param  name the name or description of the sample dimension.
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQualitative(CharSequence, NumberRange)}.</div>
+         *
+         * @param  name    the category name as a {@link String} or {@link InternationalString} object,
+         *                 or {@code null} for a default "no data" name.
+         * @param  sample  the sample value as an integer.
+         * @return {@code this}, for method call chaining.
+         */
+        public Builder addQualitative(final CharSequence name, final byte sample) {
+            return addQualitative(name, NumberRange.create(sample, true, sample, true));
+        }
+
+        /**
+         * Adds a qualitative category for samples of the given short (16 bits) integer value.
+         * The argument is treated as a signed integer ({@value Short#MIN_VALUE} to {@value Short#MAX_VALUE}).
+         *
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQualitative(CharSequence, NumberRange)}.</div>
+         *
+         * @param  name    the category name as a {@link String} or {@link InternationalString} object,
+         *                 or {@code null} for a default "no data" name.
+         * @param  sample  the sample value as an integer.
+         * @return {@code this}, for method call chaining.
+         */
+        public Builder addQualitative(final CharSequence name, final short sample) {
+            return addQualitative(name, NumberRange.create(sample, true, sample, true));
+        }
+
+        /**
+         * Adds a qualitative category for samples of the given integer value.
+         * The argument is treated as a signed integer ({@value Integer#MIN_VALUE} to {@value Integer#MAX_VALUE}).
+         *
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQualitative(CharSequence, NumberRange)}.</div>
+         *
+         * @param  name    the category name as a {@link String} or {@link InternationalString} object,
+         *                 or {@code null} for a default "no data" name.
+         * @param  sample  the sample value as an integer.
+         * @return {@code this}, for method call chaining.
+         */
+        public Builder addQualitative(final CharSequence name, final int sample) {
+            return addQualitative(name, NumberRange.create(sample, true, sample, true));
+        }
+
+        /**
+         * Adds a qualitative category for samples of the given floating-point value.
+         * The given value can not be {@link Float#NaN NaN}.
+         *
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQualitative(CharSequence, NumberRange)}.</div>
+         *
+         * @param  name    the category name as a {@link String} or {@link InternationalString} object,
+         *                 or {@code null} for a default "no data" name.
+         * @param  sample  the sample value as a real number.
+         * @return {@code this}, for method call chaining.
+         * @throws IllegalArgumentException if the given value is NaN.
+         */
+        public Builder addQualitative(final CharSequence name, final float sample) {
+            return addQualitative(name, NumberRange.create(sample, true, sample, true));
+        }
+
+        /**
+         * Adds a qualitative category for samples of the given double precision floating-point value.
+         * The given value can not be {@link Double#NaN NaN}.
+         *
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQualitative(CharSequence, NumberRange)}.</div>
+         *
+         * @param  name    the category name as a {@link String} or {@link InternationalString} object,
+         *                 or {@code null} for a default "no data" name.
+         * @param  sample  the sample value as a real number.
+         * @return {@code this}, for method call chaining.
+         * @throws IllegalArgumentException if the given value is NaN.
+         */
+        public Builder addQualitative(final CharSequence name, final double sample) {
+            return addQualitative(name, NumberRange.create(sample, true, sample, true));
+        }
+
+        /**
+         * Adds a qualitative category for all samples in the specified range of values.
+         * This is the most generic method for adding a qualitative category.
+         * All other {@code addQualitative(name, …)} methods are convenience methods delegating their work to this method.
+         *
+         * @param  name     the category name as a {@link String} or {@link InternationalString} object,
+         *                  or {@code null} for a default "no data" name.
+         * @param  samples  the minimum and maximum sample values in the category.
+         * @return {@code this}, for method call chaining.
+         * @throws IllegalArgumentException if the given range is empty.
+         */
+        public Builder addQualitative(CharSequence name, final NumberRange<?> samples) {
+            if (name == null) {
+                name = Vocabulary.formatInternational(Vocabulary.Keys.Nodata);
+            }
+            categories.add(new Category(name, samples, null, null, padValues));
+            return this;
+        }
+
+        /**
+         * Constructs a quantitative category mapping samples to geophysics values in the specified range.
+         * Sample values in the {@code samples} range will be mapped to geophysics values in the {@code geophysics} range
+         * through a linear equation of the form:
+         *
+         * <blockquote><var>measure</var> = <var>sample</var> × <var>scale</var> + <var>offset</var></blockquote>
+         *
+         * where <var>scale</var> and <var>offset</var> coefficients are computed from the ranges supplied in arguments.
+         * The units of measurement will be taken from the {@code geophysics} range if it is an instance of {@link MeasurementRange}.
+         *
+         * <p><b>Warning:</b> this method is provided for convenience when the scale and offset factors are not explicitly specified.
+         * If those factor are available, then the other {@code addQuantitative(name, samples, …)} methods are more reliable.</p>
+         *
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQuantitative(CharSequence, NumberRange, MathTransform1D, Unit)}.</div>
+         *
+         * @param  name        the category name as a {@link String} or {@link InternationalString} object.
+         * @param  samples     the minimum and maximum sample values in the category. Element class is usually
+         *                     {@link Integer}, but {@link Float} and {@link Double} types are accepted as well.
+         * @param  geophysics  the range of geophysics values for this category, as an instance of {@link MeasurementRange}
+         *                     if those values are associated to an unit of measurement.
          * @return {@code this}, for method call chaining.
+         * @throws ClassCastException if the range element class is not a {@link Number} subclass.
+         * @throws IllegalArgumentException if the range is invalid.
+         */
+        public Builder addQuantitative(final CharSequence name, final NumberRange<?> samples, final NumberRange<?> geophysics) {
+            ArgumentChecks.ensureNonNull("samples", samples);
+            ArgumentChecks.ensureNonNull("geophysics", geophysics);
+            /*
+             * We need to perform calculation using the same "included versus excluded" characteristic for sample and geophysics
+             * values. We pickup the characteristics of the range using floating point values because it is easier to adjust the
+             * bounds of the range using integer values (we just add or subtract 1 for integers, while the amount to add to real
+             * numbers is not so clear). If both ranges use floating point values, arbitrarily adjust the geophysics values.
+             */
+            final boolean isMinIncluded, isMaxIncluded;
+            if (Numbers.isInteger(samples.getElementType())) {
+                isMinIncluded = geophysics.isMinIncluded();                         // This is the usual case.
+                isMaxIncluded = geophysics.isMaxIncluded();
+            } else {
+                isMinIncluded = samples.isMinIncluded();                            // Less common case.
+                isMaxIncluded = samples.isMaxIncluded();
+            }
+            final double minValue  = geophysics.getMinDouble(isMinIncluded);
+            final double Δvalue    = geophysics.getMaxDouble(isMaxIncluded) - minValue;
+            final double minSample =    samples.getMinDouble(isMinIncluded);
+            final double Δsample   =    samples.getMaxDouble(isMaxIncluded) - minSample;
+            final double scale     = Δvalue / Δsample;
+            final TransferFunction transferFunction = new TransferFunction();
+            transferFunction.setScale(scale);
+            transferFunction.setOffset(minValue - scale * minSample);               // TODO: use Math.fma with JDK9.
+            return addQuantitative(name, samples, transferFunction.getTransform(),
+                    (geophysics instanceof MeasurementRange<?>) ? ((MeasurementRange<?>) geophysics).unit() : null);
+        }
+
+        /**
+         * Adds a quantitative category for sample values ranging from {@code lower} inclusive to {@code upper} exclusive.
+         * Sample values are converted into geophysics values using the following linear equation:
+         *
+         * <blockquote><var>measure</var> = <var>sample</var> × <var>scale</var> + <var>offset</var></blockquote>
+         *
+         * Results of above conversion are measurements in the units specified by the {@code units} argument.
+         *
+         * <div class="note"><b>Implementation note:</b>
+         * this convenience method delegates to {@link #addQuantitative(CharSequence, NumberRange, MathTransform1D, Unit)}.</div>
+         *
+         * @param  name    the category name as a {@link String} or {@link InternationalString} object.
+         * @param  lower   the lower sample value, inclusive.
+         * @param  upper   the upper sample value, exclusive.
+         * @param  scale   the scale value which is multiplied to sample values for the category. Must be different than zero.
+         * @param  offset  the offset value to add to sample values for this category.
+         * @param  units   the units of measurement of values after conversion by the offset and scale factor.
+         * @return {@code this}, for method call chaining.
+         * @throws IllegalArgumentException if {@code lower} is not smaller than {@code upper},
+         *         or if {@code scale} or {@code offset} are not real numbers, or if {@code scale} is zero.
+         */
+        public Builder addQuantitative(CharSequence name, int lower, int upper, double scale, double offset, Unit<?> units) {
+            final TransferFunction transferFunction = new TransferFunction();
+            transferFunction.setScale(scale);
+            transferFunction.setOffset(offset);
+            return addQuantitative(name, NumberRange.create(lower, true, upper, false), transferFunction.getTransform(), units);
+        }
+
+        /**
+         * Constructs a quantitative category for all samples in the specified range of values.
+         * Sample values (usually integers) will be converted into geophysics values
+         * (usually floating-point numbers) through the {@code toUnits} transform.
+         * Results of that conversion are measurements in the units specified by the {@code units} argument.
+         *
+         * <p>This is the most generic method for adding a quantitative category.
+         * All other {@code addQuantitative(name, …)} methods are convenience methods delegating their work to this method.</p>
+         *
+         * @param  name     the category name as a {@link String} or {@link InternationalString} object.
+         * @param  samples  the minimum and maximum sample values in the category. Element class is usually
+         *                  {@link Integer}, but {@link Float} and {@link Double} types are accepted as well.
+         * @param  toUnits  the transfer function from sample values to geophysics values in the specified units.
+         * @param  units    the units of measurement of values after conversion by the transfer function.
+         * @return {@code this}, for method call chaining.
+         * @throws ClassCastException if the range element class is not a {@link Number} subclass.
+         * @throws IllegalArgumentException if the range is invalid.
+         *
+         * @see TransferFunction
          */
-        public Builder<T> setName(final CharSequence name) {
-            this.name = name;
+        public Builder addQuantitative(CharSequence name, NumberRange<?> samples, MathTransform1D toUnits, Unit<?> units) {
+            ArgumentChecks.ensureNonNull("toUnits", toUnits);
+            categories.add(new Category(name, samples, toUnits, units, padValues));
             return this;
         }
 
@@ -220,9 +538,8 @@ public class SampleDimension {
          */
         public SampleDimension build() {
             return new SampleDimension(
-                    Types.toInternationalString(name), range,
-                    noDataValues.toArray(new Number[noDataValues.size()]),
-                    transferFunction.getTransform(), units);
+                    Types.toInternationalString(dimensionName),
+                    categories.toArray(new Category[categories.size()]));
         }
     }
 }
diff --git a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.java b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.java
index 197e84a..6eeaa30 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.java
@@ -74,11 +74,21 @@ public final class Resources extends IndexedResourceBundle {
         public static final short CoordinateOutsideDomain_2 = 1;
 
         /**
+         * Sample value range {1} for “{0}” category is illegal.
+         */
+        public static final short IllegalCategoryRange_2 = 15;
+
+        /**
          * Illegal grid envelope [{1} … {2}] for dimension {0}.
          */
         public static final short IllegalGridEnvelope_3 = 8;
 
         /**
+         * Illegal transfer function for “{0}” category.
+         */
+        public static final short IllegalTransferFunction_1 = 16;
+
+        /**
          * The ({0}, {1}) tile has an unexpected size, number of bands or sample layout.
          */
         public static final short IncompatibleTile_2 = 2;
@@ -114,6 +124,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short NoCategoryForValue_1 = 14;
 
         /**
+         * Too many qualitative categories.
+         */
+        public static final short TooManyQualitatives = 17;
+
+        /**
          * Coordinate reference system is unspecified.
          */
         public static final short UnspecifiedCRS = 9;
diff --git a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.properties b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.properties
index bef00d3..2c2f5a6 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.properties
+++ b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources.properties
@@ -22,7 +22,9 @@
 CanNotMapToGridDimensions         = Some envelope dimensions can not be mapped to grid dimensions.
 CategoryRangeOverlap_4            = The two categories \u201c{0}\u201d and \u201c{2}\u201d have overlapping ranges: {1} and {3} respectively.
 CoordinateOutsideDomain_2         = The ({0}, {1}) pixel coordinate is outside iterator domain.
+IllegalCategoryRange_2            = Sample value range {1} for \u201c{0}\u201d category is illegal.
 IllegalGridEnvelope_3             = Illegal grid envelope [{1} \u2026 {2}] for dimension {0}.
+IllegalTransferFunction_1         = Illegal transfer function for \u201c{0}\u201d category.
 IncompatibleTile_2                = The ({0}, {1}) tile has an unexpected size, number of bands or sample layout.
 IterationIsFinished               = Iteration is finished.
 IterationNotStarted               = Iteration did not started.
@@ -30,6 +32,7 @@ MismatchedImageLocation           = The two images have different size or pixel
 MismatchedSampleModel             = The two images use different sample models.
 MismatchedTileGrid                = The two images have different tile grid.
 NoCategoryForValue_1              = No category for value {0}.
+TooManyQualitatives               = Too many qualitative categories.
 UnspecifiedCRS                    = Coordinate reference system is unspecified.
 UnspecifiedGridExtent             = Grid extent is unspecified.
 UnspecifiedTransform              = Coordinates transform is unspecified.
diff --git a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources_fr.properties b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources_fr.properties
index da25be3..f118ccb 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources_fr.properties
+++ b/core/sis-raster/src/main/java/org/apache/sis/internal/raster/Resources_fr.properties
@@ -27,7 +27,9 @@
 CanNotMapToGridDimensions         = Certaines dimensions de l\u2019enveloppe ne correspondent pas \u00e0 des dimensions de la grille.
 CategoryRangeOverlap_4            = Les deux cat\u00e9gories \u00ab\u202f{0}\u202f\u00bb et \u00ab\u202f{2}\u202f\u00bb ont des plages de valeurs qui se chevauchent\u2008: {1} et {3} respectivement.
 CoordinateOutsideDomain_2         = La coordonn\u00e9e pixel ({0}, {1}) est en dehors du domaine de l\u2019it\u00e9rateur.
+IllegalCategoryRange_2            = La plage de valeurs {1} pour la cat\u00e9gorie \u00ab\u202f{0}\u202f\u00bb est ill\u00e9gale.
 IllegalGridEnvelope_3             = La plage d\u2019index [{1} \u2026 {2}] de la dimension {0} n\u2019est pas valide.
+IllegalTransferFunction_1         = Fonction de transfert ill\u00e9gale pour la cat\u00e9gorie \u00ab\u202f{0}\u202f\u00bb.
 IncompatibleTile_2                = La tuile ({0}, {1}) a une taille, un nombre de bandes ou une disposition des valeurs inattendu.
 IterationIsFinished               = L\u2019it\u00e9ration est termin\u00e9e.
 IterationNotStarted               = L\u2019it\u00e9ration n\u2019a pas commenc\u00e9e.
@@ -35,6 +37,7 @@ MismatchedImageLocation           = Les deux images ont une taille ou des coordo
 MismatchedSampleModel             = Les deux images disposent les pixels diff\u00e9remment.
 MismatchedTileGrid                = Les deux images utilisent des grilles de tuiles diff\u00e9rentes.
 NoCategoryForValue_1              = Aucune cat\u00e9gorie n\u2019est d\u00e9finie pour la valeur {0}.
+TooManyQualitatives               = Trop de cat\u00e9gories qualitatives.
 UnspecifiedCRS                    = Le syst\u00e8me de r\u00e9f\u00e9rence des coordonn\u00e9es n\u2019a pas \u00e9t\u00e9 sp\u00e9cifi\u00e9.
 UnspecifiedGridExtent             = L\u2019\u00e9tendue de la grille n\u2019a pas \u00e9t\u00e9 sp\u00e9cifi\u00e9e.
 UnspecifiedTransform              = La transformation de coordonn\u00e9es n\u2019a pas \u00e9t\u00e9 sp\u00e9cifi\u00e9e.
diff --git a/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryListTest.java b/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryListTest.java
new file mode 100644
index 0000000..d4f04b1
--- /dev/null
+++ b/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryListTest.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.coverage;
+
+import java.util.Arrays;
+import java.util.Random;
+import org.apache.sis.math.MathFunctions;
+import org.apache.sis.test.TestUtilities;
+import org.apache.sis.test.TestCase;
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+
+/**
+ * Tests {@link CategoryList}.
+ *
+ * @author  Martin Desruisseaux (IRD, Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+public final strictfp class CategoryListTest extends TestCase {
+    /**
+     * Small value for comparisons.
+     */
+    private static final double EPS = 1E-9;
+
+    /**
+     * Asserts that the specified categories are sorted.
+     * This method ignores {@code NaN} values.
+     */
+    private static void assertSorted(final Category[] categories) {
+        for (int i=1; i<categories.length; i++) {
+            final Category current  = categories[i  ];
+            final Category previous = categories[i-1];
+            assertFalse( current.minimum >  current.maximum);
+            assertFalse(previous.minimum > previous.maximum);
+            assertFalse(Category.compare(previous.maximum, current.minimum) > 0);
+        }
+    }
+
+    /**
+     * Tests the {@link CategoryList#binarySearch(double[], double)} method.
+     */
+    @Test
+    public void testBinarySearch() {
+        final Random random = TestUtilities.createRandomNumberGenerator();
+        for (int pass=0; pass<50; pass++) {
+            final double[] array = new double[random.nextInt(32) + 32];
+            int realNumberLimit = 0;
+            for (int i=0; i<array.length; i++) {
+                realNumberLimit += random.nextInt(10) + 1;
+                array[i] = realNumberLimit;
+            }
+            realNumberLimit += random.nextInt(10);
+            for (int i=0; i<100; i++) {
+                final double searchFor = random.nextInt(realNumberLimit);
+                assertEquals("binarySearch", Arrays.binarySearch(array, searchFor),
+                                       CategoryList.binarySearch(array, searchFor));
+            }
+            /*
+             * Previous test didn't tested NaN values (which is the main difference
+             * between binarySearch method in Arrays and CategoryList). Now test it.
+             */
+            int nanOrdinalLimit = 0;
+            realNumberLimit /= 2;
+            for (int i = array.length / 2; i < array.length; i++) {
+                nanOrdinalLimit += random.nextInt(10) + 1;
+                array[i] = MathFunctions.toNanFloat(nanOrdinalLimit);
+            }
+            nanOrdinalLimit += random.nextInt(10);
+            for (int i=0; i<100; i++) {
+                final double search;
+                if (random.nextBoolean()) {
+                    search = random.nextInt(realNumberLimit);
+                } else {
+                    search = MathFunctions.toNanFloat(random.nextInt(nanOrdinalLimit));
+                }
+                int foundAt = CategoryList.binarySearch(array, search);
+                if (foundAt >= 0) {
+                    assertEquals(Double.doubleToRawLongBits(search),
+                                 Double.doubleToRawLongBits(array[foundAt]), STRICT);
+                } else {
+                    foundAt = ~foundAt;
+                    if (foundAt < array.length) {
+                        final double after = array[foundAt];
+                        assertFalse(search >= after);
+                        if (Double.isNaN(search)) {
+                            assertTrue("isNaN", Double.isNaN(after));
+                        }
+                    }
+                    if (foundAt > 0) {
+                        final double before = array[foundAt - 1];
+                        assertFalse(search <= before);
+                        if (!Double.isNaN(search)) {
+                            assertFalse("isNaN", Double.isNaN(before));
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java b/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java
index f580fb0..9048cbc 100644
--- a/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java
+++ b/core/sis-raster/src/test/java/org/apache/sis/test/suite/RasterTestSuite.java
@@ -33,7 +33,8 @@ import org.junit.BeforeClass;
     org.apache.sis.image.DefaultIteratorTest.class,
     org.apache.sis.coverage.grid.PixelTranslationTest.class,
     org.apache.sis.coverage.grid.GridExtentTest.class,
-    org.apache.sis.coverage.grid.GridGeometryTest.class
+    org.apache.sis.coverage.grid.GridGeometryTest.class,
+    org.apache.sis.coverage.CategoryListTest.class
 })
 public final strictfp class RasterTestSuite extends TestSuite {
     /**
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConstantTransform1D.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConstantTransform1D.java
index b3d1dff..e479c57 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConstantTransform1D.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/ConstantTransform1D.java
@@ -25,6 +25,10 @@ import java.util.Arrays;
  * <code>{@linkplain #offset} = constant</code>. However, this specialized {@code ConstantTransform1D} class is
  * faster.
  *
+ * <p>{@code ConstantTransform1D} behavior differs from {@link LinearTransform1D} behavior is one aspect:
+ * NaN values are ignored and converted to the constant value. By contrast, {@code LinearTransform1D} let
+ * those values to NaN. Overwriting NaN by the constant value is required by {@link org.apache.sis.coverage}.</p>
+ *
  * @author  Martin Desruisseaux (IRD, Geomatys)
  * @version 0.5
  * @since   0.5
diff --git a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/TransferFunction.java b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/TransferFunction.java
index 1f7ddec..8852b00 100644
--- a/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/TransferFunction.java
+++ b/core/sis-referencing/src/main/java/org/apache/sis/referencing/operation/transform/TransferFunction.java
@@ -147,9 +147,11 @@ public class TransferFunction implements Cloneable, Serializable {
      * For other supported types, the default value is 10.
      *
      * @param  base  the new logarithm or exponent base.
+     * @throws IllegalArgumentException if the given base is NaN, negative, zero or infinite.
      */
     public void setBase(final double base) {
         ArgumentChecks.ensureStrictlyPositive("base", base);
+        ArgumentChecks.ensureFinite("base", base);
         this.base = base;
         transform = null;
     }
@@ -168,8 +170,13 @@ public class TransferFunction implements Cloneable, Serializable {
      * The default value is 1.
      *
      * @param  scale  the new scale factor.
+     * @throws IllegalArgumentException if the given scale is NaN, zero or infinite.
      */
     public void setScale(final double scale) {
+        ArgumentChecks.ensureFinite("scale", scale);
+        if (scale == 0) {
+            throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalArgumentValue_2, "scale", scale));
+        }
         this.scale = scale;
         transform = null;
     }
@@ -188,8 +195,10 @@ public class TransferFunction implements Cloneable, Serializable {
      * The default value is 0.
      *
      * @param  offset  the new offset.
+     * @throws IllegalArgumentException if the given scale is NaN or infinite.
      */
     public void setOffset(final double offset) {
+        ArgumentChecks.ensureFinite("offset",  offset);
         this.offset = offset;
         transform = null;
     }
diff --git a/core/sis-utility/src/main/java/org/apache/sis/math/MathFunctions.java b/core/sis-utility/src/main/java/org/apache/sis/math/MathFunctions.java
index 6f07da8..49ae289 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/math/MathFunctions.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/math/MathFunctions.java
@@ -89,7 +89,23 @@ public final class MathFunctions extends Static {
     public static final double LOG10_2 = 0.3010299956639812;
 
     /**
-     * The minimal and maximal ordinal value for {@code NaN} numbers created by {@link #toNanFloat(int)}.
+     * The minimal ordinal value for {@code NaN} numbers created by {@link #toNanFloat(int)}.
+     * The current value is {@value}.
+     *
+     * @since 1.0
+     */
+    public static final int MIN_NAN_ORDINAL = -0x200000;
+
+    /**
+     * The maximal ordinal value for {@code NaN} numbers created by {@link #toNanFloat(int)}.
+     * The current value is {@value}.
+     *
+     * @since 1.0
+     */
+    public static final int MAX_NAN_ORDINAL =  0x1FFFFF;
+
+    /**
+     * The beginning of ranges of quiet NaN values.
      * The range is selected in way to restrict ourselves to <cite>quiet</cite> NaN values.
      * The following is an adaptation of evaluator's comments for bug #4471414
      * (http://developer.java.sun.com/developer/bugParade/bugs/4471414.html):
@@ -119,12 +135,6 @@ public final class MathFunctions extends Static {
      * @see #toNanFloat(int)
      * @see #toNanOrdinal(float)
      */
-    static final int MIN_NAN_ORDINAL = -0x200000,
-                     MAX_NAN_ORDINAL =  0x1FFFFF;
-
-    /**
-     * The beginning of ranges of quiet NaN values.
-     */
     static final int POSITIVE_NAN = 0x7FC00000,
                      NEGATIVE_NAN = 0xFFC00000;
 
@@ -588,7 +598,7 @@ public final class MathFunctions extends Static {
      * and may change in any future version of the SIS library. The current implementation restricts the
      * range of allowed ordinal values to a smaller one than the range of all possible values.</p>
      *
-     * @param  ordinal  the NaN ordinal value, from {@code -0x200000} to {@code 0x1FFFFF} inclusive.
+     * @param  ordinal  the NaN ordinal value, from {@value #MIN_NAN_ORDINAL} to {@value #MAX_NAN_ORDINAL} inclusive.
      * @return one of the legal {@linkplain Float#isNaN(float) NaN} values as a float.
      * @throws IllegalArgumentException if the specified ordinal is out of range.
      *
diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java b/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java
index f20cd2b..7091dd9 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/measure/NumberRange.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.measure;
 
+import java.util.Objects;
 import org.apache.sis.util.Numbers;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.internal.util.Numerics;
@@ -78,7 +79,7 @@ import org.apache.sis.util.collection.WeakHashSet;
  *
  * @author  Martin Desruisseaux (IRD)
  * @author  Jody Garnett (for parameterized type inspiration)
- * @version 0.5
+ * @version 1.0
  *
  * @param <E>  the type of range elements as a subclass of {@link Number}.
  *
@@ -109,6 +110,9 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
      * Consequently if empty ranges were included in the pool, this method would return in some
      * occasions an empty range with different values than the given {@code range} argument.
      * </div>
+     *
+     * We use this method only for caching range of wrapper of primitive types ({@link Byte},
+     * {@link Short}, <i>etc.</i>) because those type are known to be immutable.
      */
     static <E extends Number & Comparable<? super E>, T extends NumberRange<E>> T unique(T range) {
         if (!range.isEmpty()) {
@@ -130,6 +134,7 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     public static NumberRange<Byte> create(final byte minValue, final boolean isMinIncluded,
                                            final byte maxValue, final boolean isMaxIncluded)
     {
+        // No need to check for equality because all bytes values are cached by Byte.valueOf(…).
         return unique(new NumberRange<>(Byte.class,
                 Byte.valueOf(minValue), isMinIncluded,
                 Byte.valueOf(maxValue), isMaxIncluded));
@@ -148,9 +153,9 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     public static NumberRange<Short> create(final short minValue, final boolean isMinIncluded,
                                             final short maxValue, final boolean isMaxIncluded)
     {
-        return unique(new NumberRange<>(Short.class,
-                Short.valueOf(minValue), isMinIncluded,
-                Short.valueOf(maxValue), isMaxIncluded));
+        final Short min = minValue;
+        final Short max = (minValue == maxValue) ? min : maxValue;
+        return unique(new NumberRange<>(Short.class, min, isMinIncluded, max, isMaxIncluded));
     }
 
     /**
@@ -168,9 +173,9 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     public static NumberRange<Integer> create(final int minValue, final boolean isMinIncluded,
                                               final int maxValue, final boolean isMaxIncluded)
     {
-        return unique(new NumberRange<>(Integer.class,
-                Integer.valueOf(minValue), isMinIncluded,
-                Integer.valueOf(maxValue), isMaxIncluded));
+        final Integer min = minValue;
+        final Integer max = (minValue == maxValue) ? min : maxValue;
+        return unique(new NumberRange<>(Integer.class, min, isMinIncluded, max, isMaxIncluded));
     }
 
     /**
@@ -186,9 +191,9 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     public static NumberRange<Long> create(final long minValue, final boolean isMinIncluded,
                                            final long maxValue, final boolean isMaxIncluded)
     {
-        return unique(new NumberRange<>(Long.class,
-                Long.valueOf(minValue), isMinIncluded,
-                Long.valueOf(maxValue), isMaxIncluded));
+        final Long min = minValue;
+        final Long max = (minValue == maxValue) ? min : maxValue;
+        return unique(new NumberRange<>(Long.class, min, isMinIncluded, max, isMaxIncluded));
     }
 
     /**
@@ -252,27 +257,6 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     }
 
     /**
-     * Constructs a range of {@code int} values without upper bound.
-     * This method may return a shared instance, at implementation choice.
-     *
-     * <div class="note"><b>Note:</b> for creating left-bounded ranges of floating point values,
-     * use one of the {@code create(…)} methods with a {@code POSITIVE_INFINITY} constant.
-     * We do not provide variants for other integer types because this method is typically invoked for
-     * defining the {@linkplain org.apache.sis.feature.DefaultFeatureType multiplicity of an attribute}.</div>
-     *
-     * @param  minValue       the minimal value.
-     * @param  isMinIncluded  {@code true} if the minimal value is inclusive, or {@code false} if exclusive.
-     * @return the new range of numeric values from {@code minValue} to positive infinity.
-     *
-     * @see #create(int, boolean, int, boolean)
-     *
-     * @since 0.5
-     */
-    public static NumberRange<Integer> createLeftBounded(final int minValue, final boolean isMinIncluded) {
-        return unique(new NumberRange<>(Integer.class, Integer.valueOf(minValue), isMinIncluded, null, false));
-    }
-
-    /**
      * Constructs a range using the smallest type of {@link Number} that can hold the given values.
      * The given numbers don't need to be of the same type since they will
      * be {@linkplain Numbers#cast(Number, Class) casted} as needed.
@@ -297,6 +281,7 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
      * @param  maxValue       the maximal value, or {@code null} if none.
      * @param  isMaxIncluded  {@code true} if the maximal value is inclusive, or {@code false} if exclusive.
      * @return the new range, or {@code null} if both {@code minValue} and {@code maxValue} are {@code null}.
+     * @throws Illegal­Argument­Exception if the given numbers are not primitive wrappers for numeric types.
      */
     @SuppressWarnings({"rawtypes","unchecked"})
     public static NumberRange<?> createBestFit(final Number minValue, final boolean isMinIncluded,
@@ -304,9 +289,33 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     {
         final Class<? extends Number> type = Numbers.widestClass(
                 Numbers.narrowestClass(minValue), Numbers.narrowestClass(maxValue));
-        return (type == null) ? null : unique(new NumberRange(type,
-                Numbers.cast(minValue, type), isMinIncluded,
-                Numbers.cast(maxValue, type), isMaxIncluded));
+        if (type == null) {
+            return null;
+        }
+        final Number min = Numbers.cast(minValue, type);
+        final Number max = Objects.equals(minValue, maxValue) ? min : Numbers.cast(maxValue, type);
+        return unique(new NumberRange(type, min, isMinIncluded, max, isMaxIncluded));
+    }
+
+    /**
+     * Constructs a range of {@code int} values without upper bound.
+     * This method may return a shared instance, at implementation choice.
+     *
+     * <div class="note"><b>Note:</b> for creating left-bounded ranges of floating point values,
+     * use one of the {@code create(…)} methods with a {@code POSITIVE_INFINITY} constant.
+     * We do not provide variants for other integer types because this method is typically invoked for
+     * defining the {@linkplain org.apache.sis.feature.DefaultFeatureType multiplicity of an attribute}.</div>
+     *
+     * @param  minValue       the minimal value.
+     * @param  isMinIncluded  {@code true} if the minimal value is inclusive, or {@code false} if exclusive.
+     * @return the new range of numeric values from {@code minValue} to positive infinity.
+     *
+     * @see #create(int, boolean, int, boolean)
+     *
+     * @since 0.5
+     */
+    public static NumberRange<Integer> createLeftBounded(final int minValue, final boolean isMinIncluded) {
+        return unique(new NumberRange<>(Integer.class, Integer.valueOf(minValue), isMinIncluded, null, false));
     }
 
     /**
diff --git a/core/sis-utility/src/main/java/org/apache/sis/util/Numbers.java b/core/sis-utility/src/main/java/org/apache/sis/util/Numbers.java
index 8f8571d..cb933b3 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/util/Numbers.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/util/Numbers.java
@@ -147,9 +147,8 @@ public final class Numbers extends Static {
     }
 
     /**
-     * Returns {@code true} if the given {@code type} is a floating point type.
-     * The list of floating point types include primitive and wrapper classes of
-     * {@link Float} and {@link Double}, together with the {@link BigDecimal} class.
+     * Returns {@code true} if the given {@code type} is a floating point type. The floating point types
+     * are {@link Float}, {@code float}, {@link Double}, {@code double} and {@link BigDecimal}.
      *
      * @param  type  the type to test (may be {@code null}).
      * @return {@code true} if {@code type} is one of the known types capable to represent floating point numbers.
@@ -163,8 +162,8 @@ public final class Numbers extends Static {
 
     /**
      * Returns {@code true} if the given {@code type} is an integer type. The integer types are
-     * {@link Long}, {@code long}, {@link Integer}, {@code int}, {@link Short}, {@code short},
-     * {@link Byte}, {@code byte} and {@link BigInteger}.
+     * {@link Byte}, {@code byte}, {@link Short}, {@code short}, {@link Integer}, {@code int},
+     * {@link Long}, {@code long} and {@link BigInteger}.
      *
      * @param  type  the type to test (may be {@code null}).
      * @return {@code true} if {@code type} is an integer type.
diff --git a/ide-project/NetBeans/nbproject/genfiles.properties b/ide-project/NetBeans/nbproject/genfiles.properties
index 441c800..3d72294 100644
--- a/ide-project/NetBeans/nbproject/genfiles.properties
+++ b/ide-project/NetBeans/nbproject/genfiles.properties
@@ -3,6 +3,6 @@
 build.xml.data.CRC32=58e6b21c
 build.xml.script.CRC32=462eaba0
 build.xml.stylesheet.CRC32=28e38971@1.53.1.46
-nbproject/build-impl.xml.data.CRC32=f62bab13
+nbproject/build-impl.xml.data.CRC32=420189c7
 nbproject/build-impl.xml.script.CRC32=a7689f96
 nbproject/build-impl.xml.stylesheet.CRC32=3a2fa800@1.89.1.48
diff --git a/ide-project/NetBeans/nbproject/project.xml b/ide-project/NetBeans/nbproject/project.xml
index 62f4e27..42b2236 100644
--- a/ide-project/NetBeans/nbproject/project.xml
+++ b/ide-project/NetBeans/nbproject/project.xml
@@ -81,6 +81,7 @@
             <word>accessor</word>
             <word>bilevel</word>
             <word>bitmask</word>
+            <word>boolean</word>
             <word>classname</word>
             <word>classnames</word>
             <word>classpath</word>


Mime
View raw message