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 <martin.desruisse...@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.

Reply via email to