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: Allow some extrapolations when applying the transfer function provided by SampleDimension. The reason is because the range of values given to Category instances are often only estimations, so we don't want the transfer function to fail because a value is slightly outside the estimated domain.
Date Wed, 23 Jan 2019 19:20:33 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 151acd0  Allow some extrapolations when applying the transfer function provided by SampleDimension. The reason is because the range of values given to Category instances are often only estimations, so we don't want the transfer function to fail because a value is slightly outside the estimated domain.
151acd0 is described below

commit 151acd06c47a73d9829b091d8077163a4ea56473
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Wed Jan 23 20:17:05 2019 +0100

    Allow some extrapolations when applying the transfer function provided by SampleDimension.
    The reason is because the range of values given to Category instances are often only estimations,
    so we don't want the transfer function to fail because a value is slightly outside the estimated domain.
---
 .../java/org/apache/sis/coverage/Category.java     |  84 ++--
 .../java/org/apache/sis/coverage/CategoryList.java | 450 +++++++++++----------
 .../org/apache/sis/coverage/ConvertedCategory.java |   2 +-
 .../org/apache/sis/coverage/SampleDimension.java   |   7 +-
 .../org/apache/sis/coverage/SampleRangeFormat.java |  17 +-
 .../main/java/org/apache/sis/coverage/ToNaN.java   |  11 +-
 .../org/apache/sis/coverage/CategoryListTest.java  |  71 ++--
 .../java/org/apache/sis/coverage/CategoryTest.java |  18 +-
 .../org/apache/sis/internal/util/Numerics.java     |   2 +-
 .../src/main/java/org/apache/sis/math/Vector.java  |   3 +-
 .../org/apache/sis/measure/MeasurementRange.java   |  12 +-
 .../java/org/apache/sis/measure/NumberRange.java   |  29 +-
 .../org/apache/sis/measure/NumberRangeTest.java    |  17 +-
 13 files changed, 390 insertions(+), 333 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 5b81052..29975b6 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
@@ -34,6 +34,8 @@ import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.iso.Types;
 
+import static java.lang.Double.doubleToRawLongBits;
+
 
 /**
  * Describes a sub-range of sample values in a sample dimension.
@@ -75,12 +77,13 @@ public class Category implements Serializable {
     /**
      * Serial number for inter-operability with different versions.
      */
-    private static final long serialVersionUID = 6215962897884256696L;
+    private static final long serialVersionUID = 2630516005075467646L;
 
     /**
-     * Compares {@code Category} objects according their {@link #minimum} value.
+     * Compares {@code Category} objects according their {@link NumberRange#getMinDouble(boolean)} value.
      */
-    static final Comparator<Category> COMPARATOR = (Category c1, Category c2) -> Category.compare(c1.minimum, c2.minimum);
+    static final Comparator<Category> COMPARATOR = (Category c1, Category c2) ->
+            Category.compare(c1.range.getMinDouble(true), c2.range.getMinDouble(true));
 
     /**
      * Compares two {@code double} values. This method is similar to {@link Double#compare(double,double)}
@@ -88,8 +91,8 @@ public class Category implements Serializable {
      */
     static int compare(final double v1, final double v2) {
         if (Double.isNaN(v1) && Double.isNaN(v2)) {
-            final long bits1 = Double.doubleToRawLongBits(v1);
-            final long bits2 = Double.doubleToRawLongBits(v2);
+            final long bits1 = doubleToRawLongBits(v1);
+            final long bits2 = doubleToRawLongBits(v2);
             if (bits1 < bits2) return -1;
             if (bits1 > bits2) return +1;
         }
@@ -104,20 +107,6 @@ public class Category implements Serializable {
     final InternationalString name;
 
     /**
-     * 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 sample values have been converted to real values.
-     */
-    final double minimum, maximum;
-
-    /**
      * The [minimum … maximum] range of values in this category (never {@code null}). Notes:
      *
      * <ul>
@@ -177,8 +166,6 @@ public class Category implements Serializable {
     protected Category(final Category copy) {
         name       = copy.name;
         range      = copy.range;
-        minimum    = copy.minimum;
-        maximum    = copy.maximum;
         toConverse = copy.toConverse;
         if (copy.converse == copy) {
             converse = this;
@@ -203,10 +190,8 @@ public class Category implements Serializable {
      * @param caller  the converse, or {@code null} for {@code this}.
      */
     Category(final Category copy, final Category caller) {
-        name    = copy.name;
-        range   = copy.range;
-        minimum = copy.minimum;
-        maximum = copy.maximum;
+        name  = copy.name;
+        range = copy.range;
         if (caller != null) {
             toConverse = copy.toConverse;
             converse   = caller;
@@ -242,16 +227,16 @@ public class Category implements Serializable {
             ArgumentChecks.ensureNonNull("toUnits", toUnits);
             // The converse is not true: we allow 'units' to be null even if 'toUnits' is non-null.
         }
-        this.name    = Types.toInternationalString(name);
-        this.minimum = samples.getMinDouble(true);
-        this.maximum = samples.getMaxDouble(true);
-        final boolean isNaN = Double.isNaN(minimum);
+        this.name = Types.toInternationalString(name);
+        final double  minimum = samples.getMinDouble(true);
+        final double  maximum = samples.getMaxDouble(true);
+        final boolean isNaN   = Double.isNaN(minimum);
         /*
          * Following arguments check uses '!' in comparison in order to reject NaN values in quantitative category.
          * For qualitative category, NaN is accepted provided that it is the same NaN for both ends of the range.
          */
         if (!(minimum <= maximum)) {
-            if (toUnits != null || !isNaN || Double.doubleToRawLongBits(minimum) != Double.doubleToRawLongBits(maximum)) {
+            if (toUnits != null || !isNaN || doubleToRawLongBits(minimum) != doubleToRawLongBits(maximum)) {
                 throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalCategoryRange_2, name, samples));
             }
         }
@@ -338,13 +323,12 @@ public class Category implements Serializable {
             minIncluded = maxIncluded;
             maxIncluded = tmp;
         }
-        minimum = extremums[minIncluded ? 0 : 2];                                           // Store inclusive values.
-        maximum = extremums[maxIncluded ? 1 : 3];
         if (isQuantitative) {
             range = new ConvertedRange(extremums, minIncluded, maxIncluded, units);
         } else {
+            final double minimum = extremums[minIncluded ? 0 : 2];                          // Take inclusive value.
             final float min = (float) minimum;
-            if (Double.doubleToRawLongBits(minimum) == Double.doubleToRawLongBits(min)) {
+            if (doubleToRawLongBits(minimum) == doubleToRawLongBits(min)) {
                 range = NumberRange.create(Float.class, min);
             } else {
                 range = NumberRange.create(Double.class, minimum);
@@ -436,13 +420,18 @@ public class Category implements Serializable {
      * 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;
+        if (range != null) {                                // Temporarily null during object construction.
+            final Number minimum = range.getMinValue();
+            if (minimum != null && minimum.equals(range.getMaxValue())) {
+                final float f = minimum.floatValue();
+                if (Float.isNaN(f)) {
+                    return "NaN #" + MathFunctions.toNanOrdinal(f);
+                } else {
+                    return minimum;
+                }
+            }
         }
+        return range;
     }
 
     /**
@@ -495,10 +484,19 @@ public class Category implements Serializable {
         }
         if (object != null && getClass().equals(object.getClass())) {
             final Category that = (Category) object;
-            return name.equals(that.name) && range.equals(that.range) &&
-                   Double.doubleToRawLongBits(minimum) == Double.doubleToRawLongBits(that.minimum) &&
-                   Double.doubleToRawLongBits(maximum) == Double.doubleToRawLongBits(that.maximum) &&
-                   toConverse.equals(that.toConverse);
+            if (name.equals(that.name)) {
+                final NumberRange<?> other = that.range;
+                /*
+                 * The NumberRange.equals(Object) comparison is not sufficient because it considers all NaN values as equal.
+                 * For the purpose of Category, we need to distinguish the different NaN values.
+                 */
+                if (range == other || (range.equals(other)
+                        && doubleToRawLongBits(range.getMinDouble()) == doubleToRawLongBits(other.getMinDouble())
+                        && doubleToRawLongBits(range.getMaxDouble()) == doubleToRawLongBits(other.getMaxDouble())))
+                {
+                    return toConverse.equals(that.toConverse);
+                }
+            }
         }
         return false;
     }
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 b9e4e4b..4242da6 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
@@ -37,10 +37,33 @@ 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 #search(double)} method is responsible
- * for finding the right category for an arbitrary sample value.
+ * An immutable list of categories and a <cite>transfer function</cite> implementation backed by that list.
+ * The category list (exposed by the {@link java.util.List} interface) has the following properties:
+ *
+ * <ul>
+ *   <li>Categories are sorted by their sample values.</li>
+ *   <li>Overlapping ranges of sample values are not allowed.</li>
+ *   <li>A {@code CategoryList} can contain a mix of qualitative and quantitative categories.</li>
+ * </ul>
+ *
+ * The transfer function exposed by the {@link MathTransform1D} interface is used only if this list contains
+ * at least 2 categories. More specifically:
+ *
+ * <ul>
+ *   <li>If this list contains 0 category, then the {@linkplain SampleDimension#getTransferFunction() transfer function}
+ *       shall be absent.</li>
+ *   <li>If this list contains 1 category, then the transfer function should be {@linkplain Category#getTransferFunction()
+ *       the function provided by that single category}, without the indirection level implemented by {@code CategoryList}.</li>
+ *   <li>If this list contains 2 or more categories, then the transfer function implementation provided by this
+ *       {@code CategoryList} is necessary for {@linkplain #search(double) searching the category} where belong
+ *       each sample value.</li>
+ * </ul>
+ *
+ * The transfer function allows some extrapolations if a sample values to convert falls in a gap between two categories.
+ * The category immediately below will be used (i.e. its domain is expanded up to the next category), except if one category
+ * is qualitative while the next category is quantitative. In the later case, the quantitative category has precedence.
+ * The reason for allowing some extrapolations is because the range of values given to {@link Category} are often only
+ * estimations, and we don't want the transfer function to fail because a value is slightly outside the estimated domain.
  *
  * <p>Instances of {@link CategoryList} are immutable and thread-safe.</p>
  *
@@ -53,7 +76,7 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     /**
      * Serial number for inter-operability with different versions.
      */
-    private static final long serialVersionUID = 2647846361059903365L;
+    private static final long serialVersionUID = -457688134719705403L;
 
     /**
      * An empty list of categories.
@@ -70,38 +93,40 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     final NumberRange<?> range;
 
     /**
-     * List of {@link Category#minimum} values for each category in {@link #categories}.
-     * This array <strong>must</strong> be in increasing order. Actually, this is the
-     * need to sort this array that determines the element order in {@link #categories}.
+     * List of minimum values (inclusive) for each category in {@link #categories}, in strictly increasing order.
+     * For each category, {@code minimums[i]} is often equal to {@code categories[i].range.getMinDouble(true)} but
+     * may also be lower for filling the gap between a quantitative category and its preceding qualitative category.
+     * We do not store maximum values; range of a category is assumed to span up to the start of the next category.
+     *
+     * <p>This array <strong>must</strong> be in increasing order, with {@link Double#NaN} values last.
+     * This is the need to sort this array that determines the element order in {@link #categories}.</p>
      */
     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}. Qualitative categories with NaN values are last.
+     * order of {@link Category#range} minimum. Qualitative categories with NaN values are last.
      */
     private final Category[] categories;
 
     /**
-     * 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 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>
+     * Minimum and maximum values (inclusive) of {@link Category#converse} for each category.
+     * For each category at index {@code i}, the converse minimum is at index {@code i*2} and
+     * the converse maximum is at index {@code i*2+1}.  This information is used for ensuring
+     * that extrapolated values (i.e. the result of a conversion when the input value was not
+     * in the range of any category) do not accidentally fall in the range of another category.
+     * This field may be {@code null} if there is no need to perform such verification because
+     * there is less than 2 categories bounded by real (non-NaN) values.
      */
-    private final Category extrapolation;
+    private final double[] converseRanges;
 
     /**
-     * The last used category. We assume that this category is the most likely to be requested in the next
-     * {@code transform(…)} method invocation.
-     *
-     * <p>This field is not declared {@code volatile} because we will never assign newly created objects to it.
-     * It will always be a reference to an existing category, and it does not matter if referenced category is
-     * not really the last used one.</p>
+     * Index of the last used category. We assume that this category is the most likely to be
+     * requested in the next {@code transform(…)} method invocation. This field does not need
+     * to be volatile because it is not a problem if a thread see an outdated value; this is
+     * only a hint, and the arrays used with this index are immutable.
      */
-    private transient Category last;
+    private transient int lastUsed;
 
     /**
      * The {@code CategoryList} that describes values after {@linkplain #getTransferFunction() transfer function}
@@ -122,11 +147,11 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
      * The constructor for the {@link #EMPTY} constant.
      */
     private CategoryList() {
-        range         = null;
-        minimums      = ArraysExt.EMPTY_DOUBLE;
-        categories    = new Category[0];
-        extrapolation = null;
-        converse      = this;
+        range          = null;
+        minimums       = ArraysExt.EMPTY_DOUBLE;
+        categories     = new Category[0];
+        converseRanges = null;
+        converse       = this;
     }
 
     /**
@@ -141,6 +166,8 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
      * @throws IllegalArgumentException if two or more categories have overlapping sample value range.
      */
     CategoryList(final Category[] categories, CategoryList converse) {
+        this.categories = categories;
+        final int count = categories.length;
         /*
          * If users specify Category instances themselves, maybe they took existing instances from another
          * sample dimension. A list of "non-converted" categories should not contain any ConvertedCategory
@@ -148,7 +175,7 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
          * converted categories may contain plain Category instances if the conversion is identity.
          */
         if (converse == null) {
-            for (int i=0; i<categories.length; i++) {
+            for (int i=0; i<count; i++) {
                 final Category c = categories[i];
                 if (c instanceof ConvertedCategory) {
                     categories[i] = new Category(c, null);
@@ -156,24 +183,19 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
             }
         }
         Arrays.sort(categories, Category.COMPARATOR);
-        this.categories = categories;
         /*
-         * Constructs the array of Category.minimum values. During the loop, we make sure there is no overlapping ranges.
+         * Constructs the array of minimum values (inclusive). This array shall be in increasing order since
+         * we sorted the categories based on that criterion.  We also collect the minimum and maximum values
+         * expected after conversion, but those values are not necessarily in any order.
          */
+        final double[] extremums;
+        extremums = new double[count << 1];
+        minimums  = new double[count];
+        int countOfFiniteRanges = 0;
         NumberRange<?> range = null;
-        minimums = new double[categories.length];
-        for (int i=categories.length; --i >= 0;) {
+        for (int i=count; --i >= 0;) {                  // Reverse order for making computation of 'range' more convenient.
             final Category category = categories[i];
-            minimums[i] = category.minimum;
-            if (i != 0) {
-                final Category previous = categories[i-1];
-                if (Category.compare(category.minimum, previous.maximum) <= 0) {
-                    throw new IllegalArgumentException(Resources.format(Resources.Keys.CategoryRangeOverlap_4,
-                                previous.name, previous.getRangeLabel(),
-                                category.name, category.getRangeLabel()));
-                }
-            }
-            if (!category.isConvertedQualitative()) {
+            if (!isNaN(minimums[i] = category.range.getMinDouble(true))) {
                 /*
                  * Initialize with the union of ranges at index 0 and index i.  In most cases, the result will cover the whole
                  * range so all future calls to 'range.unionAny(…)' will be no-op.  The 'categories[0].range' field should not
@@ -181,69 +203,107 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
                  */
                 if (range == null) {
                     range = categories[0].range;
+                    assert !isNaN(range.getMinDouble()) : range;
                 }
                 range = range.unionAny(category.range);
             }
+            final int j = i << 1;
+            final NumberRange<?> cr = category.converse.range;
+            if (!isNaN(extremums[j | 1] = cr.getMaxDouble(true)) |
+                !isNaN(extremums[j    ] = cr.getMinDouble(true)))
+            {
+                countOfFiniteRanges++;
+            }
         }
         this.range = range;
+        this.converseRanges = (countOfFiniteRanges > 1) ? extremums : null;
+        assert ArraysExt.isSorted(minimums, false);
         /*
-         * At this point we have two branches:
-         *
-         *   - If we are creating the list of "samples to real values" 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 real value.
-         *
-         *   - If we are creating the list of "real values to samples" 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 (non-NaN) numbers.
+         * Verify that the ranges do not overlap and perform adjustments in 'minimums' values for filling some gaps:
+         * if we find a qualitative category followed by a quantitative category and empty space between them, then
+         * the quantitative category takes that empty space. We do not perform similar check for the opposite side
+         * (quantitative followed by qualitative) because CategoryList does not store maximum values; each category
+         * take all spaces up to the next category.
+         */
+        for (int i=1; i<count; i++) {
+            final Category category = categories[i];
+            final Category previous = categories[i-1];
+            final double   minimum  = minimums[i];
+            if (Category.compare(minimum, previous.range.getMaxDouble(true)) <= 0) {
+                throw new IllegalArgumentException(Resources.format(Resources.Keys.CategoryRangeOverlap_4,
+                            previous.name, previous.getRangeLabel(),
+                            category.name, category.getRangeLabel()));
+            }
+            // No overlapping check for 'converse' ranges here; see next block below.
+            final double limit = previous.range.getMaxDouble(false);
+            if (minimum > limit &&  previous.converse.isConvertedQualitative()      // (a>b) implies that values are not NaN.
+                                && !category.converse.isConvertedQualitative())
+            {
+                minimums[i] = limit;    // Expand the range of quantitative 'category' to the limit of qualitative 'previous'.
+            }
+        }
+        assert ArraysExt.isSorted(minimums, true);
+        /*
+         * If we are creating the list of "samples to real values" conversions, we need to create the list of categories
+         * resulting from conversions to real values. Note that this will indirectly test if some coverted ranges overlap,
+         * since this block invokes recursively this CategoryList constructor with a non-null 'converse' argument. Note
+         * also that converted categories may not be in the same order.
          */
-        Category extrapolation = null;
         if (converse == null) {
-            boolean hasConversion   = false;
-            boolean hasQuantitative = false;
-            final Category[] convertedCategories = new Category[categories.length];
-            for (int i=0; i < convertedCategories.length; i++) {
+            boolean isQualitative = true;
+            boolean isIdentity    = true;
+            final Category[] convertedCategories = new Category[count];
+            for (int i=0; i<count; i++) {
                 final Category category  = categories[i];
                 final Category converted = category.converse;
-                hasConversion   |= (category != converted);
-                hasQuantitative |= !converted.isConvertedQualitative();
                 convertedCategories[i] = converted;
+                isQualitative &= converted.isConvertedQualitative();
+                isIdentity    &= (category == converted);
             }
-            if (hasQuantitative) {
-                converse = hasConversion ? new CategoryList(convertedCategories, this) : this;
-            } else {
+            if (isQualitative) {
                 converse = EMPTY;
-            }
-        } else {
-            for (int i=categories.length; --i >= 0;) {
-                final Category category = categories[i];
-                if (!isNaN(category.maximum)) {
-                    extrapolation = category;
-                    break;
+            } else if (isIdentity) {
+                converse = this;
+            } else {
+                converse = new CategoryList(convertedCategories, this);
+                if (converseRanges != null) {
+                    /*
+                     * For "samples to real values" conversion (only that direction, not the converse) and only if there
+                     * is two or more quantitative categories (should be very rare), adjust the converted maximum values
+                     * for filling gaps between converted categories.
+                     */
+                    for (int i=1; i<converseRanges.length; i+=2) {
+                        final double maximum = converseRanges[i];
+                        final int p = ~Arrays.binarySearch(converse.minimums, maximum);
+                        if (p >= 0 && p < count) {
+                            double limit = Math.nextDown(converse.minimums[p]);     // Minimum value of next category - ε
+                            if (isNaN(limit)) limit = Double.POSITIVE_INFINITY;     // Because NaN are last, no higher values.
+                            if (limit > maximum) converseRanges[i] = limit;         // Expand this category to fill the gap.
+                            if (p == 1) {
+                                converseRanges[i-1] = Double.NEGATIVE_INFINITY;     // Consistent with converse.minimums[0] = −∞
+                            }
+                        } else if (p == count) {
+                            converseRanges[i] = Double.POSITIVE_INFINITY;           // No higher category; take all the space.
+                        }
+                    }
                 }
             }
         }
-        this.extrapolation = extrapolation;
-        this.converse      = converse;
-        if (categories.length != 0) {
-            last = categories[0];
+        this.converse = converse;
+        if (count != 0 && !isNaN(minimums[0])) {
+            minimums[0] = Double.NEGATIVE_INFINITY;
         }
     }
 
     /**
-     * Computes transient fields and potentially returns a shared instance.
+     * Returns a shared instance if applicable.
      *
      * @return the object to use after deserialization.
      * @throws ObjectStreamException if the serialized object contains invalid data.
      */
     @SuppressWarnings("ReturnOfCollectionOrArrayField")
     private Object readResolve() throws ObjectStreamException {
-        if (categories.length == 0) {
-            return EMPTY;
-        } else {
-            last = categories[0];
-            return this;
-        }
+        return (categories.length == 0) ? EMPTY : this;
     }
 
     /**
@@ -265,49 +325,68 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
-     * Performs a bi-linear search of the specified value. This method is similar to
-     * {@link Arrays#binarySearch(double[],double)} except that it can differentiate
-     * the various NaN values.
+     * Performs a bi-linear search of the specified value in the given sorted array. If an exact match is found,
+     * its index is returned. If no exact match is found, index of the highest value smaller than {@code sample}
+     * is returned. If no such index exists, -1 is returned. Said otherwise, if the return value is positive and
+     * the given array is {@link #minimums}, then this method returns the index in the {@link #categories} array
+     * of the {@link Category} to use for a given sample value.
+     *
+     * <p>This method differs from {@link Arrays#binarySearch(double[],double)} in the following aspects:</p>
+     * <ul>
+     *   <li>If differentiates the various NaN values.</li>
+     *   <li>It does not differentiate exact matches from insertion points.</li>
+     * </ul>
+     *
+     * @param  minimums  {@link #minimums}.
+     * @param  sample    the sample value to search.
+     * @return index of the category to use, or -1 if none.
      */
-    static int binarySearch(final double[] array, final double key) {
+    static int binarySearch(final double[] minimums, final double sample) {
         int low  = 0;
-        int high = array.length - 1;
-        final boolean keyIsNaN = isNaN(key);
+        int high = minimums.length - 1;
+        final boolean sampleIsNaN = isNaN(sample);
         while (low <= high) {
             final int mid = (low + high) >>> 1;
-            final double midVal = array[mid];
-            if (midVal < key) {                         // Neither value is NaN, midVal is smaller.
+            final double midVal = minimums[mid];
+            if (midVal < sample) {                      // Neither value is NaN, midVal is smaller.
                 low = mid + 1;
                 continue;
             }
-            if (midVal > key) {                         // Neither value is NaN, midVal is larger.
+            if (midVal > sample) {                      // Neither value is NaN, midVal is larger.
                 high = mid - 1;
                 continue;
             }
             final long midRawBits = doubleToRawLongBits(midVal);
-            final long keyRawBits = doubleToRawLongBits(key);
-            if (midRawBits == keyRawBits) {
-                return mid;                             // Key found.
+            final long smpRawBits = doubleToRawLongBits(sample);
+            if (midRawBits == smpRawBits) {
+                return mid;                             // Exact match found.
             }
             final boolean midIsNaN = isNaN(midVal);
             final boolean adjustLow;
-            if (keyIsNaN) {
+            if (sampleIsNaN) {
                 /*
-                 * If (mid,key)==(!NaN, NaN): mid is lower.
+                 * If (mid,sample)==(!NaN, NaN): mid is lower.
                  * If two NaN arguments, compare NaN bits.
                  */
-                adjustLow = (!midIsNaN || midRawBits < keyRawBits);
+                adjustLow = (!midIsNaN || midRawBits < smpRawBits);
             } else {
                 /*
-                 * If (mid,key)==(NaN, !NaN): mid is greater.
+                 * If (mid,sample)==(NaN, !NaN): mid is greater.
                  * Otherwise, case for (-0.0, 0.0) and (0.0, -0.0).
                  */
-                adjustLow = (!midIsNaN && midRawBits < keyRawBits);
+                adjustLow = (!midIsNaN && midRawBits < smpRawBits);
             }
             if (adjustLow) low = mid + 1;
             else          high = mid - 1;
         }
-        return ~low;                                    // key not found.
+        /*
+         * If we reach this point and the sample is NaN, then it is not one of the NaN values known
+         * to CategoryList constructor and can not be mapped to a category.  Otherwise we 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).
+         */
+        return sampleIsNaN ? -1 : low - 1;
     }
 
     /**
@@ -318,67 +397,8 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
      * @return the category of the supplied value, or {@code null}.
      */
     final Category search(final double sample) {
-        /*
-         * Search which category contains the given value.
-         * Note: NaN values are at the end of 'minimums' array, so:
-         *
-         * 1) if 'value' is NaN, then 'i' will be the index of a NaN category.
-         * 2) if 'value' is a real number, then 'i' may be the index of a category
-         *    of real numbers or the first category containing NaN values.
-         */
-        int i = binarySearch(minimums, sample);                             // Special 'binarySearch' for NaN
-        if (i >= 0) {
-            assert doubleToRawLongBits(sample) == doubleToRawLongBits(minimums[i]);
-            return categories[i];
-        }
-        /*
-         * 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 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) {
-            final Category category = categories[i];
-            assert sample > category.minimum : sample;
-            if (sample <= category.maximum) {
-                return category;
-            }
-            /*
-             * 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 next = categories[i];
-                    assert !(next.minimum <= sample) : sample;         // '!' for accepting NaN.
-                    return (next.minimum - sample < sample - category.maximum) ? next : category;
-                }
-                return extrapolation;
-            }
-        } else if (extrapolation != null) {
-            /*
-             * If the value is smaller than the smallest Category.minimum, returns
-             * the first category (except if there is only qualitative categories).
-             */
-            if (categories.length != 0) {
-                final Category category = categories[0];
-                if (!isNaN(category.minimum)) {
-                    return category;
-                }
-            }
-        }
-        return null;
+        final int i = binarySearch(minimums, sample);
+        return (i >= 0) ? categories[i] : null;
     }
 
     /**
@@ -405,38 +425,26 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
          * 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.
          */
-        Category category = last;
+        int index = lastUsed;
         double value = Double.NaN;
         for (int peekOff = srcOff; /* numPts >= 0 */; peekOff += direction) {
-            final double minimum = category.minimum;
-            final double maximum = category.maximum;
+            final double minimum = minimums[index];
+            final double limit = (index+1 < minimums.length) ? minimums[index+1] : Double.NaN;
             final long   rawBits = doubleToRawLongBits(minimum);
             while (--numPts >= 0) {
                 value = (srcFloat != null) ? srcFloat[peekOff] : srcPts[peekOff];
                 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;
+                    if (value >= limit) {
+                        break;                                      // Category has changed; stop the search.
                     }
-                } else if (doubleToRawLongBits(value) != rawBits &&
-                        (isNaN(value) || extrapolation == null || category != categories[0]))
-                {
-                    /*
-                     * 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;
+                } else if (doubleToRawLongBits(value) != rawBits) {
+                    break;                                          // Not the expected NaN value.
                 }
                 peekOff += direction;
             }
             /*
              * 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.
+             * the conversion on many values in a single 'transform' method call.
              */
             int count = peekOff - srcOff;                       // May be negative if we are going backward.
             if (count < 0) {
@@ -444,7 +452,7 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
                 srcOff -= count - 1;
             }
             final int stepOff = srcOff + srcToDst;
-            final MathTransform1D piece = category.toConverse;
+            final MathTransform1D piece = categories[index].toConverse;
             if (srcFloat != null) {
                 if (dstFloat != null) {
                     piece.transform(srcFloat, srcOff, dstFloat, stepOff, count);
@@ -459,15 +467,14 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
                 }
             }
             /*
-             * If extrapolation may have happened, verify that transformed values are in expected ranges.
-             * Values out of range will be clamped.
+             * If we need safety against extrapolations (for avoiding that a value falls in the range of another category),
+             * verify that transformed values are in expected ranges. Values out of range will be clamped.
              */
-            if (extrapolation != null) {
+            if (converseRanges != null) {
                 dstOff = srcOff + srcToDst;
-                final Category converse = category.converse;
-                if (dstFloat != null) {                                 // Loop for the 'float' version.
-                    final float min = (float) converse.minimum;
-                    final float max = (float) converse.maximum;
+                if (dstFloat != null) {                                             // Loop for the 'float' version.
+                    final float min = (float) converseRanges[(index << 1)    ];
+                    final float max = (float) converseRanges[(index << 1) | 1];
                     while (--count >= 0) {
                         final float check = dstFloat[dstOff];
                         if (check < min) {
@@ -477,9 +484,9 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
                         }
                         dstOff++;
                     }
-                } else {                                                // Loop for the 'double' version.
-                    final double min = converse.minimum;
-                    final double max = converse.maximum;
+                } else {                                                            // Loop for the 'double' version.
+                    final double min = converseRanges[(index << 1)    ];
+                    final double max = converseRanges[(index << 1) | 1];
                     while (--count >= 0) {
                         final double check = dstPts[dstOff];
                         if (check < min) {
@@ -497,17 +504,18 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
              * category for the next points.
              */
             if (numPts < 0) break;
-            category = search(value);
-            if (category == null) {
+            index = binarySearch(minimums, value);
+            if (index < 0) {
                 throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
             }
             srcOff = peekOff;
         }
-        last = category;
+        lastUsed = index;
     }
 
     /**
-     * Transforms a list of coordinate point ordinal values.
+     * Transforms a list of coordinate point ordinal values. This method can be invoked only if {@link #categories}
+     * contains at least two elements, otherwise a {@code MathTransform} implementation from another package is used.
      */
     @Override
     public final void transform(double[] srcPts, int srcOff, double[] dstPts, int dstOff, int numPts) throws TransformException {
@@ -515,7 +523,8 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
-     * Transforms a list of coordinate point ordinal values.
+     * Transforms a list of coordinate point ordinal values. This method can be invoked only if {@link #categories}
+     * contains at least two elements, otherwise a {@code MathTransform} implementation from another package is used.
      */
     @Override
     public final void transform(float[] srcPts, int srcOff, float[] dstPts, int dstOff, int numPts) throws TransformException {
@@ -523,7 +532,8 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
-     * Transforms a list of coordinate point ordinal values.
+     * Transforms a list of coordinate point ordinal values. This method can be invoked only if {@link #categories}
+     * contains at least two elements, otherwise a {@code MathTransform} implementation from another package is used.
      */
     @Override
     public final void transform(float[] srcPts, int srcOff, double[] dstPts, int dstOff, int numPts) throws TransformException {
@@ -531,7 +541,8 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
-     * Transforms a list of coordinate point ordinal values.
+     * Transforms a list of coordinate point ordinal values. This method can be invoked only if {@link #categories}
+     * contains at least two elements, otherwise a {@code MathTransform} implementation from another package is used.
      */
     @Override
     public final void transform(double[] srcPts, int srcOff, float[] dstPts, int dstOff, int numPts) throws TransformException {
@@ -539,7 +550,8 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
     }
 
     /**
-     * Transforms the specified value.
+     * Transforms the specified value. This method can be invoked only if {@link #categories} contains at
+     * least two elements, otherwise a {@code MathTransform} implementation from another package is used.
      *
      * @param  value  the value to transform.
      * @return the transformed value.
@@ -547,28 +559,29 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
      */
     @Override
     public final double transform(double value) throws TransformException {
-        Category category = last;
-        if (!(value >= category.minimum  &&  value <= category.maximum) &&
-             doubleToRawLongBits(value) != doubleToRawLongBits(category.minimum))
+        int index = lastUsed;
+        final double minimum = minimums[index];
+        if (value >= minimum ? (index+1 < minimums.length && value >= minimums[index+1])
+                             : doubleToRawLongBits(value) != doubleToRawLongBits(minimum))
         {
-            category = search(value);
-            if (category == null) {
+            index = binarySearch(minimums, value);
+            if (index < 0) {
                 throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
             }
-            last = category;
+            lastUsed = index;
         }
-        value = category.toConverse.transform(value);
-        if (extrapolation != null) {
+        value = categories[index].toConverse.transform(value);
+        if (converseRanges != null) {
             double bound;
-            if (value < (bound = category.converse.minimum)) return bound;
-            if (value > (bound = category.converse.maximum)) return bound;
+            if (value < (bound = converseRanges[(index << 1)    ])) return bound;
+            if (value > (bound = converseRanges[(index << 1) | 1])) return bound;
         }
-        assert category == converse.search(value).converse : category;
         return value;
     }
 
     /**
-     * Gets the derivative of this function at a value.
+     * Gets the derivative of this function at a value. This method can be invoked only if {@link #categories}
+     * contains at least two elements, otherwise a {@code MathTransform} implementation from another package is used.
      *
      * @param  value  the value where to evaluate the derivative.
      * @return the derivative at the specified point.
@@ -576,17 +589,18 @@ final class CategoryList extends AbstractList<Category> implements MathTransform
      */
     @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))
+        int index = lastUsed;
+        final double minimum = minimums[index];
+        if (value >= minimum ? (index+1 < minimums.length && value >= minimums[index+1])
+                             : doubleToRawLongBits(value) != doubleToRawLongBits(minimum))
         {
-            category = search(value);
-            if (category == null) {
+            index = binarySearch(minimums, value);
+            if (index < 0) {
                 throw new TransformException(Resources.format(Resources.Keys.NoCategoryForValue_1, value));
             }
-            last = category;
+            lastUsed = index;
         }
-        return category.toConverse.derivative(value);
+        return categories[index].toConverse.derivative(value);
     }
 
     /**
diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
index fcfed80..16b91cc 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/ConvertedCategory.java
@@ -36,7 +36,7 @@ final class ConvertedCategory extends Category {
     /**
      * Serial number for inter-operability with different versions.
      */
-    private static final long serialVersionUID = -7164422654831370784L;
+    private static final long serialVersionUID = 336103757882427857L;
 
     /**
      * Creates a category storing the inverse of the "sample to real values" transfer function. The {@link #toConverse}
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 68c0505..b1622ff 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
@@ -79,7 +79,7 @@ public class SampleDimension implements Serializable {
     /**
      * Serial number for inter-operability with different versions.
      */
-    private static final long serialVersionUID = 6026936545776852758L;
+    private static final long serialVersionUID = -4966135180995819364L;
 
     /**
      * Identification for this sample dimension. Typically used as a way to perform a band select by
@@ -646,7 +646,7 @@ public class SampleDimension implements Serializable {
                 name = Vocabulary.formatInternational(Vocabulary.Keys.FillValue);
             }
             final NumberRange<?> samples = range(sample.getClass(), sample, sample);
-            // Use of 'getMinValue()' below shall be consistent with this.remove(…).
+            // Use of 'getMinValue()' below shall be consistent with ToNaN.remove(Category).
             toNaN.background = samples.getMinValue();
             add(new Category(name, samples, null, null, toNaN));
             return this;
@@ -963,8 +963,7 @@ public class SampleDimension implements Serializable {
                     final Category c = categories[i];
                     System.arraycopy(categories, i+1, categories, i, --count - i);
                     categories[count] = null;
-                    // Use of 'c.minimum' shall be consistent with 'this.setBackground(…)'.
-                    toNaN.remove(c.minimum, c.converse.minimum);
+                    toNaN.remove(c);
                     return c;
                 }
             };
diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleRangeFormat.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleRangeFormat.java
index ccf4712..1a7865e 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleRangeFormat.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/SampleRangeFormat.java
@@ -98,16 +98,21 @@ final class SampleRangeFormat extends RangeFormat {
         for (int i=0; i<count; i++) {
             int ndigits = 0;
             for (final Category category : dimensions[i].getCategories()) {
-                final Category converted = category.converted();
-                final boolean  isPacked  = (Double.doubleToRawLongBits(category.minimum) != Double.doubleToRawLongBits(converted.minimum))
-                                         | (Double.doubleToRawLongBits(category.maximum) != Double.doubleToRawLongBits(converted.maximum));
+                final NumberRange<?> sr = category.getSampleRange();
+                final NumberRange<?> cr = category.converted().range;
+                final double  smin = sr.getMinDouble(true);
+                final double  smax = sr.getMaxDouble(false);
+                final double  cmin = cr.getMinDouble(true);
+                final double  cmax = cr.getMaxDouble(false);
+                final boolean isPacked = (Double.doubleToRawLongBits(smin) != Double.doubleToRawLongBits(cmin))
+                                       | (Double.doubleToRawLongBits(smax) != Double.doubleToRawLongBits(cmax));
                 hasPackedValues |= isPacked;
                 /*
                  * If the sample values are already real values, pretend that they are packed in bytes.
                  * The intent is only to compute an arbitrary number of fraction digits.
                  */
-                final double range = isPacked ? ( category.maximum -  category.minimum) : 255;
-                final double increment =        (converted.maximum - converted.minimum) / range;
+                final double range = isPacked ? (smax - smin) : 256;
+                final double increment =        (cmax - cmin) / range;
                 if (!Double.isNaN(increment)) {
                     hasQuantitative = true;
                     final int n = -Numerics.toExp10(Math.getExponent(increment));
@@ -232,7 +237,7 @@ final class SampleRangeFormat extends RangeFormat {
                     table.append(text);
                     table.nextColumn();
                 }
-                table.append(category.name.toString(getLocale()));
+                table.append(category.getName().toString(getLocale()));
                 table.nextLine();
             }
         }
diff --git a/core/sis-raster/src/main/java/org/apache/sis/coverage/ToNaN.java b/core/sis-raster/src/main/java/org/apache/sis/coverage/ToNaN.java
index ea274e8..b27980d 100644
--- a/core/sis-raster/src/main/java/org/apache/sis/coverage/ToNaN.java
+++ b/core/sis-raster/src/main/java/org/apache/sis/coverage/ToNaN.java
@@ -112,12 +112,13 @@ search: if (!add(ordinal)) {
      * This method does nothing if {@code converted} is not a NaN value, i.e. if
      * the category is quantitative instead than qualitative.
      *
-     * @param  value      the real value of the presumed qualitative category.
-     * @param  converted  the converted value, which should be one of NaN values.
+     * @param  c  the presumed qualitative category.
      */
-    void remove(final double value, final double converted) {
-        if (Double.isNaN(converted) && super.remove(MathFunctions.toNanOrdinal((float) converted))) {
-            if (isBackground(value)) {
+    void remove(final Category c) {
+        final float converted = (float) c.converse.range.getMinDouble();
+        if (Float.isNaN(converted) && super.remove(MathFunctions.toNanOrdinal(converted))) {
+            // Use of 'c.getMinDouble()' shall be consistent with 'SampleDimension.Builder.setBackground(…)'.
+            if (isBackground(c.range.getMinDouble())) {
                 background = null;
             }
         }
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
index 2e8bc25..5cc8347 100644
--- 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
@@ -52,9 +52,10 @@ public final strictfp class CategoryListTest extends TestCase {
         for (int i=1; i<size; i++) {
             final Category current  = categories.get(i  );
             final Category previous = categories.get(i-1);
-            assertFalse( current.minimum >  current.maximum);
-            assertFalse(previous.minimum > previous.maximum);
-            assertFalse(Category.compare(previous.maximum, current.minimum) > 0);
+            assertFalse( current.range.getMinDouble(true) >  current.range.getMaxDouble(true));
+            assertFalse(previous.range.getMinDouble(true) > previous.range.getMaxDouble(true));
+            assertFalse(Category.compare(previous.range.getMaxDouble(true),
+                                          current.range.getMinDouble(true)) > 0);
         }
     }
 
@@ -111,12 +112,14 @@ public final strictfp class CategoryListTest extends TestCase {
             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));
+                int expected = Arrays.binarySearch(array, searchFor);
+                if (expected < 0) expected = ~expected - 1;
+                assertEquals("binarySearch", expected, 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.
+             * Previous test didn't tested NaN values, which is the main difference between Arrays.binarySearch(…) and
+             * CategoryList.binarySearch(…). Now test those NaNs. We fill the last half of the array with NaN values;
+             * the first half keep original real values. Then we search sometime real values, sometime NaN values.
              */
             int nanOrdinalLimit = 0;
             realNumberLimit /= 2;
@@ -132,26 +135,29 @@ public final strictfp class CategoryListTest extends TestCase {
                 } 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];
+                /*
+                 * At this point, 'search' is a real value or a NaN value to search.
+                 */
+                final int foundAt = CategoryList.binarySearch(array, search);
+                if (foundAt < 0) {
+                    // Expected only if the value to search is NaN or less than all values in the array.
+                    assertFalse(search >= array[0]);
+                } else if (foundAt >= array.length) {
+                    // Expected only if the value to search is NaN or greater than all values in the array.
+                    assertFalse(search <= array[array.length - 1]);
+                } else if (Double.doubleToRawLongBits(array[foundAt]) != Double.doubleToRawLongBits(search)) {
+                    final double before = array[foundAt];
+                    assertFalse(search <= before);
+                    if (!Double.isNaN(search)) {
+                        assertFalse("isNaN", Double.isNaN(before));
+                    }
+                    if (foundAt + 1 < array.length) {
+                        final double after = array[foundAt + 1];
                         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));
-                        }
-                    }
                 }
             }
         }
@@ -172,7 +178,7 @@ public final strictfp class CategoryListTest extends TestCase {
     }
 
     /**
-     * Tests the sample values range and converged values range after construction of a list of categories.
+     * Tests the sample values range and converted values range after construction of a list of categories.
      */
     @Test
     public void testRanges() {
@@ -219,20 +225,21 @@ public final strictfp class CategoryListTest extends TestCase {
         assertSame("100", categories[4].converse, list.converse.search(  /* transform(100) */   -97 ));
         assertSame("110", categories[4].converse, list.converse.search(  /* transform(110) */  -107 ));
         /*
-         * Checks values outside the range of any category. For direct conversion, no category shall be returned.
-         * For inverse conversion, the nearest category shall be returned.
+         * Checks values outside the range of any category.  The category below requested value has its
+         * domain expanded up to the next category, except if one category is qualitative and the other
+         * one is quantitative, in which case the quantitative category has precedence.
          */
-        assertNull( "-1",                         list.search( -1));
-        assertNull(  "2",                         list.search(  2));
-        assertNull(  "4",                         list.search(  4));
-        assertNull(  "9",                         list.search(  9));
-        assertNull("120",                         list.search(120));
-        assertNull("200",                         list.search(200));
+        assertSame( "-1", categories[0],          list.search( -1));
+        assertSame(  "2", categories[0],          list.search(  2));
+        assertSame(  "4", categories[2],          list.search(  4));
+        assertSame(  "9", categories[3],          list.search(  9));
+        assertSame("120", categories[4],          list.search(120));
+        assertSame("200", categories[4],          list.search(200));
         assertNull( "-1",                         list.converse.search(MathFunctions.toNanFloat(-1)));    // Nearest sample is 0
         assertNull(  "2",                         list.converse.search(MathFunctions.toNanFloat( 2)));    // Nearest sample is 3
         assertNull(  "4",                         list.converse.search(MathFunctions.toNanFloat( 4)));    // Nearest sample is 3
         assertNull(  "9",                         list.converse.search(MathFunctions.toNanFloat( 9)));    // Nearest sample is 10
-        assertSame(  "9", categories[3].converse, list.converse.search( /* transform(  9) */   5.9 ));    // Nearest sample is 10
+        assertSame(  "9", categories[4].converse, list.converse.search( /* transform(  9) */   5.9 ));    // Nearest sample is 10
         assertSame("120", categories[4].converse, list.converse.search( /* transform(120) */  -117 ));    // Nearest sample is 119
         assertSame("200", categories[4].converse, list.converse.search( /* transform(200) */  -197 ));    // Nearest sample is 119
     }
diff --git a/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java b/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java
index b0056f3..3e789e9 100644
--- a/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java
+++ b/core/sis-raster/src/test/java/org/apache/sis/coverage/CategoryTest.java
@@ -94,8 +94,6 @@ public final strictfp class CategoryTest extends TestCase {
              */
             assertEquals     ("name",           "Random",       String.valueOf(category.name));
             assertEquals     ("name",           "Random",       String.valueOf(category.getName()));
-            assertEquals     ("minimum",        sample,         category.minimum, STRICT);
-            assertEquals     ("maximum",        sample,         category.maximum, STRICT);
             assertBoundEquals("range.minValue", sample,         category.range.getMinValue());
             assertBoundEquals("range.maxValue", sample,         category.range.getMaxValue());
             assertSame       ("sampleRange",    category.range, category.getSampleRange());
@@ -114,8 +112,6 @@ public final strictfp class CategoryTest extends TestCase {
             assertSame   ("converted",          converse, converse.converted());
             assertEquals ("name",               "Random", String.valueOf(converse.name));
             assertEquals ("name",               "Random", String.valueOf(converse.getName()));
-            assertTrue   ("minimum",                      Double.isNaN(converse.minimum));
-            assertTrue   ("maximum",                      Double.isNaN(converse.maximum));
             assertNaN    ("range",                        converse.range);
             assertNotNull("sampleRange",                  converse.getSampleRange());
             assertFalse  ("measurementRange",             category.getMeasurementRange().isPresent());
@@ -168,14 +164,12 @@ public final strictfp class CategoryTest extends TestCase {
             assertEquals     ("name",               "Random",            String.valueOf(converse.name));
             assertEquals     ("name",               "Random",            String.valueOf(category.getName()));
             assertEquals     ("name",               "Random",            String.valueOf(converse.getName()));
-            assertEquals     ("minimum",            lower,               category.minimum, STRICT);
-            assertEquals     ("maximum",            upper,               category.maximum, STRICT);
-            assertEquals     ("minimum",            lower*scale+offset,  converse.minimum, EPS);
-            assertEquals     ("maximum",            upper*scale+offset,  converse.maximum, EPS);
+            assertEquals     ("minimum",            lower,               category.range.getMinDouble(true), STRICT);
+            assertEquals     ("maximum",            upper,               category.range.getMaxDouble(true), STRICT);
+            assertEquals     ("minimum",            lower*scale+offset,  converse.range.getMinDouble(true), EPS);
+            assertEquals     ("maximum",            upper*scale+offset,  converse.range.getMaxDouble(true), EPS);
             assertBoundEquals("range.minValue",     lower,               category.range.getMinValue());
             assertBoundEquals("range.maxValue",     upper,               category.range.getMaxValue());
-            assertBoundEquals("range.minValue",     converse.minimum,    converse.range.getMinValue());
-            assertBoundEquals("range.maxValue",     converse.maximum,    converse.range.getMaxValue());
             assertSame       ("sampleRange",        category.range,      category.getSampleRange());
             assertSame       ("sampleRange",        converse.range,      converse.getSampleRange());
             assertSame       ("measurementRange",   converse.range,      category.getMeasurementRange().get());
@@ -216,8 +210,6 @@ public final strictfp class CategoryTest extends TestCase {
             assertSame       ("converse",           category,            category.converse);
             assertEquals     ("name",               "Random",            String.valueOf(category.name));
             assertEquals     ("name",               "Random",            String.valueOf(category.getName()));
-            assertEquals     ("minimum",            lower,               category.minimum, STRICT);
-            assertEquals     ("maximum",            upper,               category.maximum, STRICT);
             assertBoundEquals("range.minValue",     lower,               category.range.getMinValue());
             assertBoundEquals("range.maxValue",     upper,               category.range.getMaxValue());
             assertSame       ("sampleRange",        category.range,      category.getSampleRange());
@@ -238,8 +230,6 @@ public final strictfp class CategoryTest extends TestCase {
         assertSame  ("converse",       category,   category.converse);
         assertEquals("name",           "NaN",      String.valueOf(category.name));
         assertEquals("name",           "NaN",      String.valueOf(category.getName()));
-        assertEquals("minimum",        Double.NaN, category.minimum, STRICT);
-        assertEquals("maximum",        Double.NaN, category.maximum, STRICT);
         assertNaN   ("sampleRange",                category.range);
         assertEquals("range.minValue", Float.NaN,  range.getMinValue());
         assertEquals("range.maxValue", Float.NaN,  range.getMaxValue());
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
index be52a51..fe51d38 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/util/Numerics.java
@@ -58,7 +58,7 @@ public final class Numerics extends Static {
         cache( 360);
         cache(1000);
         cache(Double.POSITIVE_INFINITY);
-        cache(Double.NaN);
+        // Do not cache NaN values because Double.equals(Object) consider all NaN as equal.
     }
 
     /**
diff --git a/core/sis-utility/src/main/java/org/apache/sis/math/Vector.java b/core/sis-utility/src/main/java/org/apache/sis/math/Vector.java
index 2a6ff93..9d1651d 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/math/Vector.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/math/Vector.java
@@ -32,7 +32,6 @@ import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.util.resources.Errors;
 import org.apache.sis.internal.system.Loggers;
-import org.apache.sis.internal.util.Numerics;
 
 import static org.apache.sis.util.ArgumentChecks.ensureValidIndex;
 
@@ -1236,7 +1235,7 @@ search:     for (;;) {
          */
         int i = 0;
         do if (i >= length) {
-            final Double NaN = Numerics.valueOf(Double.NaN);
+            final Double NaN = Double.NaN;
             return createSequence(getElementType(), NaN, NaN, length);
         } while (isNaN(i++));
         /*
diff --git a/core/sis-utility/src/main/java/org/apache/sis/measure/MeasurementRange.java b/core/sis-utility/src/main/java/org/apache/sis/measure/MeasurementRange.java
index 59d03c5..969d6fd 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/measure/MeasurementRange.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/measure/MeasurementRange.java
@@ -151,14 +151,18 @@ public class MeasurementRange<E extends Number & Comparable<? super E>> extends
     public static MeasurementRange<?> createBestFit(final Number minValue, final boolean isMinIncluded,
             final Number maxValue, final boolean isMaxIncluded, final Unit<?> unit)
     {
-        final Class<? extends Number> type = Numbers.widestClass(
-                Numbers.narrowestClass(minValue), Numbers.narrowestClass(maxValue));
+        final Class<? extends Number> type = Numbers.widestClass(Numbers.narrowestClass(minValue),
+                                                                 Numbers.narrowestClass(maxValue));
         if (type == null) {
             return null;
         }
-        return unique(new MeasurementRange(type,
+        MeasurementRange range = new MeasurementRange(type,
                 Numbers.cast(minValue, type), isMinIncluded,
-                Numbers.cast(maxValue, type), isMaxIncluded, unit));
+                Numbers.cast(maxValue, type), isMaxIncluded, unit);
+        if (!isOtherNaN(minValue) && !isOtherNaN(maxValue)) {
+            range = unique(range);
+        }
+        return 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 1aad501..ac117d1 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
@@ -122,6 +122,23 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
     }
 
     /**
+     * Returns {@code true} if the given value is a NaN value other than the canonical {@link Float#NaN}
+     * or {@link Double#NaN} value. This is used for determining if the range should be omitted from the
+     * {@link POOL} cache, since {@link #equals(Object)} considers all NaN values as equal.
+     */
+    static boolean isOtherNaN(final Number n) {
+        if (n instanceof Double) {
+            final double value = (Double) n;
+            return Double.isNaN(value) && Double.doubleToRawLongBits(value) != 0x7ff8000000000000L;
+        } else if (n instanceof Float) {
+            final float value = (Float) n;
+            return Float.isNaN(value) && Float.floatToRawIntBits(value) != 0x7fc00000;
+        } else {
+            return false;
+        }
+    }
+
+    /**
      * Constructs a range containing a single value of the given type.
      * The given value is used as the minimum and maximum values, inclusive.
      *
@@ -134,7 +151,11 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
      * @since 1.0
      */
     public static <N extends Number & Comparable<? super N>> NumberRange<N> create(final Class<N> type, final N value) {
-        return unique(new NumberRange<>(type, value, true, value, true));
+        NumberRange<N> range = new NumberRange<>(type, value, true, value, true);
+        if (!isOtherNaN(value)) {
+            range = unique(range);
+        }
+        return range;
     }
 
     /**
@@ -310,7 +331,11 @@ public class NumberRange<E extends Number & Comparable<? super E>> extends Range
         }
         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));
+        NumberRange range = new NumberRange(type, min, isMinIncluded, max, isMaxIncluded);
+        if (!isOtherNaN(min) && !isOtherNaN(max)) {
+            range = unique(range);
+        }
+        return range;
     }
 
     /**
diff --git a/core/sis-utility/src/test/java/org/apache/sis/measure/NumberRangeTest.java b/core/sis-utility/src/test/java/org/apache/sis/measure/NumberRangeTest.java
index 2974fee..f9a381d 100644
--- a/core/sis-utility/src/test/java/org/apache/sis/measure/NumberRangeTest.java
+++ b/core/sis-utility/src/test/java/org/apache/sis/measure/NumberRangeTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.measure;
 
+import org.apache.sis.math.MathFunctions;
 import org.junit.Test;
 import org.apache.sis.test.TestCase;
 import org.apache.sis.test.DependsOn;
@@ -27,7 +28,7 @@ import static org.junit.Assert.*;
  * Tests the {@link NumberRange} class.
  *
  * @author  Martin Desruisseaux (IRD)
- * @version 0.3
+ * @version 1.0
  * @since   0.3
  * @module
  */
@@ -37,6 +38,20 @@ import static org.junit.Assert.*;
 })
 public final strictfp class NumberRangeTest extends TestCase {
     /**
+     * Tests {@link NumberRange#isOtherNaN(Number)}.
+     */
+    @Test
+    public void testIsOtherNaN() {
+        assertFalse(NumberRange.isOtherNaN(0));
+        assertFalse(NumberRange.isOtherNaN(0f));
+        assertFalse(NumberRange.isOtherNaN(0d));
+        assertFalse(NumberRange.isOtherNaN(Float.NaN));
+        assertFalse(NumberRange.isOtherNaN(Double.NaN));
+        assertFalse(NumberRange.isOtherNaN(MathFunctions.toNanFloat(0)));
+        assertTrue (NumberRange.isOtherNaN(MathFunctions.toNanFloat(1)));
+    }
+
+    /**
      * Tests the endpoint values of a range of integers.
      */
     @Test


Mime
View raw message