/*
* GeoTools - The Open Source Java GIS Toolkit
* http://geotools.org
*
* (C) 2007-2008, Open Source Geospatial Foundation (OSGeo)
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotools.image.io.netcdf;
import java.text.DateFormat;
import java.text.Format;
import java.text.NumberFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import javax.imageio.ImageReader;
import org.opengis.referencing.cs.AxisDirection;
import ucar.nc2.Variable;
import ucar.nc2.Attribute;
import ucar.nc2.dataset.AxisType;
import ucar.nc2.dataset.CoordinateAxis;
import ucar.nc2.dataset.CoordinateAxis1D;
import ucar.nc2.dataset.CoordinateSystem;
import ucar.nc2.dataset.NetcdfDataset;
import ucar.nc2.dataset.VariableDS;
import org.geotools.image.io.metadata.Axis;
import org.geotools.image.io.metadata.ImageGeometry;
import org.geotools.image.io.metadata.ImageReferencing;
import org.geotools.image.io.metadata.MetadataAccessor;
import org.geotools.image.io.metadata.GeographicMetadata;
import org.geotools.image.io.metadata.GeographicMetadataFormat;
import org.geotools.util.logging.LoggedFormat;
import org.geotools.resources.i18n.Errors;
import org.geotools.resources.i18n.ErrorKeys;
/**
* Metadata from NetCDF file. This implementation assumes that the NetCDF file follows the
* <A HREF="http://www.cfconventions.org/">CF Metadata conventions</A>.
* <p>
* <b>Limitation:</b>
* Current implementation retains only the first {@linkplain CoordinateSystem coordinate system}
* found in the NetCDF file or for a given variable. The {@link org.geotools.coverage.io} package
* would not know what to do with the extra coordinate systems anyway.
*
* @since 2.4
* @source $URL$
* @version $Id$
* @author Martin Desruisseaux
*/
public class NetcdfMetadata extends GeographicMetadata {
/**
* Forces usage of UCAR libraries in some places where we use our own code instead.
* This may result in rounding errors and absence of information regarding fill values,
* but is useful for checking if we are doing the right thing compared to the UCAR way.
*/
private static final boolean USE_UCAR_LIB = false;
/**
* The mapping between UCAR axis type and ISO axis directions.
*/
private static final Map<AxisType,AxisDirection> DIRECTIONS = new HashMap<AxisType,AxisDirection>(16);
static {
add(AxisType.Time, AxisDirection.FUTURE);
add(AxisType.GeoX, AxisDirection.EAST);
add(AxisType.GeoY, AxisDirection.NORTH);
add(AxisType.GeoZ, AxisDirection.UP);
add(AxisType.Lat, AxisDirection.NORTH);
add(AxisType.Lon, AxisDirection.EAST);
add(AxisType.Height, AxisDirection.UP);
add(AxisType.Pressure, AxisDirection.UP);
}
/**
* Adds a mapping between UCAR type and ISO direction.
*/
private static void add(final AxisType type, final AxisDirection direction) {
if (DIRECTIONS.put(type, direction) != null) {
throw new IllegalArgumentException(String.valueOf(type));
}
}
/**
* Creates metadata from the specified file. This constructor is typically invoked
* for creating {@linkplain NetcdfReader#getStreamMetadata stream metadata}. Note that
* {@link ucar.nc2.dataset.CoordSysBuilder#addCoordinateSystems} should have been invoked
* (if needed) before this constructor.
*/
public NetcdfMetadata(final ImageReader reader, final NetcdfDataset file) {
super(reader);
final List<CoordinateSystem> systems = file.getCoordinateSystems();
if (!systems.isEmpty()) {
addCoordinateSystem(systems.get(0));
}
}
/**
* Creates metadata from the specified file. This constructor is typically invoked
* for creating {@linkplain NetcdfReader#getImageMetadata image metadata}. Note that
* {@link ucar.nc2.dataset.CoordSysBuilder#addCoordinateSystems} should have been invoked
* (if needed) before this constructor.
*/
public NetcdfMetadata(final ImageReader reader, final VariableDS variable) {
super(reader);
final List<CoordinateSystem> systems = variable.getCoordinateSystems();
if (!systems.isEmpty()) {
addCoordinateSystem(systems.get(0));
}
setSampleType(GeographicMetadataFormat.PACKED);
addSampleDimension(variable);
}
/**
* Adds the specified coordinate system. Current implementation can adds at most one
* coordinate system, but this limitation may be revisited in a future Geotools version.
*
* @param cs The coordinate system to add.
*/
public void addCoordinateSystem(final CoordinateSystem cs) {
String crsType, csType;
if (cs.isLatLon()) {
crsType = cs.hasVerticalAxis() ? GeographicMetadataFormat.GEOGRAPHIC_3D
: GeographicMetadataFormat.GEOGRAPHIC;
csType = GeographicMetadataFormat.ELLIPSOIDAL;
} else if (cs.isGeoXY()) {
crsType = cs.hasVerticalAxis() ? GeographicMetadataFormat.PROJECTED_3D
: GeographicMetadataFormat.PROJECTED;
csType = GeographicMetadataFormat.CARTESIAN;
} else {
crsType = null;
csType = null;
}
final ImageReferencing referencing = getReferencing();
referencing.setCoordinateReferenceSystem(null, crsType);
referencing.setCoordinateSystem(cs.getName(), csType);
final ImageGeometry geometry = getGeometry();
geometry.setPixelOrientation("center");
/*
* Adds the axis in reverse order, because the NetCDF image reader put the last
* dimensions in the rendered image. Typical NetCDF convention is to put axis in
* the (time, depth, latitude, longitude) order, which typically maps to
* (longitude, latitude, depth, time) order in Geotools referencing framework.
*/
final List<CoordinateAxis> axis = cs.getCoordinateAxes();
for (int i=axis.size(); --i>=0;) {
addCoordinateAxis(axis.get(i));
}
}
/**
* Gets the name, as the "description", "title" or "standard name"
* attribute if possible, or as the variable name otherwise.
*/
private static String getName(final Variable variable) {
String name = variable.getDescription();
if (name == null || (name=name.trim()).length() == 0) {
name = variable.getName();
}
return name;
}
/**
* Adds the specified coordinate axis. This method is invoked recursively
* by {@link #addCoordinateSystem}.
*
* @param axis The axis to add.
*/
public void addCoordinateAxis(final CoordinateAxis axis) {
final String name = getName(axis);
final AxisType type = axis.getAxisType();
String units = axis.getUnitsString();
/*
* Gets the axis direction, taking in account the possible reversal or vertical axis.
* Note that geographic and projected CRS have the same directions. We can distinguish
* them either using the ISO CRS type ("geographic" or "projected"), the ISO CS type
* ("ellipsoidal" or "cartesian") or the units ("degrees" or "m").
*/
String direction = null;
AxisDirection directionCode = DIRECTIONS.get(type);
if (directionCode != null) {
if (CoordinateAxis.POSITIVE_DOWN.equalsIgnoreCase(axis.getPositive())) {
directionCode = directionCode.opposite();
}
direction = directionCode.name();
final int offset = units.lastIndexOf('_');
if (offset >= 0) {
final String unitsDirection = units.substring(offset + 1).trim();
final String opposite = directionCode.opposite().name();
if (unitsDirection.equalsIgnoreCase(opposite)) {
warning("addCoordinateAxis", ErrorKeys.INCONSISTENT_AXIS_ORIENTATION_$2,
new String[] {name, direction});
direction = opposite;
}
if (unitsDirection.equalsIgnoreCase(direction)) {
units = units.substring(0, offset).trim();
}
}
}
/*
* Gets the axis origin. In the particular case of time axis, units are typically
* written in the form "days since 1990-01-01 00:00:00". We extract the part before
* "since" as the units and the part after "since" as the date.
*/
final Axis axisNode = getReferencing().addAxis(name, direction, units);
if (AxisType.Time.equals(type)) {
String origin = null;
final String[] unitsParts = units.split("(?i)\\s+since\\s+");
if (unitsParts.length == 2) {
units = unitsParts[0].trim();
origin = unitsParts[1].trim();
} else {
final Attribute attribute = axis.findAttribute("time_origin");
if (attribute != null) {
origin = attribute.getStringValue();
}
}
Date epoch = null;
if (origin != null) {
origin = MetadataAccessor.trimFractionalPart(origin);
epoch = parse(type, origin, Date.class, "addCoordinateAxis");
}
axisNode.setTimeOrigin(epoch);
axisNode.setUnits(units);
}
/*
* If the axis is not numeric, we can't process any further.
* If it is, then adds the coordinate and index ranges.
*/
if (!axis.isNumeric()) {
return;
}
if (axis instanceof CoordinateAxis1D) {
final CoordinateAxis1D axis1D = (CoordinateAxis1D) axis;
final ImageGeometry geometry = getGeometry();
final double[] values = axis1D.getCoordValues();
geometry.addOrdinates(0, values);
}
}
/**
* Adds sample dimension information for the specified variable.
*
* @param variable The variable to add as a sample dimension.
*/
public void addSampleDimension(final VariableDS variable) {
final VariableMetadata m;
if (USE_UCAR_LIB) {
m = new VariableMetadata(variable);
} else {
m = new VariableMetadata(variable, forcePacking("valid_range"));
}
m.copyTo(addBand(getName(variable)));
}
/**
* Parses the given string as a value along the specified axis.
*
* @param type The type of the axis.
* @param value The value along that axis.
* @param expected The expected type.
* @return The value after parsing.
*/
private <T> T parse(final AxisType type, String value, final Class<T> expected, final String caller) {
final LoggedFormat<T> format = createLoggedFormat(getAxisFormat(type, value), expected);
format.setLogger("org.geotools.image.io.netcdf");
format.setCaller(NetcdfMetadata.class, caller);
return format.parse(value);
}
/**
* Returns a format to use for parsing values along the specified axis type. This method
* is invoked when parsing the date part of axis units like "<cite>days since 1990-01-01
* 00:00:00</cite>". Subclasses should override this method if the date part is formatted
* in a different way. The default implementation returns the following formats:
* <p>
* <ul>
* <li>For {@linkplain AxisType#Time time axis}, a {@link DateFormat} using the
* {@code "yyyy-MM-dd HH:mm:ss"} pattern in UTC {@linkplain TimeZone timezone}.</li>
* <li>For all other kind of axis, a {@link NumberFormat}.</li>
* </ul>
* <p>
* The {@linkplain Locale#CANADA Canada locale} is used by default for most formats because
* it is relatively close to ISO (for example regarding days and months order in dates) while
* using the English symbols.
*
* @param type The type of the axis.
* @param prototype An example of the values to be parsed. Implementations may parse this
* prototype when the axis type alone is not suffisient. For example the {@linkplain
* AxisType#Time time axis type} should uses the {@code "yyyy-MM-dd"} date pattern,
* but some files do not follow this convention and use the default local instead.
* @return The format for parsing values along the axis.
*/
protected Format getAxisFormat(final AxisType type, final String prototype) {
if (!type.equals(AxisType.Time)) {
return NumberFormat.getNumberInstance(Locale.CANADA);
}
char dateSeparator = '-'; // The separator used in ISO format.
boolean yearLast = false; // Year is first in ISO pattern.
boolean namedMonth = false; // Months are numbers in the ISO pattern.
if (prototype != null) {
/*
* Performs a quick check on the prototype content. If the prototype seems to use a
* different date separator than the ISO one, we will adjust the pattern accordingly.
* Also checks if the year seems to appears last rather than first, and if the month
* seems to be written using letters rather than digits.
*/
int field = 1;
int digitCount = 0;
final int length = prototype.length();
for (int i=0; i<length; i++) {
final char c = prototype.charAt(i);
if (Character.isWhitespace(c)) {
break; // Checks only the dates, ignore the hours.
}
if (Character.isDigit(c)) {
digitCount++;
continue; // Digits are legal in all cases.
}
if (field == 2 && Character.isLetter(c)) {
namedMonth = true;
continue; // Letters are legal for month only.
}
if (field == 1) {
dateSeparator = c;
}
digitCount = 0;
field++;
}
if (digitCount >= 4) {
yearLast = true;
}
}
String pattern;
if (yearLast) {
pattern = namedMonth ? "dd-MMM-yyyy" : "dd-MM-yyyy";
} else {
pattern = namedMonth ? "yyyy-MMM-dd" : "yyyy-MM-dd";
}
pattern = pattern.replace('-', dateSeparator);
pattern += " HH:mm:ss";
final DateFormat format = new SimpleDateFormat(pattern, Locale.CANADA);
format.setTimeZone(TimeZone.getTimeZone("UTC"));
return format;
}
/**
* Returns {@code true} if an attribute (usually the <cite>valid range</cite>) should be
* converted from unpacked to packed units. The <A HREF="http://www.cfconventions.org/">CF
* Metadata conventions</A> states that valid ranges should be in packed units, but not
* every NetCDF files follow this advice in practice. The UCAR NetCDF library applies the
* following heuristic rules (quoting from {@link ucar.nc2.dataset.EnhanceScaleMissing}):
*
* <blockquote>
* If {@code valid_range} is the same type as {@code scale_factor} (actually the wider of
* {@code scale_factor} and {@code add_offset}) and this is wider than the external data,
* then it will be interpreted as being in the units of the internal (unpacked) data.
* Otherwise it is in the units of the external (packed) data.
* <blockquote>
*
* However some NetCDF files stores unpacked ranges using the same type than packed data.
* The above cited heuristic rule can not resolve those cases.
* <p>
* If this method returns {@code true}, then the attribute is assumed in unpacked units no
* matter what the CF convention and the heuristic rules said. If this method returns
* {@code false}, then UCAR's heuristic rules applies.
* <p>
* The default implementation returns {@code false} in all cases.
*
* @param attribute The attribute (usually {@code "valid_range"}).
* @return {@code true} if the attribute should be converted from unpacked to packed units
* regardless CF convention and UCAR's heuristic rules.
*
* @see ucar.nc2.dataset.EnhanceScaleMissing
*/
protected boolean forcePacking(final String attribute) {
return false;
}
/**
* Convenience method for logging a warning.
*/
private void warning(final String method, final int key, final Object value) {
final LogRecord record = Errors.getResources(getLocale()).
getLogRecord(Level.WARNING, key, value);
record.setSourceClassName(NetcdfMetadata.class.getName());
record.setSourceMethodName(method);
warningOccurred(record);
}
}