You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2024/09/17 16:42:55 UTC

(sis) branch geoapi-4.0 updated (6ae933802f -> 4f66c86c45)

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

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


    from 6ae933802f Handle subsampling as long integers instead of 32 bits integers. This is needed because small overviews of large images require a subsampling which is about as large as the image size.
     new 76d4d2c198 Review the way that some "multi-values map" are handled. In the netCDF case, group together the domains (set of axes) that are subset or superset of another domain.
     new 4f66c86c45 Refactor the handling of the HYCOM special case, which is replaced by `VariableTransformer`. The special case was about converting an axis of encoded "year month day" values to instants. We need to handle another case used in AML IWC. The difficulty is that they are climatological data (no particular year) with time values encoded as scharacter strings instead of numbers.

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../main/org/apache/sis/io/wkt/AbstractParser.java |   6 +-
 .../main/org/apache/sis/io/wkt/Element.java        |   5 +-
 .../main/org/apache/sis/io/wkt/Warnings.java       |  14 +-
 .../apache/sis/storage/netcdf/MetadataReader.java  |  28 +-
 .../org/apache/sis/storage/netcdf/base/Axis.java   |  18 +-
 .../apache/sis/storage/netcdf/base/CRSBuilder.java |  16 +-
 .../apache/sis/storage/netcdf/base/Convention.java |   8 +-
 .../apache/sis/storage/netcdf/base/Decoder.java    |  16 +-
 .../apache/sis/storage/netcdf/base/FeatureSet.java |  26 +-
 .../sis/storage/netcdf/base/GridMapping.java       |  11 +-
 .../org/apache/sis/storage/netcdf/base/HYCOM.java  | 124 --------
 .../sis/storage/netcdf/base/NamedElement.java      |   6 +
 .../org/apache/sis/storage/netcdf/base/Node.java   |  27 +-
 .../apache/sis/storage/netcdf/base/Variable.java   |  90 +++---
 .../storage/netcdf/base/VariableTransformer.java   | 312 +++++++++++++++++++++
 .../sis/storage/netcdf/classic/ChannelDecoder.java |  16 +-
 .../sis/storage/netcdf/classic/GridInfo.java       |  22 +-
 .../sis/storage/netcdf/classic/VariableInfo.java   |  41 +--
 .../sis/storage/netcdf/ucar/GridWrapper.java       |   3 +-
 .../sis/storage/netcdf/ucar/VariableWrapper.java   |   1 +
 .../sis/storage/sql/feature/FeatureAnalyzer.java   |   7 +-
 .../sis/storage/sql/feature/TableReference.java    |   2 +-
 .../main/org/apache/sis/storage/FeatureNaming.java |  28 +-
 .../apache/sis/storage/base/MetadataBuilder.java   |  10 +-
 .../org/apache/sis/util/privy/CollectionsExt.java  |  69 +++--
 .../apache/sis/util/privy/CollectionsExtTest.java  |   4 +-
 26 files changed, 574 insertions(+), 336 deletions(-)
 delete mode 100644 endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/HYCOM.java
 create mode 100644 endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/VariableTransformer.java


(sis) 01/02: Review the way that some "multi-values map" are handled. In the netCDF case, group together the domains (set of axes) that are subset or superset of another domain.

Posted by de...@apache.org.
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

commit 76d4d2c198764528da7e9db136dfe468c81c0456
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue Sep 10 16:06:27 2024 +0200

    Review the way that some "multi-values map" are handled.
    In the netCDF case, group together the domains (set of axes) that are subset or superset of another domain.
---
 .../main/org/apache/sis/io/wkt/AbstractParser.java |  6 +-
 .../main/org/apache/sis/io/wkt/Element.java        |  5 +-
 .../main/org/apache/sis/io/wkt/Warnings.java       | 14 ++---
 .../apache/sis/storage/netcdf/MetadataReader.java  | 28 +++++++--
 .../sis/storage/netcdf/base/NamedElement.java      |  6 ++
 .../org/apache/sis/storage/netcdf/base/Node.java   |  6 ++
 .../apache/sis/storage/netcdf/base/Variable.java   |  4 +-
 .../sis/storage/netcdf/classic/ChannelDecoder.java |  4 +-
 .../sis/storage/netcdf/classic/VariableInfo.java   |  5 ++
 .../sis/storage/sql/feature/FeatureAnalyzer.java   |  7 ++-
 .../sis/storage/sql/feature/TableReference.java    |  2 +-
 .../main/org/apache/sis/storage/FeatureNaming.java | 28 +++++----
 .../apache/sis/storage/base/MetadataBuilder.java   | 10 +++-
 .../org/apache/sis/util/privy/CollectionsExt.java  | 69 +++++++++++-----------
 .../apache/sis/util/privy/CollectionsExtTest.java  |  4 +-
 15 files changed, 119 insertions(+), 79 deletions(-)

diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/AbstractParser.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/AbstractParser.java
index 477011c6e0..416a63422f 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/AbstractParser.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/AbstractParser.java
@@ -18,7 +18,7 @@ package org.apache.sis.io.wkt;
 
 import java.net.URI;
 import java.util.Map;
-import java.util.List;
+import java.util.Set;
 import java.util.Date;
 import java.util.Locale;
 import java.util.LinkedHashMap;
@@ -138,7 +138,7 @@ abstract class AbstractParser implements Parser {
      * <ul>
      *   <li><b>Keys</b>: keyword of ignored elements. Note that a key may be null.</li>
      *   <li><b>Values</b>: keywords of all elements containing an element identified by the above-cited key.
-     *       This list is used for helping the users to locate the ignored elements.</li>
+     *       This collection is used for helping the users to locate the ignored elements.</li>
      * </ul>
      *
      * Content of this map is not discarded immediately {@linkplain #getAndClearWarnings(Object) after parsing}.
@@ -146,7 +146,7 @@ abstract class AbstractParser implements Parser {
      *
      * @see #getAndClearWarnings(Object)
      */
-    final Map<String, List<String>> ignoredElements;
+    final Map<String, Set<String>> ignoredElements;
 
     /**
      * The warning (other than {@link #ignoredElements}) that occurred during the parsing.
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Element.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Element.java
index af323210cd..8a4e621d0b 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Element.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Element.java
@@ -18,6 +18,7 @@ package org.apache.sis.io.wkt;
 
 import java.util.Date;
 import java.util.Map;
+import java.util.Set;
 import java.util.List;
 import java.util.LinkedList;
 import java.util.Iterator;
@@ -785,13 +786,13 @@ final class Element {
      * <ul>
      *   <li><b>Keys</b>: keyword of ignored elements. Note that a key may be null.</li>
      *   <li><b>Values</b>: keywords of all elements containing an element identified by the above-cited key.
-     *       This list is used for helping the users to locate the ignored elements.</li>
+     *       This collection is used for helping the users to locate the ignored elements.</li>
      * </ul>
      *
      * @param  ignoredElements  the collection where to declare ignored elements.
      * @throws ParseException if the children list still contains some unprocessed values.
      */
-    final void close(final Map<String, List<String>> ignoredElements) throws ParseException {
+    final void close(final Map<String, Set<String>> ignoredElements) throws ParseException {
         for (final Object value : children) {
             if (value instanceof Element) {
                 CollectionsExt.addToMultiValuesMap(ignoredElements, ((Element) value).keyword, keyword);
diff --git a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Warnings.java b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Warnings.java
index dcb6bcd183..846c5f7bfe 100644
--- a/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Warnings.java
+++ b/endorsed/src/org.apache.sis.referencing/main/org/apache/sis/io/wkt/Warnings.java
@@ -17,11 +17,9 @@
 package org.apache.sis.io.wkt;
 
 import java.util.Locale;
-import java.util.List;
 import java.util.Set;
 import java.util.Map;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Iterator;
@@ -69,7 +67,7 @@ import org.apache.sis.util.resources.Vocabulary;
  * </ul>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.5
  *
  * @see WKTFormat#getWarnings()
  *
@@ -134,7 +132,7 @@ public final class Warnings implements Localized, Serializable {
      * @see AbstractParser#ignoredElements
      */
     @SuppressWarnings("serial")                             // Various serializable implementations.
-    private Map<String, List<String>> ignoredElements;
+    private Map<String, Set<String>> ignoredElements;
 
     /**
      * {@code true} if {@link #publish()} has been invoked.
@@ -148,7 +146,7 @@ public final class Warnings implements Localized, Serializable {
      * @param isParsing        {@code false} if formatting, or {@code true} if parsing.
      * @param ignoredElements  the {@link AbstractParser#ignoredElements} map, or an empty map (cannot be null).
      */
-    Warnings(final Locale locale, final boolean isParsing, final Map<String, List<String>> ignoredElements) {
+    Warnings(final Locale locale, final boolean isParsing, final Map<String, Set<String>> ignoredElements) {
         this.errorLocale     = locale;
         this.isParsing       = isParsing;
         this.ignoredElements = ignoredElements;
@@ -198,7 +196,7 @@ public final class Warnings implements Localized, Serializable {
      */
     final void publish() {
         if (!published) {
-            ignoredElements = ignoredElements.isEmpty() ? Collections.emptyMap() : new LinkedHashMap<>(ignoredElements);
+            ignoredElements = Map.copyOf(ignoredElements);
             published = true;
         }
     }
@@ -313,7 +311,7 @@ public final class Warnings implements Localized, Serializable {
      * @param  element  the keyword of the unknown element.
      * @return the keywords of elements where the given unknown element was found.
      */
-    public Collection<String> getUnknownElementLocations(final String element) {
+    public Set<String> getUnknownElementLocations(final String element) {
         return ignoredElements.get(element);
     }
 
@@ -377,7 +375,7 @@ public final class Warnings implements Localized, Serializable {
         if (!ignoredElements.isEmpty()) {
             final Vocabulary vocabulary = Vocabulary.forLocale(locale);
             buffer.append(lineSeparator).append(" • ").append(resources.getString(Messages.Keys.UnknownElementsInText));
-            for (final Map.Entry<String, List<String>> entry : ignoredElements.entrySet()) {
+            for (final Map.Entry<String, Set<String>> entry : ignoredElements.entrySet()) {
                 buffer.append(lineSeparator).append("    ‣ ").append(vocabulary.getString(Vocabulary.Keys.Quoted_1, entry.getKey()));
                 String separator = vocabulary.getString(Vocabulary.Keys.InBetweenWords);
                 for (final String p : entry.getValue()) {
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java
index f718b5c233..3940e00ac3 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/MetadataReader.java
@@ -23,7 +23,6 @@ import java.util.Set;
 import java.util.Map;
 import java.util.LinkedHashSet;
 import java.util.LinkedHashMap;
-import java.util.Arrays;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.io.IOException;
@@ -862,23 +861,39 @@ split:  while ((start = CharSequences.skipLeadingWhitespaces(value, start, lengt
 
     /**
      * Adds information about all netCDF variables. This is the {@code <mdb:contentInfo>} element in XML.
-     * This method groups variables by their domains, i.e. variables having the same set of axes are grouped together.
+     * This method groups variables by their domains, i.e. variables having the same set of axes, ignoring order,
+     * are grouped together. Variables having only a subset of axes are also grouped together with the variables
+     * having more dimension.
+     *
+     * <p><b>Example:</b> a netCDF file may contain variables for both static and dynamic phenomenons.
+     * The dynamic phenomenons are associated to (<var>x</var>, <var>y</var>, <var>t</var>) axes,
+     * while the static phenomenons have only the (<var>x</var>, <var>y</var>) axes.
+     * But we still want to group them together. Not doing so appear to be confusing.</p>
      */
     private void addContentInfo() {
         /*
          * Prepare a list of features and coverages, but without writing metadata now.
          * We differ metadata writing for giving us a chance to group related contents.
          */
-        final Set<Dimension> features = new LinkedHashSet<>();
-        final Map<List<String>, List<Variable>> coverages = new LinkedHashMap<>(4);
+        final var features  = new LinkedHashSet<Dimension>();
+        final var coverages = new LinkedHashMap<Set<String>, List<Variable>>();
         for (final Variable variable : decoder.getVariables()) {
             if (VariableRole.isCoverage(variable)) {
-                final List<org.apache.sis.storage.netcdf.base.Dimension> dimensions = variable.getGridDimensions();
+                final var dimensions = variable.getGridDimensions();
                 final String[] names = new String[dimensions.size()];
                 for (int i=0; i<names.length; i++) {
                     names[i] = dimensions.get(i).getName();
                 }
-                CollectionsExt.addToMultiValuesMap(coverages, Arrays.asList(names), variable);
+                coverages.computeIfAbsent(Set.of(names), (key) -> {
+                    for (Map.Entry<Set<String>, List<Variable>> entry : coverages.entrySet()) {
+                        final Set<String> previous = entry.getKey();
+                        if (previous.containsAll(key) || key.containsAll(previous)) {
+                            // Share with all keys that are subset or superset.
+                            return entry.getValue();
+                        }
+                    }
+                    return new ArrayList<>();
+                }).add(variable);
                 hasGridCoverages = true;
             } else if (variable.getRole() == VariableRole.FEATURE_PROPERTY) {
                 /*
@@ -901,6 +916,7 @@ split:  while ((start = CharSequences.skipLeadingWhitespaces(value, start, lengt
                 addFeatureType(decoder.nameFactory.createLocalName(decoder.namespace, name), feature.length());
             }
         }
+        newFeatureTypes();   // See Javadoc about confusing ordering.
         final String processingLevel = stringValue(PROCESSING_LEVEL);
         for (final List<Variable> group : coverages.values()) {
             /*
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/NamedElement.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/NamedElement.java
index d23be01c0c..07221908bc 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/NamedElement.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/NamedElement.java
@@ -126,4 +126,10 @@ public abstract class NamedElement {
     public String toString() {
         return Strings.bracket(getClass().getSimpleName(), getName());
     }
+
+    /*
+     * Do not override `equals(Object)` and `hashCode()`. Some subclasses are
+     * used in `HashSet` and the identity comparison is well suited for them.
+     * For example, `Variable` is used as keys in `GridMapping.forVariable(…)`.
+     */
 }
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java
index faac3873f2..fded643327 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java
@@ -304,4 +304,10 @@ public abstract class Node extends NamedElement {
     protected final void error(final Class<?> caller, final String method, final Exception exception, final short key, final Object... arguments) {
         warning(decoder.listeners, caller, method, exception, errors(), key, arguments);
     }
+
+    /*
+     * Do not override `equals(Object)` and `hashCode()`. Some subclasses are
+     * used in `HashSet` and the identity comparison is well suited for them.
+     * For example, `Variable` is used as keys in `GridMapping.forVariable(…)`.
+     */
 }
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java
index 26c696a390..8153d7dced 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java
@@ -1310,7 +1310,7 @@ public abstract class Variable extends Node {
     }
 
     /*
-     * Do not override Object.equals(Object) and Object.hashCode(),
-     * because Variables are used as keys by GridMapping.forVariable(…).
+     * Do not override `Object.equals(Object)` and `Object.hashCode()`,
+     * because variables are used as keys by `GridMapping.forVariable(…)`.
      */
 }
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java
index 356bc6f213..dfb99b4786 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java
@@ -961,7 +961,7 @@ public final class ChannelDecoder extends Decoder {
              * from grid coordinates to CRS coordinates). For each key there is usually only one value, but
              * more complicated netCDF files (e.g. using two-dimensional localisation grids) also exist.
              */
-            final Map<DimensionInfo, List<VariableInfo>> dimToAxes = new IdentityHashMap<>();
+            final var dimToAxes = new IdentityHashMap<DimensionInfo, Set<VariableInfo>>();
             for (final VariableInfo variable : variables) {
                 switch (variable.getRole()) {
                     case COVERAGE:
@@ -1011,7 +1011,7 @@ nextVar:    for (final VariableInfo variable : variables) {
                 for (int i=variable.dimensions.length; --i >= 0;) {                     // Reverse of netCDF order.
                     final DimensionInfo dimension = variable.dimensions[i];
                     if (usedDimensions.add(dimension)) {
-                        final List<VariableInfo> axis = dimToAxes.get(dimension);       // Should have only 1 element.
+                        final Set<VariableInfo> axis = dimToAxes.get(dimension);       // Should have only 1 element.
                         if (axis == null) {
                             continue nextVar;
                         }
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java
index a051dbdbc0..5473325f93 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java
@@ -848,4 +848,9 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
         if (c == 0) c = name.compareTo(other.name);                 // Should not happen, but we are paranoiac.
         return c;
     }
+
+    /*
+     * Do not override `Object.equals(Object)` and `Object.hashCode()`,
+     * because variables are used as keys by `GridMapping.forVariable(…)`.
+     */
 }
diff --git a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java
index d817e1940f..a3f3375915 100644
--- a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java
+++ b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/FeatureAnalyzer.java
@@ -17,6 +17,7 @@
 package org.apache.sis.storage.sql.feature;
 
 import java.util.List;
+import java.util.ArrayList;
 import java.util.Map;
 import java.util.Set;
 import java.util.HashMap;
@@ -34,7 +35,6 @@ import org.apache.sis.geometry.wrapper.Geometries;
 import org.apache.sis.util.CharSequences;
 import org.apache.sis.util.Classes;
 import org.apache.sis.util.Numbers;
-import org.apache.sis.util.privy.CollectionsExt;
 
 // Specific to the geoapi-3.1 and geoapi-4.0 branches:
 import org.opengis.feature.FeatureType;
@@ -45,6 +45,9 @@ import org.opengis.feature.FeatureType;
  * This is used by {@link Analyzer} for creating {@link Table} instances.
  * A view or a custom query is considered as a "virtual" table.
  *
+ * <p>Instances of this class are created temporarily when starting the analysis
+ * of a database structure, and discarded after the analysis is finished.</p>
+ *
  * <h2>Side effects</h2>
  * Methods shall be invoked as below, in that order. The order is important because some
  * methods have values computed as side-effects and which are required by a subsequent method.
@@ -171,7 +174,7 @@ abstract class FeatureAnalyzer {
      */
     final void addForeignerKeys(Relation relation) {
         for (final String column : relation.getOwnerColumns()) {
-            CollectionsExt.addToMultiValuesMap(foreignerKeys, column, relation);
+            foreignerKeys.computeIfAbsent(column, (key) -> new ArrayList<>()).add(relation);
             relation = null;       // Only the first column will be associated.
         }
     }
diff --git a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/TableReference.java b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/TableReference.java
index 5d272580e7..b19f5a5130 100644
--- a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/TableReference.java
+++ b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/TableReference.java
@@ -102,7 +102,7 @@ public class TableReference {
 
     /**
      * Returns {@code true} if the given object is a {@code TableReference} with equal table, schema and catalog names.
-     * All other properties that may be defined in subclasses (column names, action on delete, etc.) are ignored; this
+     * All other properties that may be defined in subclasses (column names, action on delete, etc.) are ignored. This
      * method is <strong>not</strong> for testing if two {@link Relation} are fully equal. The purpose of this method
      * is only to use {@code TableReference} as keys in a {@code HashSet} for remembering full coordinates of tables
      * that may need to be analyzed later.
diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureNaming.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureNaming.java
index 92d0951bc9..466257c02e 100644
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureNaming.java
+++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureNaming.java
@@ -18,8 +18,9 @@ package org.apache.sis.storage;
 
 import java.util.Map;
 import java.util.HashMap;
-import java.util.List;
+import java.util.Set;
 import java.util.ConcurrentModificationException;
+import java.util.Iterator;
 import java.util.Locale;
 import java.util.Objects;
 import org.opengis.util.GenericName;
@@ -86,7 +87,7 @@ import org.apache.sis.storage.internal.Resources;
  * The caller is typically the {@code DataStore} implementation which contains this {@code FeatureNaming} instance.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.5
  *
  * @param <E>  the type of elements associated with the names.
  *
@@ -100,13 +101,13 @@ public class FeatureNaming<E> {
     /**
      * All aliases found for all names given to the {@link #add(DataStore, GenericName, Object)} method.
      * Keys are aliases and values are the complete names for which the key is an alias.
-     * Each {@code List<String>} instance contains exactly one name if there is no ambiguity.
+     * Each {@code Set<String>} instance contains exactly one name if there is no ambiguity.
      * If the list contains more than one name, this means that the alias is ambiguous.
      *
-     * <p>For saving space in common cases, a missing entry in this map is interpreted as synonymous of
-     * {@code (name, Collections.singletonList(name))} entry.</p>
+     * <p>For saving space in common cases, a missing entry in this map is interpreted
+     * as synonymous of the {@code (name, Collections.singleton(name))} entry.</p>
      */
-    private final Map<String, List<String>> aliases;
+    private final Map<String, Set<String>> aliases;
 
     /**
      * The user-specified values associated to names and aliases. If a value is absent, it may means either that the
@@ -160,13 +161,14 @@ public class FeatureNaming<E> {
         }
         final short key;
         final Object[] params;
-        final List<String> nc = aliases.get(name);
+        final Set<String> nc = aliases.get(name);
         if (nc == null) {
             key = Resources.Keys.FeatureNotFound_2;
             params = new CharSequence[] {name(store), name};
         } else if (nc.size() >= 2) {
             key = Resources.Keys.AmbiguousName_4;
-            params = new CharSequence[] {name(store), nc.get(0), nc.get(1), name};
+            final Iterator<String> it = nc.iterator();
+            params = new CharSequence[] {name(store), it.next(), it.next(), name};
         } else {
             return null;    // Name was explicitly associated to null value (actually not allowed by current API).
         }
@@ -186,7 +188,7 @@ public class FeatureNaming<E> {
         final String key = name.toString();
         final E previous = values.put(key, Objects.requireNonNull(value));
         if (previous != null) {
-            final List<String> fullNames = aliases.get(key);            // Null is synonymous of singletonList(key).
+            final Set<String> fullNames = aliases.get(key);             // Null is synonymous of singleton(key).
             if (fullNames == null || fullNames.contains(key)) {
                 /*
                  * If we already had a value for the given name and if that value was associated to a user-specified
@@ -206,7 +208,7 @@ public class FeatureNaming<E> {
         while (name instanceof ScopedName) {
             name = ((ScopedName) name).tail();
             final String alias = name.toString();
-            final List<String> fullNames = CollectionsExt.addToMultiValuesMap(aliases, alias, key);
+            final Set<String> fullNames = CollectionsExt.addToMultiValuesMap(aliases, alias, key);
             if (fullNames.size() > 1) {
                 /*
                  * If there is more than one GenericName for the same alias, we have an ambiguity.
@@ -245,13 +247,13 @@ public class FeatureNaming<E> {
         if (values.remove(key) == null) {
             return false;
         }
-        List<String> remaining = CollectionsExt.removeFromMultiValuesMap(aliases, key, key);
+        Set<String> remaining = CollectionsExt.removeFromMultiValuesMap(aliases, key, key);
         if (remaining != null && remaining.size() == 1) {
             /*
              * If there is exactly one remaining element, that element is a non-ambiguous alias.
              * So we can associate the value to that alias.
              */
-            final String select = remaining.get(0);
+            final String select = remaining.iterator().next();
             assert !select.equals(key) : select;     // Should have been removed by removeFromMultiValuesMap(…).
             if (values.put(key, values.get(select)) != null) {
                 throw new ConcurrentModificationException(name(store).toString());          // Paranoiac check.
@@ -271,7 +273,7 @@ public class FeatureNaming<E> {
             if (remaining == null || remaining.isEmpty()) {
                 error |= (values.remove(alias) == null);
             } else if (remaining.size() == 1) {
-                final String select = remaining.get(0);
+                final String select = remaining.iterator().next();
                 assert !select.equals(key) : select;     // Should have been removed by removeFromMultiValuesMap(…).
                 error |= (values.putIfAbsent(alias, values.get(select)) != null);
             }
diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
index 3500f6deb3..dd81894447 100644
--- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
+++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
@@ -716,7 +716,10 @@ public class MetadataBuilder {
      * If there is no pending feature description, then invoking this method has no effect.
      * If new feature descriptions are added after this method call, they will be stored in a new element.
      *
-     * <p>This method does not need to be invoked unless a new "feature catalog description" node is desired.</p>
+     * <p>This method does not need to be invoked unless a new "feature catalog description" node is desired.
+     * It may also be useful when switching from writing feature types to writing coverage descriptions,
+     * because both classes appear under the same "content information" node.
+     * Invoking this method may avoid confusing ordering of those elements.</p>
      */
     public final void newFeatureTypes() {
         if (featureDescription != null) {
@@ -732,7 +735,10 @@ public class MetadataBuilder {
      * If new coverage descriptions are added after this method call, they will be stored in a new element.
      *
      * <p>This method does not need to be invoked unless a new "coverage description" node is desired,
-     * or the {@code electromagnetic} flag needs to be set to {@code true}.</p>
+     * or the {@code electromagnetic} flag needs to be set to {@code true}. It may also be useful when
+     * switching from writing coverage descriptions to writing feature types,
+     * because both classes appear under the same "content information" node.
+     * Invoking this method may avoid confusing ordering of those elements.</p>
      *
      * @param  electromagnetic  {@code true} if the next {@code CoverageDescription} to create
      *         will be a description of measurements in the electromagnetic spectrum.
diff --git a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/privy/CollectionsExt.java b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/privy/CollectionsExt.java
index 31c0c3c43e..d332b9b431 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/privy/CollectionsExt.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/privy/CollectionsExt.java
@@ -746,61 +746,58 @@ public final class CollectionsExt extends Static {
     }
 
     /**
-     * Adds a value in a pseudo multi-values map. The multi-values map is simulated by a map of lists.
-     * The map can be initially empty - lists will be created as needed.
+     * Adds a value in a pseudo multi-values map. The multi-values map is simulated by a map of sets.
+     * The map can be initially empty: sets will be created as needed, with an optimization for the
+     * common case where the majority of keys are associated to exactly one value.
+     * Null values are accepted.
      *
      * @param  <K>    the type of key elements in the map.
-     * @param  <V>    the type of value elements in the lists.
+     * @param  <V>    the type of value elements in the sets.
      * @param  map    the multi-values map where to add an element.
      * @param  key    the key of the element to add. Can be null if the given map supports null keys.
      * @param  value  the value of the element to add. Can be null.
-     * @return the list where the given value has been added. May be unmodifiable.
+     * @return the set where the given value has been added. May be unmodifiable.
      */
-    public static <K,V> List<V> addToMultiValuesMap(final Map<K,List<V>> map, final K key, final V value) {
-        final List<V> singleton = Collections.singletonList(value);
-        List<V> values = map.put(key, singleton);
-        if (values == null) {
-            return singleton;               // This is the most common case.
-        }
-        if (values.size() <= 1) {
-            values = new ArrayList<>(values);
-        }
-        if (map.put(key, values) != singleton) {
-            throw new ConcurrentModificationException();
-        }
-        values.add(value);
-        return values;
+    public static <K,V> Set<V> addToMultiValuesMap(final Map<K,Set<V>> map, final K key, final V value) {
+        return map.merge(key, Collections.singleton(value), (values, singleton) -> {
+            final Set<V> dest = (values.size() > 1) ? values : new LinkedHashSet<>(values);
+            return dest.addAll(singleton) ? dest : values;
+        });
     }
 
     /**
-     * Removes a value in a pseudo multi-values map. The multi-values map is simulated by a map of lists.
-     * If more than one occurrence of the given value is found in the list, only the first occurrence is
-     * removed. If the list become empty after this method call, that list is removed from the map.
+     * Removes a value in a pseudo multi-values map. The multi-values map is simulated by a map of sets.
+     * If the set become empty after this method call, that set is removed from the map and this method
+     * returns the empty set.
      *
      * @param  <K>    the type of key elements in the map.
      * @param  <V>    the type of value elements in the lists.
      * @param  map    the multi-values map where to remove an element.
      * @param  key    the key of the element to remove. Can be null if the given map supports null keys.
      * @param  value  the value of the element to remove. Can be null.
-     * @return list of remaining elements after the removal, or {@code null} if no list is mapped to the given key.
+     * @return set of remaining elements after the removal, or {@code null} if no set is mapped to the given key.
      */
-    public static <K,V> List<V> removeFromMultiValuesMap(final Map<K,List<V>> map, final K key, final V value) {
-        List<V> values = map.get(key);
-        if (values != null) {
-            final boolean isEmpty;
-            switch (values.size()) {
-                case 0:  isEmpty = true; break;
-                case 1:  isEmpty = Objects.equals(value, values.get(0)); break;
-                default: isEmpty = values.remove(value) && values.isEmpty(); break;
-            }
-            if (isEmpty) {
-                if (map.remove(key) != values) {
-                    throw new ConcurrentModificationException();
+    public static <K,V> Set<V> removeFromMultiValuesMap(final Map<K,Set<V>> map, final K key, final V value) {
+        final Set<V> remaining = map.compute(key, (k, values) -> {
+            if (values != null) {
+                final boolean isEmpty;
+                switch (values.size()) {
+                    case 0:  isEmpty = true; break;
+                    case 1:  isEmpty = values.contains(value); break;
+                    default: isEmpty = values.remove(value) && values.isEmpty(); break;
+                }
+                if (isEmpty) {
+                    return Collections.emptySet();
                 }
-                values = Collections.emptyList();
+            }
+            return values;
+        });
+        if (remaining != null && remaining.isEmpty()) {
+            if (map.remove(key) != remaining) {
+                throw new ConcurrentModificationException();
             }
         }
-        return values;
+        return remaining;
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/privy/CollectionsExtTest.java b/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/privy/CollectionsExtTest.java
index f2be3db691..f8133cdfb4 100644
--- a/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/privy/CollectionsExtTest.java
+++ b/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/privy/CollectionsExtTest.java
@@ -102,7 +102,7 @@ public final class CollectionsExtTest extends TestCase {
      */
     @Test
     public void testAddAndRemoveToMultiValuesMap() {
-        final Map<String, List<Integer>> map = new LinkedHashMap<>();
+        final var map = new LinkedHashMap<String, Set<Integer>>();
         final Integer A1 = 2;
         final Integer A2 = 4;
         final Integer B1 = 3;
@@ -130,7 +130,7 @@ public final class CollectionsExtTest extends TestCase {
      */
     @Test
     public void testToCaseInsensitiveNameMap() {
-        final List<Map.Entry<String,String>> elements = new ArrayList<>();
+        final var elements = new ArrayList<Map.Entry<String,String>>();
         elements.add(new AbstractMap.SimpleEntry<>("AA", "AA"));
         elements.add(new AbstractMap.SimpleEntry<>("Aa", "Aa"));
         elements.add(new AbstractMap.SimpleEntry<>("BB", "BB"));


(sis) 02/02: Refactor the handling of the HYCOM special case, which is replaced by `VariableTransformer`. The special case was about converting an axis of encoded "year month day" values to instants. We need to handle another case used in AML IWC. The difficulty is that they are climatological data (no particular year) with time values encoded as scharacter strings instead of numbers.

Posted by de...@apache.org.
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

commit 4f66c86c4592cdc01d4bc79ea3a10cb0f4c1ed83
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Wed Sep 11 19:27:10 2024 +0200

    Refactor the handling of the HYCOM special case, which is replaced by `VariableTransformer`.
    The special case was about converting an axis of encoded "year month day" values to instants.
    We need to handle another case used in AML IWC. The difficulty is that they are climatological
    data (no particular year) with time values encoded as scharacter strings instead of numbers.
---
 .../org/apache/sis/storage/netcdf/base/Axis.java   |  18 +-
 .../apache/sis/storage/netcdf/base/CRSBuilder.java |  16 +-
 .../apache/sis/storage/netcdf/base/Convention.java |   8 +-
 .../apache/sis/storage/netcdf/base/Decoder.java    |  16 +-
 .../apache/sis/storage/netcdf/base/FeatureSet.java |  26 +-
 .../sis/storage/netcdf/base/GridMapping.java       |  11 +-
 .../org/apache/sis/storage/netcdf/base/HYCOM.java  | 124 --------
 .../org/apache/sis/storage/netcdf/base/Node.java   |  21 +-
 .../apache/sis/storage/netcdf/base/Variable.java   |  86 +++---
 .../storage/netcdf/base/VariableTransformer.java   | 312 +++++++++++++++++++++
 .../sis/storage/netcdf/classic/ChannelDecoder.java |  12 +-
 .../sis/storage/netcdf/classic/GridInfo.java       |  22 +-
 .../sis/storage/netcdf/classic/VariableInfo.java   |  36 +--
 .../sis/storage/netcdf/ucar/GridWrapper.java       |   3 +-
 .../sis/storage/netcdf/ucar/VariableWrapper.java   |   1 +
 15 files changed, 455 insertions(+), 257 deletions(-)

diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Axis.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Axis.java
index 8bff2968de..dca2c1dd06 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Axis.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Axis.java
@@ -122,7 +122,7 @@ public final class Axis extends NamedElement {
      * In particular, this field has no meaning for CRS of geometries in a {@link FeatureSet}.
      * See {@link #Axis(Variable)} for a list of methods than cannot be used in such case.</p>
      *
-     * @suu #getNumDimensions()
+     * @see #getNumDimensions()
      * @see #getMainDirection()
      */
     final int[] gridDimensionIndices;
@@ -157,7 +157,7 @@ public final class Axis extends NamedElement {
 
     /**
      * Creates an axis for a {@link FeatureSet}. This constructor leaves the {@link #gridDimensionIndices}
-     * and {@link #gridSizes} array to {@code null}, which forbid the use of following methods:
+     * and {@link #gridSizes} array to {@code null}, which forbids the use of following methods:
      *
      * <ul>
      *   <li>{@link #mainDimensionFirst(Axis[], int)}</li>
@@ -178,21 +178,24 @@ public final class Axis extends NamedElement {
     }
 
     /**
-     * Constructs a new axis associated to an arbitrary number of grid dimension. The given arrays are stored
+     * Constructs a new axis associated to an arbitrary number of grid dimensions. The given arrays are stored
      * as-in (not cloned) and their content may be modified after construction by {@link Grid#getAxes(Decoder)}.
      *
      * @param  abbreviation          axis abbreviation, also identifying its type. This is a controlled vocabulary.
      * @param  direction             direction of positive values ("up" or "down"), or {@code null} if unknown.
      * @param  gridDimensionIndices  indices of grid dimension associated to this axis, initially in netCDF order.
      * @param  gridSizes             number of cell elements along above grid dimensions, as unsigned integers.
+     * @param  dimension             number of valid elements in {@code gridDimensionIndices} and {@code gridSizes}.
      * @param  coordinates           coordinates of the localization grid used by this axis.
      * @throws IOException if an I/O operation was necessary but failed.
      * @throws DataStoreException if a logical error occurred.
      * @throws ArithmeticException if the size of an axis exceeds {@link Integer#MAX_VALUE}, or other overflow occurs.
      */
-    public Axis(final char abbreviation, final String direction, final int[] gridDimensionIndices, final int[] gridSizes,
-                final Variable coordinates) throws IOException, DataStoreException
+    public Axis(final char abbreviation, final String direction, int[] gridDimensionIndices, int[] gridSizes,
+                int dimension, final Variable coordinates) throws IOException, DataStoreException
     {
+        gridDimensionIndices = ArraysExt.resize(gridDimensionIndices, dimension);
+        gridSizes = ArraysExt.resize(gridSizes, dimension);
         /*
          * Try to get the axis direction from one of the following sources,
          * in preference order (unless an inconsistency is detected):
@@ -385,7 +388,7 @@ public final class Axis extends NamedElement {
 
     /**
      * Returns the number of dimension of the localization grid used by this axis.
-     * This method returns 2 if this axis if backed by a localization grid having 2 or more dimensions.
+     * This method returns 2 if this axis is backed by a localization grid having 2 or more dimensions.
      * In the netCDF UCAR library, such axes are handled by a {@link ucar.nc2.dataset.CoordinateAxis2D}.
      *
      * @return number of dimension of the localization grid used by this axis.
@@ -917,7 +920,8 @@ public final class Axis extends NamedElement {
             data = data.transform(tr.getScale(), tr.getOffset());       // Apply scale and offset attributes, if any.
             return data;
         } else {
-            throw new DataStoreException(coordinates.resources().getString(Resources.Keys.CanNotUseAxis_1, getName()));
+            throw new DataStoreException(coordinates.decoder.resources()
+                    .getString(Resources.Keys.CanNotUseAxis_1, getName()));
         }
     }
 
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java
index b3d4d844fd..5f1ce20493 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/CRSBuilder.java
@@ -202,11 +202,11 @@ abstract class CRSBuilder<D extends Datum, CS extends CoordinateSystem> {
             final List<GridCacheValue> linearizations, final Matrix reorderGridToCRS)
             throws DataStoreException, FactoryException, IOException
     {
-        final List<CRSBuilder<?,?>> builders = new ArrayList<>(4);
+        final var builders = new ArrayList<CRSBuilder<?,?>>(4);
         for (final Axis axis : grid.getAxes(decoder)) {
             dispatch(builders, axis);
         }
-        final SingleCRS[] components = new SingleCRS[builders.size()];
+        final var components = new SingleCRS[builders.size()];
         for (int i=0; i < components.length; i++) {
             components[i] = builders.get(i).build(decoder, true);
         }
@@ -240,11 +240,11 @@ abstract class CRSBuilder<D extends Datum, CS extends CoordinateSystem> {
     static CoordinateReferenceSystem assemble(final Decoder decoder, final Iterable<Variable> axes, final SingleCRS[] time)
             throws DataStoreException, FactoryException, IOException
     {
-        final List<CRSBuilder<?,?>> builders = new ArrayList<>(4);
+        final var builders = new ArrayList<CRSBuilder<?,?>>(4);
         for (final Variable axis : axes) {
             dispatch(builders, new Axis(axis));
         }
-        final SingleCRS[] components = new SingleCRS[builders.size()];
+        final var components = new SingleCRS[builders.size()];
         int n = 0;
         for (final CRSBuilder<?, ?> cb : builders) {
             final SingleCRS c = cb.build(decoder, false);
@@ -310,7 +310,7 @@ abstract class CRSBuilder<D extends Datum, CS extends CoordinateSystem> {
          * block below fixes it.
          */
         if (addTo == Projected.class) {
-previous:   for (int i=components.size(); --i >= 0;) {
+previous:   for (int i = components.size(); --i >= 0;) {
                 final CRSBuilder<?,?> replace = components.get(i);
                 for (final Axis a : replace.axes) {
                     if (a.abbreviation != 'h') {
@@ -377,7 +377,7 @@ previous:   for (int i=components.size(); --i >= 0;) {
              * By contrast, `DataStoreContentException` would be treated as a fatal error.
              */
             final Variable axis = getFirstAxis().coordinates;
-            throw new FactoryException(axis.resources().getString(Resources.Keys.UnexpectedAxisCount_4,
+            throw new FactoryException(axis.decoder.resources().getString(Resources.Keys.UnexpectedAxisCount_4,
                     axis.getFilename(), getClass().getSimpleName(), dimension, NamedElement.listNames(axes, dimension, ", ")));
         }
         /*
@@ -475,7 +475,7 @@ previous:   for (int i=components.size(); --i >= 0;) {
             // Fallback if the coordinate system is not predefined.
             final StringJoiner joiner = new StringJoiner(" ");
             final CSFactory csFactory = decoder.getCSFactory();
-            final CoordinateSystemAxis[] iso = new CoordinateSystemAxis[dimension];
+            final var iso = new CoordinateSystemAxis[dimension];
             for (int i=0; i<iso.length; i++) {
                 final Axis axis = axes[i];
                 joiner.add(axis.getName());
@@ -528,7 +528,7 @@ previous:   for (int i=components.size(); --i >= 0;) {
     final Integer epsgCandidateCS(final Unit<?> defaultUnit) {
         Unit<?> unit = getFirstAxis().getUnit();
         if (unit == null) unit = defaultUnit;
-        final AxisDirection[] directions = new AxisDirection[dimension];
+        final var directions = new AxisDirection[dimension];
         for (int i=0; i<directions.length; i++) {
             directions[i] = axes[i].direction;
         }
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java
index 07912902d8..1e5449b5c3 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Convention.java
@@ -478,7 +478,7 @@ public class Convention {
         if (method == null) {
             return null;
         }
-        final Map<String,Object> definition = new HashMap<>();
+        final var definition = new HashMap<String,Object>();
         definition.put(CF.GRID_MAPPING_NAME, method);
         for (final String name : node.getAttributeNames()) try {
             final String ln = name.toLowerCase(Decoder.DATA_LOCALE);
@@ -493,7 +493,7 @@ public class Convention {
                      */
                     final Vector values = node.getAttributeAsVector(name);
                     if (values == null || values.size() < 3) continue;
-                    final BursaWolfParameters bp = new BursaWolfParameters(CommonCRS.WGS84.datum(), null);
+                    final var bp = new BursaWolfParameters(CommonCRS.WGS84.datum(), null);
                     bp.setValues(values.doubleValues());
                     value = bp;
                     break;
@@ -709,7 +709,7 @@ public class Convention {
      * @return no-data values with bitmask of their roles or textual descriptions.
      */
     public Map<Number,Object> nodataValues(final Variable data) {
-        final Map<Number,Object> pads = new LinkedHashMap<>();
+        final var pads = new LinkedHashMap<Number,Object>();
         for (int i=0; i < NODATA_ATTRIBUTES.length; i++) {
             final String name = NODATA_ATTRIBUTES[i];
             final Vector values = data.getAttributeAsVector(name);
@@ -748,7 +748,7 @@ public class Convention {
          * If scale_factor and/or add_offset variable attributes are present, then this is
          * a "packed" variable. Otherwise the transfer function is the identity transform.
          */
-        final TransferFunction tr = new TransferFunction();
+        final var tr = new TransferFunction();
         final double scale  = data.getAttributeAsDouble(CDM.SCALE_FACTOR);
         final double offset = data.getAttributeAsDouble(CDM.ADD_OFFSET);
         if (!Double.isNaN(scale))  tr.setScale (scale);
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Decoder.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Decoder.java
index b559329826..545f8c968b 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Decoder.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Decoder.java
@@ -27,7 +27,6 @@ import java.util.concurrent.TimeUnit;
 import java.util.logging.LogRecord;
 import java.util.logging.Logger;
 import java.util.logging.Level;
-import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.temporal.Temporal;
 import java.io.IOException;
@@ -199,7 +198,10 @@ public abstract class Decoder extends ReferencingFactoryContainer {
      * @throws DataStoreException if an error occurred while interpreting the netCDF file content.
      */
     public final void applyOtherConventions() throws IOException, DataStoreException {
-        HYCOM.convert(this, getVariables());
+        final var t = new VariableTransformer(this);
+        for (Variable variable : getVariables()) {
+            t.analyze(variable);
+        }
     }
 
     /**
@@ -328,7 +330,7 @@ public abstract class Decoder extends ReferencingFactoryContainer {
      * @param  value  the illegal value.
      * @param  e      the exception, or {@code null} if none.
      */
-    final void illegalAttributeValue(final String name, final String value, final NumberFormatException e) {
+    final void illegalAttributeValue(final String name, final String value, final Exception e) {
         listeners.warning(resources().getString(Resources.Keys.IllegalAttributeValue_3, getFilename(), name, value), e);
     }
 
@@ -355,7 +357,7 @@ public abstract class Decoder extends ReferencingFactoryContainer {
      *
      * @return the timezone for dates.
      */
-    public ZoneId getTimeZone() {
+    public ZoneOffset getTimeZone() {
         return ZoneOffset.UTC;
     }
 
@@ -440,7 +442,7 @@ public abstract class Decoder extends ReferencingFactoryContainer {
      * @throws DataStoreException if a logical error occurred.
      */
     public final List<CoordinateReferenceSystem> getReferenceSystemInfo() throws IOException, DataStoreException {
-        final List<CoordinateReferenceSystem> list = new ArrayList<>();
+        final var list = new ArrayList<CoordinateReferenceSystem>();
         for (final Variable variable : getVariables()) {
             final GridMapping m = GridMapping.forVariable(variable);
             if (m != null) {
@@ -453,7 +455,7 @@ public abstract class Decoder extends ReferencingFactoryContainer {
          * Consequently, if such information is present, grid CRS may be inaccurate.
          */
         if (list.isEmpty()) {
-            final List<Exception> warnings = new ArrayList<>();     // For internal usage by Grid.
+            final var warnings = new ArrayList<Exception>();    // For internal usage by Grid.
             for (final Grid grid : getGridCandidates()) {
                 addIfNotPresent(list, grid.getCoordinateReferenceSystem(this, warnings, null, null));
             }
@@ -530,7 +532,7 @@ public abstract class Decoder extends ReferencingFactoryContainer {
      *
      * @return the localized error resource bundle.
      */
-    final Resources resources() {
+    public final Resources resources() {
         return Resources.forLocale(getLocale());
     }
 
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/FeatureSet.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/FeatureSet.java
index 66fe896ce5..11b224b32d 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/FeatureSet.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/FeatureSet.java
@@ -209,26 +209,26 @@ final class FeatureSet extends DiscreteSampling {
          *    - Trajectory as a geometric object, potentially with a time characteristic.
          *    - Time-varying properties (i.e. properties having a value per instant).
          */
-        final FeatureTypeBuilder builder = new FeatureTypeBuilder(
-                decoder.nameFactory, decoder.geomlib, decoder.listeners.getLocale());
+        final var builder = new FeatureTypeBuilder(decoder.nameFactory, decoder.geomlib, decoder.listeners.getLocale());
         /*
          * Identifier and other static properties (one value per feature instance).
          */
         for (int i = getReferencingDimension(false); i < properties.length; i++) {
             final Variable v = properties[i];
-            final Class<?> type;
+            final Class<?> t;
             if (v.getEnumeration() != null) {
-                type = String.class;
+                t = String.class;
             } else {
-                type = v.getDataType().getClass(v.getNumDimensions() > 1);
+                t = v.getDataType().getClass(v.getNumDimensions() > 1);
             }
-            describe(v, builder.addAttribute(type));
+            describe(v, builder.addAttribute(t));
         }
         /*
          * Geometry object as a single point or a trajectory, associated with:
          *   - A Coordinate Reference System (CRS) characteristic.
          *   - A "datetimes" characteristic if a time axis exists.
          */
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
         DefaultTemporalCRS timeCRS = null;
         if (referencingDimension != 0) {
             final AttributeTypeBuilder<?> geometry;
@@ -256,8 +256,8 @@ final class FeatureSet extends DiscreteSampling {
          */
         for (int i = getReferencingDimension(true); i < dynamicProperties.length; i++) {
             final Variable v = dynamicProperties[i];
-            final Class<?> type = (v.getEnumeration() != null || v.isString()) ? String.class : Number.class;
-            describe(v, builder.addAttribute(type).setMaximumOccurs(Integer.MAX_VALUE));
+            final Class<?> t = (v.getEnumeration() != null || v.isString()) ? String.class : Number.class;
+            describe(v, builder.addAttribute(t).setMaximumOccurs(Integer.MAX_VALUE));
         }
         /*
          * By default, `name` is a netCDF dimension name (see method javadoc), usually all lower-cases.
@@ -302,8 +302,8 @@ final class FeatureSet extends DiscreteSampling {
      */
     static FeatureSet[] create(final Decoder decoder, final DataStore lock) throws IOException, DataStoreException {
         assert Thread.holdsLock(lock);
-        final List<FeatureSet> features = new ArrayList<>(3);     // Will usually contain at most one element.
-        final Map<Dimension,Boolean> done = new HashMap<>();      // Whether a dimension has already been used.
+        final var features = new ArrayList<FeatureSet>(3);      // Will usually contain at most one element.
+        final var done = new HashMap<Dimension,Boolean>();      // Whether a dimension has already been used.
         for (final Variable v : decoder.getVariables()) {
             if (v.getRole() != VariableRole.FEATURE_PROPERTY) {
                 continue;
@@ -444,7 +444,7 @@ final class FeatureSet extends DiscreteSampling {
          * support mixing both modes (e.g. X and Y coordinates as static properties and T as dynamic property).
          * The variables are reordered for making sure that X, Y, Z, T are first and in that order.
          */
-        final Reorder r = new Reorder();
+        final var r = new Reorder();
         features.add(new FeatureSet(decoder, featureName,
                      (counts != null) ? counts.read() : null,
                      r.toArray(properties, coordinates, false),
@@ -908,8 +908,8 @@ makeGeom:   if (!isEmpty) {
                     }
                     final Map<Integer,String> enumeration = p.getEnumeration();
                     if (enumeration != null && value instanceof Vector) {
-                        final Vector data = (Vector) value;
-                        final String[] meanings = new String[data.size()];
+                        final var data = (Vector) value;
+                        final var meanings = new String[data.size()];
                         for (int j=0; j<meanings.length; j++) {
                             String m = enumeration.get(data.intValue(j));
                             meanings[j] = (m != null) ? m : "";
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java
index 609cc18f5f..4bdf40c8ea 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/GridMapping.java
@@ -457,8 +457,7 @@ final class GridMapping {
                 if (c.length == 6) {
                     gridToCRS = new AffineTransform2D(c[1], c[4], c[2], c[5], c[0], c[3]);         // X_DIMENSION, Y_DIMENSION
                 } else {
-                    canNotCreate(mapping, message, new DataStoreContentException(
-                            Errors.forLocale(mapping.getLocale())
+                    canNotCreate(mapping, message, new DataStoreContentException(mapping.errors()
                                   .getString(Errors.Keys.UnexpectedArrayLength_2, 6, c.length)));
                 }
             }
@@ -519,9 +518,9 @@ final class GridMapping {
      * The WKT is presumed to use the GDAL flavor of WKT 1, and warnings are redirected to decoder listeners.
      */
     private static CoordinateReferenceSystem createFromWKT(final Node node, final String wkt) throws ParseException {
-        final WKTFormat f = new WKTFormat(Decoder.DATA_LOCALE, TimeZone.getTimeZone(node.decoder.getTimeZone()));
+        final var f = new WKTFormat(Decoder.DATA_LOCALE, TimeZone.getTimeZone(node.decoder.getTimeZone()));
         f.setConvention(org.apache.sis.io.wkt.Convention.WKT1_COMMON_UNITS);
-        final CoordinateReferenceSystem crs = (CoordinateReferenceSystem) f.parseObject(wkt);
+        final var crs = (CoordinateReferenceSystem) f.parseObject(wkt);
         final Warnings warnings = f.getWarnings();
         if (warnings != null) {
             final LogRecord record = new LogRecord(Level.WARNING, warnings.toString());
@@ -643,8 +642,8 @@ final class GridMapping {
                 explicitG2C = implicitG2C;
             } else try {
                 int count = 0;
-                MathTransform[] components = new MathTransform[3];
-                final TransformSeparator sep = new TransformSeparator(implicitG2C, variable.decoder.getMathTransformFactory());
+                var components = new MathTransform[3];
+                final var sep = new TransformSeparator(implicitG2C, variable.decoder.getMathTransformFactory());
                 if (firstAffectedCoordinate != 0) {
                     sep.addTargetDimensionRange(0, firstAffectedCoordinate);
                     components[count++] = sep.separate();
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/HYCOM.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/HYCOM.java
deleted file mode 100644
index 543f9f12e4..0000000000
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/HYCOM.java
+++ /dev/null
@@ -1,124 +0,0 @@
-/*
- * 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.storage.netcdf.base;
-
-import java.io.IOException;
-import java.time.Instant;
-import java.util.TimeZone;
-import java.util.GregorianCalendar;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.apache.sis.math.Vector;
-import org.apache.sis.measure.Units;
-import org.apache.sis.storage.DataStoreException;
-import org.apache.sis.util.privy.Constants;
-
-
-/**
- * Handles particularity of HYCOM format. It is not yet clear whether those particularities are used elsewhere or not.
- * We handle them in a separated class for now and may refactor later in a more general mechanism for providing extensions.
- *
- * @author  Martin Desruisseaux (Geomatys)
- *
- * @see <a href="https://issues.apache.org/jira/browse/SIS-315">SIS-315</a>
- */
-final class HYCOM {
-    /**
-     * The pattern to use for identifying temporal units of the form "day as %Y%m%d.%f".
-     * "%Y" is year formatted as at least four digits, "%m" is month formatted as two digits,
-     * and "%d" is day of month formatted as two digits.
-     *
-     * Example: 20181017.0000 for 2018-10-17.
-     */
-    private static final Pattern DATE_PATTERN = Pattern.compile("days?\\s+as\\s+(?-i)%Y%m%d.*", Pattern.CASE_INSENSITIVE);
-
-    /**
-     * Do not allow instantiation of this class.
-     */
-    private HYCOM() {
-    }
-
-    /**
-     * If any variable uses the "day as %Y%m%d.%f" pseudo-units, converts to a number of days since the epoch.
-     * The epoch is taken from the unit of the dimension. Example of netCDF file header:
-     *
-     * <pre class="text">
-     *     dimensions:
-     *         MT = UNLIMITED ; // (1 currently)
-     *         Y = 3298 ;
-     *         X = 4500 ;
-     *     variables:
-     *         double MT(MT) ;
-     *             MT:long_name = "time" ;
-     *             MT:units = "days since 1900-12-31 00:00:00" ;
-     *             MT:calendar = "standard" ;
-     *             MT:axis = "T" ;
-     *         double Date(MT) ;
-     *             Date:long_name = "date" ;
-     *             Date:units = "day as %Y%m%d.%f" ;
-     *             Date:C_format = "%13.4f" ;
-     *             Date:FORTRAN_format = "(f13.4)" ;
-     *     data:
-     *         MT = 43024 ;
-     *         Date = 20181017.0000 ;</pre>
-     *
-     * In this example, the real units of {@code Date(MT)} will be taken from {@code MT(MT)}, which is
-     * "days since 1900-12-31 00:00:00".
-     */
-    static void convert(final Decoder decoder, final Variable[] variables) throws IOException, DataStoreException {
-        Matcher matcher = null;
-        for (final Variable variable : variables) {
-            if (variable.getNumDimensions() == 1) {
-                final String units = variable.getUnitsString();
-                if (units != null) {
-                    if (matcher == null) {
-                        matcher = DATE_PATTERN.matcher(units);
-                    } else {
-                        matcher.reset(units);
-                    }
-                    if (matcher.matches()) {
-                        final Dimension dimension = variable.getGridDimensions().get(0);
-                        Instant epoch = variable.setUnit(decoder.findVariable(dimension.getName()), Units.DAY);
-                        if (epoch == null) {
-                            epoch = Instant.EPOCH;
-                        }
-                        final long origin = epoch.toEpochMilli();
-                        /*
-                         * Convert all dates into numbers of days since the epoch.
-                         */
-                        Vector values = variable.read();
-                        final double[] times = new double[values.size()];
-                        final GregorianCalendar calendar = new GregorianCalendar(TimeZone.getTimeZone(decoder.getTimeZone()), Decoder.DATA_LOCALE);
-                        calendar.clear();
-                        for (int i=0; i<times.length; i++) {
-                            double time = values.doubleValue(i);                            // Date encoded as a double (e.g. 20181017)
-                            long date = (long) time;                                        // Round toward zero.
-                            time -= date;                                                   // Fractional part of the day.
-                            int day   = (int) (date % 100); date /= 100;
-                            int month = (int) (date % 100); date /= 100;
-                            calendar.set(Math.toIntExact(date), month - 1, day, 0, 0, 0);
-                            date = calendar.getTimeInMillis() - origin;                     // Milliseconds since epoch.
-                            time += date / (double) Constants.MILLISECONDS_PER_DAY;
-                            times[i] = time;
-                        }
-                        variable.setValues(times);
-                    }
-                }
-            }
-        }
-    }
-}
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java
index fded643327..4c56220944 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Node.java
@@ -16,7 +16,6 @@
  */
 package org.apache.sis.storage.netcdf.base;
 
-import java.util.Locale;
 import java.util.Collection;
 import org.apache.sis.math.Vector;
 import org.apache.sis.math.DecimalFunctions;
@@ -252,31 +251,13 @@ public abstract class Node extends NamedElement {
         }
     }
 
-    /**
-     * Returns the locale to use for warnings and error messages.
-     *
-     * @return the locale for warnings and error messages.
-     */
-    protected final Locale getLocale() {
-        return decoder.listeners.getLocale();
-    }
-
-    /**
-     * Returns the resources to use for warnings or error messages.
-     *
-     * @return the resources for the locales specified to the decoder.
-     */
-    protected final Resources resources() {
-        return Resources.forLocale(getLocale());
-    }
-
     /**
      * Returns the resources to use for error messages.
      *
      * @return the resources for error messages using the locales specified to the decoder.
      */
     final Errors errors() {
-        return Errors.forLocale(getLocale());
+        return Errors.forLocale(decoder.getLocale());
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java
index 8153d7dced..7288cdb144 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/Variable.java
@@ -21,6 +21,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.ArrayList;
 import java.util.regex.Pattern;
+import java.lang.reflect.Array;
 import java.io.IOException;
 import java.time.Instant;
 import ucar.nc2.constants.CDM;      // String constants are copied by the compiler with no UCAR reference left.
@@ -65,7 +66,7 @@ public abstract class Variable extends Node {
      * <p>All shared vectors shall be considered read-only.</p>
      *
      * @see #read()
-     * @see #setValues(Object)
+     * @see #setValues(Object, boolean)
      */
     private static final WeakHashSet<Vector> SHARED_VECTORS = new WeakHashSet<>(Vector.class);
 
@@ -166,7 +167,7 @@ public abstract class Variable extends Node {
      * (for example the values in coordinate system axes).
      *
      * @see #read()
-     * @see #setValues(Object)
+     * @see #setValues(Object, boolean)
      */
     private transient Vector values;
 
@@ -176,12 +177,12 @@ public abstract class Variable extends Node {
      * This is a different instance if this variable is a two-dimensional character array, in which case this field
      * is an instance of {@code List<String>}.
      *
-     * The difference between {@code values} and {@code valuesAnyType} is that {@code values.get(i)} may throw
-     * {@link NumberFormatException} because it always try to return its elements as {@link Number} instances,
-     * while {@code valuesAnyType.get(i)} can return {@link String} instances.
+     * <p>The difference between {@code values} and {@code valuesAnyType} is that {@code values.get(i)} may throw
+     * {@link NumberFormatException} because it always tries to return its elements as {@link Number} instances,
+     * while {@code valuesAnyType.get(i)} can return {@link String} instances.</p>
      *
      * @see #readAnyType()
-     * @see #setValues(Object)
+     * @see #setValues(Object, boolean)
      */
     private transient List<?> valuesAnyType;
 
@@ -356,26 +357,18 @@ public abstract class Variable extends Node {
     protected abstract Unit<?> parseUnit(String symbols) throws Exception;
 
     /**
-     * Sets the unit of measurement and the epoch to the same value as the given variable.
-     * This method is not used in CF-compliant files; it is reserved for the handling of some
-     * particular conventions, for example {@link HYCOM}.
+     * Sets the unit of measurement to the given value. This method is not used in CF-compliant files.
+     * It is reserved for the handling of some particular conventions, for example HYCOM.
      *
-     * @param  other      the variable from which to copy unit and epoch, or {@code null} if none.
-     * @param  overwrite  if non-null, set to the given unit instead of the unit of {@code other}.
-     * @return the epoch (may be {@code null}).
+     * @param  unit   the new unit of measurement.
+     * @param  epich  the epoch if the unit is temporal, or {@code null} otherwise.
      *
      * @see #getUnit()
      */
-    final Instant setUnit(final Variable other, Unit<?> overwrite) {
-        if (other != null) {
-            unit  = other.getUnit();        // May compute the epoch as a side effect.
-            epoch = other.epoch;
-        }
-        if (overwrite != null) {
-            unit = overwrite;
-        }
+    final void setUnit(final Unit<?> unit, final Instant epoch) {
+        this.unit  = unit;
+        this.epoch = epoch;
         unitParsed = true;
-        return epoch;
     }
 
     /**
@@ -483,7 +476,7 @@ public abstract class Variable extends Node {
      * @see #STRING_DIMENSION
      */
     final boolean isString() {
-        return getDataType() == DataType.CHAR && getNumDimensions() >= STRING_DIMENSION;
+        return getNumDimensions() >= STRING_DIMENSION && getDataType() == DataType.CHAR;
     }
 
     /**
@@ -1004,7 +997,7 @@ public abstract class Variable extends Node {
     @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public final Vector read() throws IOException, DataStoreException {
         if (values == null) {
-            setValues(readFully());
+            setValues(readFully(), false);
         }
         return values;
     }
@@ -1012,9 +1005,12 @@ public abstract class Variable extends Node {
     /**
      * Reads all the data for this variable and returns them as a list of any object.
      * The difference between {@code read()} and {@code readAnyType()} is that {@code vector.get(i)} may throw
-     * {@link NumberFormatException} because it always try to return its elements as {@link Number} instances,
+     * {@link NumberFormatException} because it always tries to return its elements as {@link Number} instances,
      * while {@code list.get(i)} can return {@link String} instances.
      *
+     * @todo Consider extending to {@link java.time} objects as well. It would be useful in particular for
+     *       climatological data, where objects may be {@link java.time.Month} or {@link java.time.MonthDay}.
+     *
      * @return the data as a list of numbers or strings.
      * @throws IOException if an error occurred while reading the data.
      * @throws DataStoreException if a logical error occurred.
@@ -1023,7 +1019,7 @@ public abstract class Variable extends Node {
     @SuppressWarnings("ReturnOfCollectionOrArrayField")
     public final List<?> readAnyType() throws IOException, DataStoreException {
         if (valuesAnyType == null) {
-            setValues(readFully());
+            setValues(readFully(), false);
         }
         return valuesAnyType;
     }
@@ -1056,6 +1052,8 @@ public abstract class Variable extends Node {
      * Reads a subsampled sub-area of the variable and returns them as a list of any object.
      * Elements in the returned list may be {@link Number} or {@link String} instances.
      *
+     * @todo Consider extending to {@link java.time} objects as well.
+     *
      * @param  area         indices of cell values to read along each dimension, in "natural" order.
      * @param  subsampling  subsampling along each dimension, or {@code null} if none.
      * @return the data as a list of {@link Number} or {@link String} instances.
@@ -1076,28 +1074,44 @@ public abstract class Variable extends Node {
     protected abstract Object readFully() throws IOException, DataStoreException;
 
     /**
-     * Sets the values in this variable. The values are normally read from the netCDF file by the {@link #read()} method,
-     * but this {@code setValues(Object)} method may also be invoked if the caller wants to overwrite those values.
+     * Sets the values in this variable. The values are normally read from the netCDF file by {@link #read()},
+     * but this {@code setValues(…)} method may also be invoked if the caller wants to overwrite those values.
      *
-     * @param  array  the values as an array of primitive type (for example {@code float[]}.
+     * @param  array          the values as an array of primitive type (for example {@code float[]}.
+     * @param  forceNumerics  whether to force the replacement of character strings by real numbers.
      * @throws ArithmeticException if the dimensions of this variable are too large.
      */
-    final void setValues(final Object array) {
+    final void setValues(final Object array, final boolean forceNumerics) {
         final DataType dataType = getDataType();
         if (dataType == DataType.CHAR) {
             int n = getNumDimensions();
             if (n >= STRING_DIMENSION) {
                 final List<Dimension> dimensions = getGridDimensions();
-                final int length = Math.toIntExact(dimensions.get(--n).length());
-                long count = dimensions.get(--n).length();
-                while (n > 0) {
+                final int length = Math.toIntExact(dimensions.get(--n).length());   // Number of characters per value.
+                long count = dimensions.get(--n).length();                          // Number of values.
+                while (n > 0) {                                                     // In case of matrix of strings.
                     count = Math.multiplyExact(count, dimensions.get(--n).length());
                 }
-                final String[] strings = createStringArray(array, Math.toIntExact(count), length);
                 /*
-                 * Following method calls take the array reference without cloning it.
+                 * The character strings may have been replaced by real numbers by the caller.
+                 * In such case, we need to take the vector as-is.
+                 */
+                if (forceNumerics) {
+                    assert Array.getLength(array) == count : getName();
+                    values = SHARED_VECTORS.unique(Vector.create(array, false));
+                    valuesAnyType = values;
+                    return;
+                }
+                /*
+                 * Standard case. The `createStringArray(…)` method expects either `byte[]` or `char[]`,
+                 * depending on the subclass. It may throw `ClassCastException` if the array is not of
+                 * the expected class, but it should not happen unless there is a bug in our algorithm.
+                 *
+                 * The `Vector.create(…)` and `wrap(…)` method calls take the array reference without cloning it.
                  * Consequently, creating those two objects now (even if we may not use them) is reasonably cheap.
                  */
+                assert Array.getLength(array) == count * length : getName();
+                final String[] strings = createStringArray(array, Math.toIntExact(count), length);
                 values        = Vector.create(strings, false);
                 valuesAnyType = UnmodifiableArrayList.wrap(strings);
                 return;
@@ -1162,7 +1176,7 @@ public abstract class Variable extends Node {
     protected final List<String> createStringList(final Object chars, final GridExtent area) {
         final int length = Math.toIntExact(area.getSize(0));
         long count = area.getSize(1);
-        for (int i = area.getDimension(); --i >= 2;) {          // As a safety, but should never enter in this loop.
+        for (int i = area.getDimension(); --i >= STRING_DIMENSION;) {   // As a safety, but should never enter in this loop.
             count = Math.multiplyExact(count, area.getSize(i));
         }
         return UnmodifiableArrayList.wrap(createStringArray(chars, Math.toIntExact(count), length));
@@ -1272,7 +1286,7 @@ public abstract class Variable extends Node {
      * @return the exception to throw.
      */
     protected final DataStoreContentException canNotComputePosition(final ArithmeticException cause) {
-        return new DataStoreContentException(resources().getString(
+        return new DataStoreContentException(decoder.resources().getString(
                 Resources.Keys.CanNotComputeVariablePosition_2, getFilename(), getName()), cause);
     }
 
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/VariableTransformer.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/VariableTransformer.java
new file mode 100644
index 0000000000..3a15424bb4
--- /dev/null
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/base/VariableTransformer.java
@@ -0,0 +1,312 @@
+/*
+ * 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.storage.netcdf.base;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneOffset;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.measure.Unit;
+import javax.measure.UnitConverter;
+import javax.measure.quantity.Time;
+import ucar.nc2.constants.CDM;
+import org.apache.sis.math.Vector;
+import org.apache.sis.measure.Units;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.util.privy.Constants;
+
+
+/**
+ * Helper class for transforming a variable from one type to another type.
+ * This is used mostly for converting dates encoded in some calendar form
+ * to a number of temporal units since an epoch. Use cases:
+ *
+ * <ul>
+ *   <li>Time axis with temporal coordinates encoded as character strings.</li>
+ *   <li>Temporal coordinates encoded as numbers with "day as %Y%m%d.%f" pattern.</li>
+ * </ul>
+ *
+ * In the current implementation, the special cases handled by this class
+ * are detected solely from the unit of measurement.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/SIS-315">SIS-315</a>
+ */
+final class VariableTransformer {
+    /**
+     * Patterns of unit of measurements that need to be handled in a special way.
+     * This enumeration contains special cases that we have met in practice.
+     * The pattern are tested in the order of enumeration values.
+     */
+    private enum UnitPattern {
+        /**
+         * The pattern to use for identifying temporal units of the form "day as %Y%m%d.%f".
+         * "%Y" is year formatted as at least four digits, "%m" is month formatted as two digits,
+         * and "%d" is day of month formatted as two digits.
+         * For example, 20181017.0000 stands for 2018-10-17.
+         *
+         * <p>In HYCOM files, the transformations needs to be applied on "ordinary" variables.
+         * It should not be restricted to variables that are coordinate system axes.</p>
+         */
+        HYCOM("days?\\s+as\\s+(?-i)%Y%m%d.*", Units.DAY, false, (matcher) -> new int[] {100, 100}),
+
+        /**
+         * The pattern to use for identifying temporal units of the form "CCYYMMDDHHMMSS".
+         * The "CC" prefix identifies climatological time, for data repeated every year.
+         * The number of characters in each group determines the number of digits.
+         * The actual values are sometime stored as character strings.
+         *
+         * <p>The transformation will be applied on variables that are coordinate system axes.</p>
+         *
+         * @see <a href="https://www.admiralty.co.uk/defence/additional-military-layers#Specifications">Additional
+         *      Military Layers (<abbr>AML</abbr>)</a> — Integrated water column (<abbr>IWC</abbr>) Annex C
+         */
+        CLIMATOLOGICAL("C+Y+(M+)?(D+)?(H+)?(M+)?(S+)?", null, true, (matcher) -> {
+            final String order = "MDHMS";
+            final int count = matcher.groupCount();
+            final int[] bases = new int[count];
+            for (int i=0; i<count; i++) {
+                String value = matcher.group(i+1);
+                int n = value.length();
+                if (--n < 0 || Character.toUpperCase(value.charAt(0)) != order.charAt(i)) {
+                    return null;    // Fields out of order.
+                }
+                int base = 10;
+                while (--n >= 0) {
+                    if ((base *= 10) < 0) return null;      // Return null if overflow.
+                }
+                bases[i] = base;
+            }
+            return bases;
+        });
+
+        /**
+         * The compiled pattern.
+         */
+        final Pattern pattern;
+
+        /**
+         * Units of measurement implied by the pattern,
+         * or {@code null} if more analysis is needed for determining the unit.
+         */
+        final Unit<Time> unit;
+
+        /**
+         * A function providing values of 10ⁿ where <var>n</var> is the number of digits for the month, day, hour,
+         * minute and second fields, in that order. The year field is omitted because implicit. If the dates have
+         * no time of day, then the array length should be 2 instead of 5. Intermediate lengths are also allowed.
+         * Returns {@code null} if the match is invalid.
+         */
+        final Function<Matcher, int[]> fieldBases;
+
+        /**
+         * Whether to restrict the match to variables that are identified as time axis. This is a safety for avoiding
+         * false positive when the unit does not contain a clear keyword such as "days". We do not require time axis
+         * in all cases because {@link #datesToTimeSinceEpoch}, for example, needs to be applied on "ordinary"
+         * variables (i.e., variables that are not coordinate system axes).
+         */
+        final boolean requireTimeAxis;
+
+        /**
+         * Creates a new enumeration value.
+         *
+         * @param regex the regular expression to compile.
+         * @param unit  units of measurement implied by the pattern, or {@code null}.
+         */
+        private UnitPattern(final String regex, final Unit<Time> unit, final boolean requireTimeAxis,
+                            final Function<Matcher, int[]> fieldBases)
+        {
+            pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
+            this.requireTimeAxis = requireTimeAxis;
+            this.fieldBases = fieldBases;
+            this.unit = unit;
+        }
+    }
+
+    /**
+     * The decoder that produced the variables to transform.
+     */
+    private final Decoder decoder;
+
+    /**
+     * The matcher for each pattern to test, created when first needed.
+     */
+    private Matcher[] matchers;
+
+    /**
+     * Creates a new transformer for the given variable.
+     *
+     * @param  decoder the decoder that produced the variables to transform.
+     */
+    VariableTransformer(final Decoder decoder) {
+        this.decoder = decoder;
+    }
+
+    /**
+     * Analyzes and (if needed) transforms the given variable.
+     * This method applies heuristic rules for detecting the transformation to apply.
+     *
+     * @param  variable  the variable to analyze and (if needed) to transform.
+     */
+    final void analyze(final Variable variable) throws IOException, DataStoreException {
+        /*
+         * Heuristic rule #1: accept only one-dimensional variables.
+         * Character strings are two dimensional, but considered as
+         * one dimensional after each string is parsed as a number.
+         */
+        switch (variable.getNumDimensions()) {
+            default: return;
+            case 1: break;
+            case Variable.STRING_DIMENSION: {
+                if (variable.getDataType() != DataType.CHAR) return;
+                break;
+            }
+        }
+        /*
+         * Heuristic rule #2: identify the special case from the unit of measurement
+         * and the axis type. The axis type is itself determined from the axis name.
+         */
+        final String units = variable.getUnitsString();
+        if (units != null) {
+            final UnitPattern[] candidates = UnitPattern.values();
+            if (matchers == null) {
+                matchers = new Matcher[candidates.length];
+            }
+            for (final UnitPattern candidate : candidates) {
+                if (!candidate.requireTimeAxis || AxisType.valueOf(variable) == AxisType.T) {
+                    Matcher matcher = matchers[candidate.ordinal()];
+                    if (matcher == null) {
+                        matcher = candidate.pattern.matcher(units);
+                        matchers[candidate.ordinal()] = matcher;
+                    } else {
+                        matcher.reset(units);
+                    }
+                    if (matcher.matches()) {
+                        final int[] fieldBases = candidate.fieldBases.apply(matcher);
+                        if (fieldBases != null) {
+                            datesToTimeSinceEpoch(variable, candidate.unit, fieldBases,
+                                    candidate == UnitPattern.CLIMATOLOGICAL);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Converts from "Year Month Day Hours Minutes Seconds" pseudo-units to an amounts of time since the epoch.
+     * The {@code fieldBases} argument gives the values of 10ⁿ where <var>n</var> is the number of digits for
+     * the month, day, hour, minute and second fields, in that order. Trailing fields that do no exist should
+     * be omitted or have their value set to 1.
+     *
+     * @param  variable        the variable to transform.
+     * @param  unit            the target unit, or {@code null} keeping the current unit unchanged.
+     * @param  fieldBases      number of digits in month, day, hour, minute, second fields, as powers of 10.
+     * @param  climatological  whether a climatological calendar is used (data repeated each year).
+     */
+    private void datesToTimeSinceEpoch(final Variable variable, Unit<Time> unit, final int[] fieldBases,
+            final boolean climatological) throws IOException, DataStoreException
+    {
+        final Unit<Time> unitOfLast;
+        switch (fieldBases.length) {
+            case 2:  unitOfLast = Units.DAY;    break;
+            case 3:  unitOfLast = Units.HOUR;   break;
+            case 4:  unitOfLast = Units.MINUTE; break;
+            case 5:  unitOfLast = Units.SECOND; break;
+            default: return;
+        }
+        /*
+         * The unit and epoch sometime need to be taken from another variable.
+         * In the following example from HYCOM, the real unit of `Date(MT)` will be taken from `MT(MT)`:
+         *
+         *     variables:
+         *         double MT(MT)
+         *             MT:long_name = "time"
+         *             MT:units = "days since 1900-12-31 00:00:00"
+         *             MT:axis = "T"
+         *         double Date(MT)
+         *             Date:long_name = "date"
+         *             Date:units = "day as %Y%m%d.%f"
+         *     data:
+         *         MT = 43024
+         *         Date = 20181017.0000
+         */
+        Instant epoch = Instant.EPOCH;
+        final Variable axis = decoder.findVariable(variable.getGridDimensions().get(0).getName());
+        if (axis != variable && axis != null) {
+            Unit<?> t = axis.getUnit();     // Unconditional call because computes `epoch` as a side-effect.
+            if (unit == null) try {
+                unit = t.asType(Time.class);
+            } catch (ClassCastException e) {
+                decoder.illegalAttributeValue(CDM.UNITS, axis.getUnitsString(), e);
+            }
+            if (axis.epoch != null) {
+                epoch = axis.epoch;         // Need to be after the call to `getUnit()`.
+            }
+        }
+        if (unit == null) {
+            unit = Units.DAY;
+        }
+        variable.setUnit(unit, epoch);
+        /*
+         * Prepares the conversion factors. The offset takes in account the change of epoch from Unix epoch
+         * to the target epoch of the variable, including timezone shift. The `year` field is relevant only
+         * if a climatological calendar is used, in which case we need a pseudo-year.
+         */
+        final int year = climatological ? LocalDate.ofInstant(epoch, ZoneOffset.UTC).getYear() : 0;
+        double offset = epoch.toEpochMilli() + Constants.MILLIS_PER_SECOND * decoder.getTimeZone().getTotalSeconds();
+        offset = Units.MILLISECOND.getConverterTo(unit).convert(offset);
+        final UnitConverter toUnit = unitOfLast.getConverterTo(unit);
+        /*
+         * Convert all dates to amounts of time since the epoch. This code takes in account change
+         * of epoch from the Unix epoch to the target epoch of the variable, including timezone.
+         */
+        int[] fields = new int[fieldBases.length];
+        final Vector values = variable.read();
+        final var times = new double[values.size()];
+        for (int i=0; i < times.length; i++) {
+            double value = values.doubleValue(i);    // Date encoded as a double (e.g. 20181017)
+            long   time  = (long) value;             // Intentional rounding toward zero.
+            value -= time;                           // Fractional part to be added at the end.
+            for (int j = fields.length; --j >= 0;) {
+                final int base = fieldBases[j];
+                fields[j] = Math.abs((int) (time % base));    // Will apply the minus sign to the year field only.
+                time /= base;
+            }
+            /*
+             * After completion of the loop, `time` should be the year (potentially negative).
+             * Convert to a number of days since the epoch, then add hours, minutes and seconds.
+             *
+             * TODO: if we provide an API for returning `java.time` objects, then we should build
+             *       instances of `Month` or `MonthDay` below in the case of climatological data.
+             */
+            var date = LocalDate.of(Math.toIntExact(year + time), fields[0], fields[1]);
+            time = date.toEpochDay();
+            for (int j=2; j < fields.length; j++) {
+                time *= (j == 2) ? 24 : 60;
+                time += fields[j];
+            }
+            value += time - offset;
+            times[i] = toUnit.convert(value);
+        }
+        variable.setValues(times, true);
+    }
+}
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java
index dfb99b4786..e920decb07 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/ChannelDecoder.java
@@ -592,6 +592,7 @@ public final class ChannelDecoder extends Decoder {
         if (allDimensions == null) {
             throw malformedHeader();        // May happen if readDimensions(…) has not been invoked.
         }
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
         final VariableInfo[] variables = new VariableInfo[nelems];
         for (int j=0; j<nelems; j++) {
             final String name = readName();
@@ -965,8 +966,7 @@ public final class ChannelDecoder extends Decoder {
             for (final VariableInfo variable : variables) {
                 switch (variable.getRole()) {
                     case COVERAGE:
-                    case DISCRETE_COVERAGE:
-                    {
+                    case DISCRETE_COVERAGE: {
                         // If Convention.roleOf(…) overwrote the value computed by VariableInfo,
                         // remember the new value for avoiding to ask again in next loops.
                         variable.isCoordinateSystemAxis = false;
@@ -984,9 +984,9 @@ public final class ChannelDecoder extends Decoder {
              * For each variable, get its list of axes. More than one variable may have the same list of axes,
              * so we remember the previously created instances in order to share the grid geometry instances.
              */
-            final Set<VariableInfo> axes = new LinkedHashSet<>(8);
-            final Set<DimensionInfo> usedDimensions = new HashSet<>(8);
-            final Map<GridInfo,GridInfo> shared = new LinkedHashMap<>();
+            final var axes = new LinkedHashSet<VariableInfo>(8);
+            final var usedDimensions = new HashSet<DimensionInfo>(8);
+            final var shared = new LinkedHashMap<GridInfo,GridInfo>();
 nextVar:    for (final VariableInfo variable : variables) {
                 if (variable.isCoordinateSystemAxis || variable.dimensions.length == 0) {
                     continue;
@@ -1072,7 +1072,7 @@ nextVar:    for (final VariableInfo variable : variables) {
      */
     @Override
     public String toString() {
-        final StringBuilder buffer = new StringBuilder();
+        final var buffer = new StringBuilder();
         buffer.append("SIS driver: “").append(getFilename()).append('”');
         if (!input.channel.isOpen()) {
             buffer.append(" (closed)");
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/GridInfo.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/GridInfo.java
index c0250c6d4e..1596467eac 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/GridInfo.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/GridInfo.java
@@ -19,8 +19,8 @@ package org.apache.sis.storage.netcdf.classic;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import java.util.TreeMap;
-import java.util.SortedMap;
 import ucar.nc2.constants.CF;       // String constants are copied by the compiler with no UCAR reference left.
 import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.DataStoreException;
@@ -30,7 +30,6 @@ import org.apache.sis.storage.netcdf.base.Grid;
 import org.apache.sis.storage.netcdf.base.Decoder;
 import org.apache.sis.storage.netcdf.base.Dimension;
 import org.apache.sis.storage.netcdf.internal.Resources;
-import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.privy.UnmodifiableArrayList;
 
 
@@ -180,11 +179,12 @@ next:       for (final String name : axisNames) {
          * This is often the reverse order of range indices, but not necessarily. The intent is to reduce the
          * number of disk seek operations. Data loading may happen in this method through Axis constructor.
          */
-        final SortedMap<VariableInfo,Integer> variables = new TreeMap<>();
+        final var variables = new TreeMap<VariableInfo,Integer>();
         for (int i=0; i<range.length; i++) {
             final VariableInfo v = range[i];
             if (variables.put(v, i) != null) {
-                throw new DataStoreContentException(Resources.format(Resources.Keys.DuplicatedAxis_2, getFilename(), v.getName()));
+                throw new DataStoreContentException(decoder.resources().getString(
+                        Resources.Keys.DuplicatedAxis_2, getFilename(), v.getName()));
             }
         }
         /*
@@ -192,13 +192,21 @@ next:       for (final String name : axisNames) {
          * So `sourceDim` is the grid (domain) dimension and `targetDim` is the CRS (range) dimension.
          */
         final Axis[] axes = new Axis[range.length];
-        for (final SortedMap.Entry<VariableInfo,Integer> entry : variables.entrySet()) {
+        for (final Map.Entry<VariableInfo,Integer> entry : variables.entrySet()) {
             final int targetDim = entry.getValue();
             final VariableInfo axis = entry.getKey();
             /*
              * Get the grid dimensions (part of the "domain" in UCAR terminology) used for computing
              * the coordinate values along the current axis. There is exactly 1 such grid dimension in
              * straightforward netCDF files. However, some more complex files may have 2 dimensions.
+             *
+             * An axis may have two-dimensions but be conceptually one-dimensional if the coordinate values
+             * are given as character strings. While rare, this is sometime observed in practice for dates.
+             * In such case, the dimension used for indexing the characters in each value should not appear
+             * in the `domain` field of this grid. Therefore, that artificial dimension should be discarded
+             * by the loop below. This is okay because either the strings are automatically parsed as numbers
+             * by `org.apache.sis.math.ArrayVector.ASCII`, or either that variable have already been converted
+             * to a one-dimensional vector of numbers by `VariableTransformer`.
              */
             int i = 0;
             final DimensionInfo[] axisDomain = axis.dimensions;
@@ -213,8 +221,8 @@ next:       for (final String name : axisNames) {
                     }
                 }
             }
-            axes[targetDim] = new Axis(AxisType.abbreviation(axis), axis.getAttributeAsString(CF.POSITIVE),
-                                       ArraysExt.resize(indices, i), ArraysExt.resize(sizes, i), axis);
+            final char abbreviation = AxisType.abbreviation(axis);
+            axes[targetDim] = new Axis(abbreviation, axis.getAttributeAsString(CF.POSITIVE), indices, sizes, i, axis);
         }
         return axes;
     }
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java
index 5473325f93..3aa85d6251 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/classic/VariableInfo.java
@@ -62,7 +62,7 @@ import org.apache.sis.math.Vector;
 /**
  * Description of a variable found in a netCDF file.
  * The natural ordering of {@code VariableInfo} is the order in which the variables appear in the stream of bytes
- * that make the netCDF file. Reading variables in natural order reduces the number of channel seek operations.
+ * that makes the netCDF file. Reading variables in natural order reduces the number of channel seek operations.
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
@@ -218,7 +218,7 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
                     offsetToNextRecord = Math.multiplyExact(offsetToNextRecord, dim.length());
                 } else if (i != 0) {
                     // Unlimited dimension, if any, must be first in a netCDF 3 classic format.
-                    throw new DataStoreContentException(getLocale(), Decoder.FORMAT_NAME, input.filename, null);
+                    throw new DataStoreContentException(decoder.getLocale(), Decoder.FORMAT_NAME, input.filename, null);
                 }
             }
             reader = new HyperRectangleReader(dataType.number, input);
@@ -228,7 +228,7 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
         }
         /*
          * If the value that we computed ourselves does not match the value declared in the netCDF file,
-         * maybe for some reason the writer used a different layout.  For example, maybe it inserted some
+         * maybe for some reason the writer used a different layout. For example, maybe it inserted some
          * additional padding.
          */
         if (size != -1) {                           // Maximal unsigned value, means possible overflow.
@@ -249,21 +249,23 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
          * as its dimension. But the "_CoordinateAxisType" attribute is often used for making explicit that a
          * variable is an axis. We check that case before to check variable name.
          */
-        isCoordinateSystemAxis = (dimensions.length == 1 || dimensions.length == 2) && (getAxisType() != null);
-        /*
-         * If the "_CoordinateAliasForDimension" attribute is defined, then its value will be used
-         * instead of the variable name when determining if the variable is a coordinate system axis.
-         * "_CoordinateVariableAlias" seems to be a legacy attribute name for the same purpose.
-         */
-        if (!isCoordinateSystemAxis && dimensions.length == 1) {
-            Object value = getAttributeValue(_Coordinate.AliasForDimension, "_coordinatealiasfordimension");
-            if (value == null) {
-                value = getAttributeValue("_CoordinateVariableAlias", "_coordinatevariablealias");
+        if (dimensions.length == 1 || dimensions.length == 2) {
+            isCoordinateSystemAxis = (getAxisType() != null);
+            if (!isCoordinateSystemAxis && (dimensions.length == 1 || dataType == DataType.CHAR)) {
+                /*
+                 * If the "_CoordinateAliasForDimension" attribute is defined, then its value will be used
+                 * instead of the variable name when determining if the variable is a coordinate system axis.
+                 * "_CoordinateVariableAlias" seems to be a legacy attribute name for the same purpose.
+                 */
+                Object value = getAttributeValue(_Coordinate.AliasForDimension, "_coordinatealiasfordimension");
                 if (value == null) {
-                    value = name;
+                    value = getAttributeValue("_CoordinateVariableAlias", "_coordinatevariablealias");
+                    if (value == null) {
+                        value = name;
+                    }
                 }
+                isCoordinateSystemAxis = dimensions[0].name.equals(value);
             }
-            isCoordinateSystemAxis = dimensions[0].name.equals(value);
         }
         /*
          * Rewrite the enumeration names as an array for avoiding to parse the string if this information
@@ -736,7 +738,7 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
             subsampling = new long[dimension];
             Arrays.fill(subsampling, 1);
         }
-        final Region region = new Region(size, lower, upper, subsampling);
+        final var region = new Region(size, lower, upper, subsampling);
         /*
          * If this variable uses the unlimited dimension, we have to skip the records of all other unlimited variables
          * before to reach the next record of this variable.  Current implementation can do that only if the number of
@@ -835,7 +837,7 @@ final class VariableInfo extends Variable implements Comparable<VariableInfo> {
      * Returns the error message for an unknown data type.
      */
     private String unknownType() {
-        return resources().getString(Resources.Keys.UnsupportedDataType_3, getFilename(), name, dataType);
+        return decoder.resources().getString(Resources.Keys.UnsupportedDataType_3, getFilename(), name, dataType);
     }
 
     /**
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/GridWrapper.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/GridWrapper.java
index 89e988064c..e893ac60e0 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/GridWrapper.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/GridWrapper.java
@@ -304,8 +304,7 @@ next:       for (final String name : axisNames) {
                  */
             }
             if (i != 0) {   // Variables with 0 dimensions sometimes happen.
-                axes[axisCount++] = new Axis(abbreviation, axis.getPositive(),
-                        ArraysExt.resize(indices, i), ArraysExt.resize(sizes, i), wrapper);
+                axes[axisCount++] = new Axis(abbreviation, axis.getPositive(), indices, sizes, i, wrapper);
             }
         }
         return ArraysExt.resize(axes, axisCount);
diff --git a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/VariableWrapper.java b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/VariableWrapper.java
index fba9001d16..ed90e30614 100644
--- a/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/VariableWrapper.java
+++ b/endorsed/src/org.apache.sis.storage.netcdf/main/org/apache/sis/storage/netcdf/ucar/VariableWrapper.java
@@ -369,6 +369,7 @@ final class VariableWrapper extends org.apache.sis.storage.netcdf.base.Variable
     /**
      * Returns the single value or vector of values for the given attribute, or {@code null} if none.
      * The returned value can be an instance of {@link String}, {@link Number}, {@link Vector} or {@code String[]}.
+     * The search is case-insensitive.
      *
      * @param  attributeName  the name of the attribute for which to get the values.
      * @return value(s) for the named attribute, or {@code null} if none.