blob: 1ad36310ec89863af5db1f3c57fec2d86eb34b90 [file] [log] [blame]
/*
* 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;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.HashMap;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.io.IOException;
import javax.measure.Unit;
import javax.measure.UnitConverter;
import javax.measure.IncommensurableException;
import javax.measure.format.ParserException;
import org.opengis.util.CodeList;
import org.opengis.util.InternationalString;
import org.opengis.metadata.Metadata;
import org.opengis.metadata.Identifier;
import org.opengis.metadata.spatial.*;
import org.opengis.metadata.content.*;
import org.opengis.metadata.citation.*;
import org.opengis.metadata.identification.*;
import org.opengis.metadata.maintenance.ScopeCode;
import org.opengis.metadata.constraint.Restriction;
import org.opengis.referencing.cs.AxisDirection;
import org.opengis.referencing.crs.VerticalCRS;
import org.opengis.referencing.crs.CoordinateReferenceSystem;
import org.apache.sis.util.iso.Types;
import org.apache.sis.util.iso.DefaultNameFactory;
import org.apache.sis.storage.DataStoreException;
import org.apache.sis.storage.event.StoreListeners;
import org.apache.sis.metadata.iso.DefaultMetadata;
import org.apache.sis.metadata.iso.citation.*;
import org.apache.sis.metadata.iso.identification.*;
import org.apache.sis.metadata.sql.MetadataStoreException;
import org.apache.sis.internal.netcdf.Axis;
import org.apache.sis.internal.netcdf.Decoder;
import org.apache.sis.internal.netcdf.Variable;
import org.apache.sis.internal.netcdf.VariableRole;
import org.apache.sis.internal.netcdf.Grid;
import org.apache.sis.internal.storage.io.IOUtilities;
import org.apache.sis.internal.storage.MetadataBuilder;
import org.apache.sis.internal.storage.wkt.StoreFormat;
import org.apache.sis.internal.referencing.AxisDirections;
import org.apache.sis.internal.util.CollectionsExt;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.CharSequences;
import org.apache.sis.referencing.CRS;
import org.apache.sis.measure.Units;
import org.apache.sis.math.Vector;
// The following dependency is used only for static final String constants.
// Consequently the compiled class files should not have this dependency.
import ucar.nc2.constants.ACDD;
import ucar.nc2.constants.CDM;
import ucar.nc2.constants.CF;
import static java.util.Collections.singleton;
import static org.apache.sis.storage.netcdf.AttributeNames.*;
/**
* Mapping from netCDF metadata to ISO 19115-2 metadata. The {@link String} constants declared in
* the {@linkplain AttributeNames parent class} are the name of attributes examined by this class.
* The current implementation searches the attribute values in the following places, in that order:
*
* <ol>
* <li>{@code "NCISOMetadata"} group</li>
* <li>{@code "CFMetadata"} group</li>
* <li>Global attributes</li>
* <li>{@code "THREDDSMetadata"} group</li>
* </ol>
*
* The {@code "CFMetadata"} group has precedence over the global attributes because the
* {@linkplain AttributeNames#LONGITUDE longitude} and {@linkplain AttributeNames#LATITUDE latitude}
* resolutions are often more accurate in that group.
*
* <h2>Known limitations</h2>
* <ul>
* <li>{@code "degrees_west"} and {@code "degrees_south"} units not correctly handled.</li>
* <li>Units of measurement not yet declared in the {@link Band} elements.</li>
* <li>{@link AttributeNames#FLAG_VALUES} and {@link AttributeNames#FLAG_MASKS}
* not yet included in the {@link RangeElementDescription} elements.</li>
* <li>Services (WMS, WCS, OPeNDAP, THREDDS) <i>etc.</i>) and transfer options not yet declared.</li>
* </ul>
*
* @author Martin Desruisseaux (Geomatys)
* @author Thi Phuong Hao Nguyen (VNSC)
* @author Alexis Manin (Geomatys)
* @version 1.0
* @since 0.3
* @module
*/
final class MetadataReader extends MetadataBuilder {
/**
* Whether the reader should include experimental fields.
* They are fields for which we are unsure of the proper ISO 19115 location.
*/
private static final boolean EXPERIMENTAL = true;
/**
* Names of global attributes identifying services.
*/
private static final String[] SERVICES = {"wms_service", "wcs_service"};
/**
* The character to use as a separator in comma-separated list. This separator is used for parsing the
* {@link AttributeNames#KEYWORDS} attribute value for instance.
*/
private static final char SEPARATOR = ',';
/**
* The character to use for quoting strings in a comma-separated list. Quoted strings may contain comma.
*
* <div class="note"><b>Example:</b>
* John Doe, Jane Lee, "L J Smith, Jr."
* </div>
*/
private static final char QUOTE = '"';
/**
* The source of netCDF attributes from which to infer ISO metadata.
* This source is set at construction time.
*
* <p>This {@code MetadataReader} class does <strong>not</strong> close this source.
* Closing this source after usage is the user responsibility.</p>
*/
private final Decoder decoder;
/**
* The actual search path, as a subset of {@link org.apache.sis.internal.netcdf.Convention#SEARCH_PATH}
* with only the name of the groups which have been found in the NeCDF file.
*/
private final String[] searchPath;
/**
* The contact, used at metadata creation time for avoiding to construct identical objects
* more than once.
*
* <p>The point of contact is stored in the two following places. The semantic of those two
* contacts is not strictly identical, but the distinction is not used in netCDF file:</p>
*
* <ul>
* <li>{@link DefaultMetadata#getContacts()}</li>
* <li>{@link DefaultDataIdentification#getPointOfContacts()}</li>
* </ul>
*
* An object very similar is used as the creator. The point of contact and the creator
* are often identical except for their role attribute.
*/
private transient ResponsibleParty pointOfContact;
/**
* The vertical coordinate reference system to be given to the object created by {@link #addExtent()}.
* This is set to the first vertical CRS found.
*/
private VerticalCRS verticalCRS;
/**
* Creates a new <cite>netCDF to ISO</cite> mapper for the given source.
*
* @param decoder the source of netCDF attributes.
*/
MetadataReader(final Decoder decoder) {
this.decoder = decoder;
decoder.setSearchPath(decoder.convention().getSearchPath());
searchPath = decoder.getSearchPath();
}
/**
* Invoked when a non-fatal exception occurred while reading metadata.
* This method sends a record to the registered listeners if any,
* or logs the record otherwise.
*/
private void warning(final Exception e) {
decoder.listeners.warning(e);
}
/**
* Logs a warning using the localized error resource bundle for the locale given by
* {@link StoreListeners#getLocale()}.
*
* @param key one of {@link Errors.Keys} values.
*/
private void warning(final short key, final Object p1, final Object p2, final Exception e) {
final StoreListeners listeners = decoder.listeners;
listeners.warning(Errors.getResources(listeners.getLocale()).getString(key, p1, p2), e);
}
/**
* Splits comma-separated values. Leading and trailing spaces are removed for each item
* unless the item is between double quotes. Empty strings are ignored unless between double quotes.
* If a value begin with double quotes, all content will be copied verbatim until the closing double quote.
* A double quote is considered as a closing double quote if just before a comma separator (ignoring spaces).
*/
static List<String> split(final String value) {
if (value == null) {
return Collections.emptyList();
}
final List<String> items = new ArrayList<>();
int start = 0; // Index of the first character of the next item to add in the list.
int end; // Index after the last character of the next item to add in the list.
int next; // Index of the next separator (comma) after 'end'.
final int length = CharSequences.skipTrailingWhitespaces(value, 0, value.length());
split: while ((start = CharSequences.skipLeadingWhitespaces(value, start, length)) < length) {
if (value.charAt(start) == QUOTE) {
next = ++start; // Skip the quote character.
do {
end = value.indexOf(QUOTE, next); // End of quoted text, may have comma separator before.
if (end < 0) break split;
next = CharSequences.skipLeadingWhitespaces(value, end+1, length);
} while (next < length && value.charAt(next) != SEPARATOR);
} else {
next = value.indexOf(SEPARATOR, start); // Unquoted text - comma is the item separator.
if (next < 0) break;
end = CharSequences.skipTrailingWhitespaces(value, start, next);
}
if (start != end) {
items.add(value.substring(start, end));
}
start = next+1;
}
if (start < length) {
items.add(value.substring(start, length));
}
return items;
}
/**
* Trims the leading and trailing spaces of the given string.
* If the string is null, empty or contains only spaces, then this method returns {@code null}.
*/
private static String trim(String value) {
if (value != null) {
value = value.trim();
if (value.isEmpty()) {
value = null;
}
}
return value;
}
/**
* Reads the attribute value for the given name, then trims the leading and trailing spaces.
* If the value is null, empty or contains only spaces, then this method returns {@code null}.
*/
private String stringValue(final String name) {
return trim(decoder.stringValue(name));
}
/**
* Reads the numeric value for the given value, or returns {@code NaN} if none.
*/
private double numericValue(final String name) {
final Number v = decoder.numericValue(name);
return (v != null) ? v.doubleValue() : Double.NaN;
}
/**
* Returns the enumeration constant for the given name, or {@code null} if the given name is not recognized.
* In the later case, this method emits a warning.
*/
private <T extends Enum<T>> T forEnumName(final Class<T> enumType, final String name) {
final T code = Types.forEnumName(enumType, name);
if (code == null && name != null) {
warning(Errors.Keys.UnknownEnumValue_2, enumType, name, null);
}
return code;
}
/**
* Returns the code value for the given name, or {@code null} if the given name is not recognized.
* In the later case, this method emits a warning.
*/
private <T extends CodeList<T>> T forCodeName(final Class<T> codeType, final String name) {
final T code = Types.forCodeName(codeType, name, false);
if (code == null && name != null) {
/*
* CodeLists are not enums, but using the error message for enums is not completly wrong since
* if we did not allowed CodeList to create new elements, then we are using it like an enum.
*/
warning(Errors.Keys.UnknownEnumValue_2, codeType, name, null);
}
return code;
}
/**
* Adds the given element in the given collection if the element is non-null.
* If the element is non-null and the collection is null, a new collection is
* created. The given collection, or the new collection if it has been created,
* is returned.
*/
private static <T> Set<T> addIfNonNull(Set<T> collection, final T element) {
if (element != null) {
if (collection == null) {
collection = new LinkedHashSet<>(4);
}
collection.add(element);
}
return collection;
}
/**
* Returns {@code true} if the given netCDF attribute is either null or equals to the
* string value of the given metadata value.
*
* @param metadata The value stored in the metadata object.
* @param attribute The value parsed from the netCDF file.
*/
private static boolean canShare(final CharSequence metadata, final String attribute) {
return (attribute == null) || (metadata != null && metadata.toString().equals(attribute));
}
/**
* Returns {@code true} if the given netCDF attribute is either null or equals to one
* of the values in the given collection.
*
* @param metadata the value stored in the metadata object.
* @param attribute the value parsed from the netCDF file.
*/
private static boolean canShare(final Collection<String> metadata, final String attribute) {
return (attribute == null) || metadata.contains(attribute);
}
/**
* Returns {@code true} if the given URL is null, or if the given resource contains that URL.
*
* @param resource the value stored in the metadata object.
* @param url the value parsed from the netCDF file.
*/
private static boolean canShare(final OnlineResource resource, final String url) {
return (url == null) || (resource != null && canShare(resource.getLinkage().toString(), url));
}
/**
* Returns {@code true} if the given email is null, or if the given address contains that email.
*
* @param address the value stored in the metadata object.
* @param email the value parsed from the netCDF file.
*/
private static boolean canShare(final Address address, final String email) {
return (email == null) || (address != null && canShare(address.getElectronicMailAddresses(), email));
}
/**
* Creates a URI form the given path, or returns {@code null} if the given URL is null or can not be parsed.
* In the later case, a warning will be emitted.
*/
private URI createURI(final String url) {
if (url != null) try {
return new URI(url);
} catch (URISyntaxException e) {
warning(e);
}
return null;
}
/**
* Creates an {@code OnlineResource} element if the given URL is not null. Since ISO 19115
* declares the URL as a mandatory attribute, this method will ignore all other attributes
* if the given URL is null.
*
* @param url the URL (mandatory - if {@code null}, no resource will be created).
* @return the online resource, or {@code null} if the URL was null.
*/
private OnlineResource createOnlineResource(final String url) {
final URI uri = createURI(url);
if (uri == null) {
return null;
}
final DefaultOnlineResource resource = new DefaultOnlineResource(uri);
final String protocol = uri.getScheme();
resource.setProtocol(protocol);
if ("http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol)) {
resource.setApplicationProfile("web browser");
}
resource.setFunction(OnLineFunction.INFORMATION);
return resource;
}
/**
* Creates an {@code Address} element if at least one of the given attributes is non-null.
*/
private static Address createAddress(final String email) {
if (email != null) {
final DefaultAddress address = new DefaultAddress();
address.setElectronicMailAddresses(singleton(email));
return address;
}
return null;
}
/**
* Creates a {@code Contact} element if at least one of the given attributes is non-null.
*/
private static Contact createContact(final Address address, final OnlineResource url) {
if (address != null || url != null) {
final DefaultContact contact = new DefaultContact();
if (address != null) contact.setAddresses(singleton(address));
if (url != null) contact.setOnlineResources(singleton(url));
return contact;
}
return null;
}
/**
* Creates a {@code Responsibility} element if at least one of the name, email or URL attributes is defined.
* For more consistent results, the caller should restrict the {@linkplain Decoder#setSearchPath search path}
* to a single group before invoking this method.
*
* <p>Implementation note: this method tries to reuse the existing {@link #pointOfContact} instance,
* or part of it, if it is suitable.</p>
*
* @param keys the group of attribute names to use for fetching the values.
* @param isPointOfContact {@code true} if this responsible party is the "main" one. This will force the
* role to {@link Role#POINT_OF_CONTACT} and enable the use of {@code "institution"} attribute as
* a fallback if there is no value for {@link Responsible#INSTITUTION}.
* @return the responsible party, or {@code null} if none.
*
* @see AttributeNames#CREATOR
* @see AttributeNames#CONTRIBUTOR
* @see AttributeNames#PUBLISHER
*/
private ResponsibleParty createResponsibleParty(final Responsible keys, final boolean isPointOfContact) {
String individualName = stringValue(keys.NAME);
String organisationName = stringValue(keys.INSTITUTION);
final String email = stringValue(keys.EMAIL);
final String url = stringValue(keys.URL);
if (organisationName == null && isPointOfContact) {
organisationName = stringValue("institution");
}
if (individualName == null && organisationName == null && email == null && url == null) {
return null;
}
/*
* The "individual" name may actually be an institution name, either because a "*_type" attribute
* said so or because the "individual" name is the same than the institution name. In such cases,
* reorganize the names in order to avoid duplication.
*/
if (organisationName == null) {
if (isOrganisation(keys)) {
organisationName = individualName;
individualName = null;
}
} else if (organisationName.equalsIgnoreCase(individualName)) {
individualName = null;
}
Role role = forCodeName(Role.class, stringValue(keys.ROLE));
if (role == null) {
role = isPointOfContact ? Role.POINT_OF_CONTACT : keys.DEFAULT_ROLE;
}
/*
* Verify if we can share the existing 'pointOfContact' instance. This is often the case in practice.
* If we can not share the whole existing instance, we usually can share parts of it like the address.
*/
ResponsibleParty responsibility = pointOfContact;
Contact contact = null;
Address address = null;
OnlineResource resource = null;
if (responsibility != null) {
{ // Additional indentation for having the same level than SIS branches for GeoAPI snapshots (makes merges easier).
contact = responsibility.getContactInfo();
if (contact != null) {
address = contact.getAddress();
resource = contact.getOnlineResource();
}
if (!canShare(resource, url)) {
resource = null;
contact = null; // Clear the parents all the way up to the root.
responsibility = null;
}
if (!canShare(address, email)) {
address = null;
contact = null; // Clear the parents all the way up to the root.
responsibility = null;
}
if (responsibility != null) {
if (!canShare(responsibility.getOrganisationName(), organisationName) ||
!canShare(responsibility.getIndividualName(), individualName))
{
responsibility = null;
}
}
}
}
/*
* If we can not share the exiting instance, we have to build a new one. If there is both
* an individual and organisation name, then the individual is considered a member of the
* organisation. This structure shall be kept consistent with the check in the above block.
*/
if (responsibility == null) {
if (contact == null) {
if (address == null) address = createAddress(email);
if (resource == null) resource = createOnlineResource(url);
contact = createContact(address, resource);
}
if (individualName != null || organisationName != null || contact != null) { // Do not test role.
AbstractParty party = null;
if (individualName != null) party = new DefaultIndividual(individualName, null, null);
if (organisationName != null) party = new DefaultOrganisation(organisationName, null, (DefaultIndividual) party, null);
if (party == null) party = isOrganisation(keys) ? new DefaultOrganisation() : new DefaultIndividual();
if (contact != null) party.setContactInfo(singleton(contact));
responsibility = new DefaultResponsibleParty(role);
((DefaultResponsibleParty) responsibility).setParties(singleton(party));
}
}
return responsibility;
}
/**
* Returns {@code true} if the responsible party described by the given keys is an organization.
* In case of doubt, this method returns {@code false}. This is consistent with ACDD recommendation,
* which set the default value to {@code "person"}.
*/
private boolean isOrganisation(final Responsible keys) {
final String type = stringValue(keys.TYPE);
return "institution".equalsIgnoreCase(type) || "group".equalsIgnoreCase(type);
}
/**
* Adds a {@code DataIdentification/Citation} element if at least one of the required attributes is non-null.
* This method will initialize the {@link #pointOfContact} field, then reuses it if non-null and suitable.
*
* <p>This method opportunistically collects the name of all publishers.
* Those names are useful to {@link #addIdentificationInfo(Set)}.</p>
*
* @return the name of all publishers, or {@code null} if none.
*/
private Set<InternationalString> addCitation() {
String title = stringValue(TITLE);
if (title == null) {
title = stringValue("full_name"); // THREDDS attribute documented in TITLE javadoc.
if (title == null) {
title = stringValue("name"); // THREDDS attribute documented in TITLE javadoc.
if (title == null) {
title = decoder.getTitle();
}
}
}
addTitle(title);
addEdition(stringValue(PRODUCT_VERSION));
addOtherCitationDetails(stringValue(REFERENCES));
addCitationDate(decoder.dateValue(METADATA_CREATION), DateType.CREATION, Scope.ALL);
addCitationDate(decoder.dateValue(METADATA_MODIFIED), DateType.REVISION, Scope.ALL);
addCitationDate(decoder.dateValue(DATE_CREATED), DateType.CREATION, Scope.RESOURCE);
addCitationDate(decoder.dateValue(DATE_MODIFIED), DateType.REVISION, Scope.RESOURCE);
addCitationDate(decoder.dateValue(DATE_ISSUED), DateType.PUBLICATION, Scope.RESOURCE);
/*
* Add the responsible party which is declared in global attributes, or in
* the THREDDS attributes if no information was found in global attributes.
* This responsible party is taken as the point of contact.
*/
for (final String path : searchPath) {
decoder.setSearchPath(path);
final ResponsibleParty party = createResponsibleParty(CREATOR, true);
if (party != pointOfContact) {
addPointOfContact(party, Scope.RESOURCE);
if (pointOfContact == null) {
pointOfContact = party;
}
}
}
/*
* There is no distinction in netCDF files between "point of contact" and "creator".
* We take the first one as the data originator.
*/
addCitedResponsibleParty(pointOfContact, Role.ORIGINATOR);
/*
* Add the contributors only after we did one full pass over the creators. We keep those two
* loops separated in order to increase the chances that pointOfContact has been initialized
* (it may not have been initialized on the first pass).
*/
Set<InternationalString> publisher = null;
for (final String path : searchPath) {
decoder.setSearchPath(path);
final ResponsibleParty contributor = createResponsibleParty(CONTRIBUTOR, false);
if (contributor != pointOfContact) {
addCitedResponsibleParty(contributor, null);
}
final ResponsibleParty r = createResponsibleParty(PUBLISHER, false);
if (r instanceof DefaultResponsibility) {
addDistributor(r);
for (final AbstractParty party : ((DefaultResponsibility) r).getParties()) {
publisher = addIfNonNull(publisher, party.getName());
}
}
}
decoder.setSearchPath(searchPath);
return publisher;
}
/**
* Adds a {@code DataIdentification} element if at least one of the required attributes is non-null.
*
* @param publisher the publisher names, built by the caller in an opportunist way.
*/
private void addIdentificationInfo(final Set<InternationalString> publisher) throws IOException, DataStoreException {
boolean hasExtent = false;
Set<String> project = null;
Set<String> standard = null;
boolean hasDataType = false;
final Set<String> keywords = new LinkedHashSet<>();
for (final String path : searchPath) {
decoder.setSearchPath(path);
keywords.addAll(split(stringValue(KEYWORDS.TEXT)));
standard = addIfNonNull(standard, stringValue(STANDARD_NAME.TEXT));
project = addIfNonNull(project, stringValue(PROJECT));
for (final String keyword : split(stringValue(ACCESS_CONSTRAINT))) {
addAccessConstraint(forCodeName(Restriction.class, keyword));
}
addTopicCategory(forCodeName(TopicCategory.class, stringValue(TOPIC_CATEGORY)));
SpatialRepresentationType dt = forCodeName(SpatialRepresentationType.class, stringValue(DATA_TYPE));
addSpatialRepresentation(dt);
hasDataType |= (dt != null);
if (!hasExtent) {
/*
* Takes only ONE extent, because a netCDF file may declare many time the same
* extent with different precision. The groups are ordered in such a way that
* the first extent should be the most accurate one.
*/
hasExtent = addExtent();
}
}
/*
* Add spatial representation type only if it was not explicitly given in the metadata.
* The call to getGrids() may be relatively costly, so we don't want to invoke it without necessity.
*/
if (!hasDataType && decoder.getGrids().length != 0) {
addSpatialRepresentation(SpatialRepresentationType.GRID);
}
/*
* For the following properties, use only the first non-empty attribute value found on the search path.
*/
decoder.setSearchPath(searchPath);
addAbstract (stringValue(SUMMARY));
addPurpose (stringValue(PURPOSE));
addSupplementalInformation(stringValue(COMMENT));
addCredits (stringValue(ACKNOWLEDGEMENT));
addCredits (stringValue("acknowledgment")); // Legacy spelling.
addUseLimitation (stringValue(LICENSE));
addKeywords(standard, KeywordType.THEME, stringValue(STANDARD_NAME.VOCABULARY));
addKeywords(keywords, KeywordType.THEME, stringValue(KEYWORDS.VOCABULARY));
addKeywords(project, KeywordType.valueOf("PROJECT"), null);
addKeywords(publisher, KeywordType.valueOf("DATA_CENTRE"), null);
/*
* Add geospatial bounds as a geometric object. This optional operation requires
* an external library (ESRI or JTS) to be present on the classpath.
*/
final String wkt = stringValue(GEOSPATIAL_BOUNDS);
if (wkt != null) {
addBoundingPolygon(new StoreFormat(decoder.geomlib, decoder.listeners).parseGeometry(wkt,
stringValue(GEOSPATIAL_BOUNDS + "_crs"), stringValue(GEOSPATIAL_BOUNDS + "_vertical_crs")));
}
final String[] format = decoder.getFormatDescription();
String id = format[0];
if (NetcdfStoreProvider.NAME.equalsIgnoreCase(id)) try {
setFormat(NetcdfStoreProvider.NAME);
id = null;
} catch (MetadataStoreException e) {
// Will add 'id' at the end of this method.
warning(e);
}
if (format.length >= 2) {
addFormatName(format[1]);
if (format.length >= 3) {
setFormatEdition(format[2]);
}
}
addFormatName(id); // Do nothing is 'id' is null.
}
/**
* Adds information about axes and cell geometry.
* This is the {@code <mdb:spatialRepresentationInfo>} element in XML.
*
* @param cs the grid geometry (related to the netCDF coordinate system).
* @throws ArithmeticException if the size of an axis exceeds {@link Integer#MAX_VALUE}, or other overflow occurs.
*/
private void addSpatialRepresentationInfo(final Grid cs) throws IOException, DataStoreException {
final Axis[] axes = cs.getAxes(decoder);
for (int i=0; i<axes.length; i++) {
final Axis axis = axes[i];
/*
* Axes usually have exactly one dimension. However some netCDF axes are backed by a two-dimensional
* conversion grid. In such case, our Axis constructor should have ensured that the first element in
* the 'sourceDimensions' and 'sourceSizes' arrays are for the grid dimension which is most closely
* oriented toward the axis direction.
*/
if (axis.getDimension() >= 1) {
setAxisSize(i, axis.getSize());
}
final AttributeNames.Dimension attributeNames;
switch (axis.abbreviation) {
case 'λ': case 'θ': attributeNames = AttributeNames.LONGITUDE; break;
case 'φ': case 'Ω': attributeNames = AttributeNames.LATITUDE; break;
case 'h': case 'H': case 'D': attributeNames = AttributeNames.VERTICAL; break;
case 't': case 'T': attributeNames = AttributeNames.TIME; break;
default : continue;
}
final DimensionNameType name = attributeNames.DEFAULT_NAME_TYPE;
setAxisName(i, name);
final String res = stringValue(attributeNames.RESOLUTION);
if (res != null) try {
/*
* ACDD convention recommends to write units after the resolution.
* Examples: "100 meters", "0.1 degree".
*/
final int s = res.indexOf(' ');
final double value;
Unit<?> units = null;
if (s < 0) {
value = numericValue(attributeNames.RESOLUTION);
} else {
value = Double.parseDouble(res.substring(0, s).trim());
final String symbol = res.substring(s+1).trim();
if (!symbol.isEmpty()) try {
units = Units.valueOf(symbol);
} catch (ParserException e) {
warning(Errors.Keys.CanNotAssignUnitToDimension_2, name, units, e);
}
}
setAxisResolution(i, value, units);
} catch (NumberFormatException e) {
warning(e);
}
}
setCellGeometry(CellGeometry.AREA);
}
/**
* Adds the extent declared in the current group. For more consistent results, the caller should restrict
* the {@linkplain Decoder#setSearchPath search path} to a single group before invoking this method.
* The {@link #verticalCRS} field should have been set before to invoke this method.
*
* @return {@code true} if at least one numerical value has been added.
*/
private boolean addExtent() {
addExtent(stringValue(GEOGRAPHIC_IDENTIFIER));
final double[] extent = new double[4];
/*
* If at least one geographic coordinate is available, add a GeographicBoundingBox.
*/
boolean hasExtent;
hasExtent = fillExtent(LONGITUDE, Units.DEGREE, AxisDirection.EAST, extent, 0);
hasExtent |= fillExtent(LATITUDE, Units.DEGREE, AxisDirection.NORTH, extent, 2);
if (hasExtent) {
addExtent(extent, 0);
hasExtent = true;
}
/*
* If at least one vertical coordinate is available, add a VerticalExtent.
*/
if (fillExtent(VERTICAL, Units.METRE, null, extent, 0)) {
addVerticalExtent(extent[0], extent[1], verticalCRS);
hasExtent = true;
}
/*
* Get the start and end times as Date objects if available, or as numeric values otherwise.
* In the later case, the unit symbol tells how to convert to Date objects.
*/
Date startTime = decoder.dateValue(TIME.MINIMUM);
Date endTime = decoder.dateValue(TIME.MAXIMUM);
if (startTime == null && endTime == null) {
final Number tmin = decoder.numericValue(TIME.MINIMUM);
final Number tmax = decoder.numericValue(TIME.MAXIMUM);
if (tmin != null || tmax != null) {
final String symbol = stringValue(TIME.UNITS);
if (symbol != null) {
final Date[] dates = decoder.numberToDate(symbol, tmin, tmax);
startTime = dates[0];
endTime = dates[1];
}
}
}
/*
* If at least one time value above is available, add a temporal extent.
* This operation requires the sis-temporal module. If not available,
* we will report a warning and leave the temporal extent missing.
*/
if (startTime != null || endTime != null) try {
addTemporalExtent(startTime, endTime);
hasExtent = true;
} catch (UnsupportedOperationException e) {
warning(e);
}
return hasExtent;
}
/**
* Fills one dimension of the geographic bounding box or vertical extent.
* The extent values are written in the given {@code extent} array.
*
* @param dim the dimension for which to get the extent.
* @param targetUnit the destination unit of the extent.
* @param positive the direction considered positive, or {@code null} if the unit symbol is not expected to contain a direction.
* @param extent where to store the minimum and maximum values.
* @param index index where to store the minimum value in {@code extent}. The maximum value is stored at {@code index+1}.
* @return {@code true} if a minimum or a maximum value has been found.
*/
private boolean fillExtent(final AttributeNames.Dimension dim, final Unit<?> targetUnit, final AxisDirection positive,
final double[] extent, final int index)
{
double min = numericValue(dim.MINIMUM);
double max = numericValue(dim.MAXIMUM);
boolean hasExtent = !Double.isNaN(min) || !Double.isNaN(max);
if (hasExtent) {
final String symbol = stringValue(dim.UNITS);
if (symbol != null) {
try {
final UnitConverter c = Units.valueOf(symbol).getConverterToAny(targetUnit);
min = c.convert(min);
max = c.convert(max);
} catch (ParserException | IncommensurableException e) {
warning(e);
}
boolean reverse = false;
if (positive != null) {
reverse = AxisDirections.opposite(positive).equals(Axis.direction(symbol));
} else if (dim.POSITIVE != null) {
// For now, only the vertical axis have a "positive" attribute.
reverse = CF.POSITIVE_DOWN.equals(stringValue(dim.POSITIVE));
}
if (reverse) {
final double tmp = min;
min = -max;
max = -tmp;
}
}
}
extent[index ] = min;
extent[index+1] = max;
return hasExtent;
}
/**
* Adds information about acquisition (program, platform).
*/
private void addAcquisitionInfo() {
final Term[] attributes = {
AttributeNames.PROGRAM,
AttributeNames.PLATFORM,
AttributeNames.INSTRUMENT
};
for (int i=0; i<attributes.length; i++) {
final Term at = attributes[i];
final String authority = stringValue(at.VOCABULARY);
for (final String keyword : split(stringValue(at.TEXT))) {
switch (i) {
case 0: {
if (EXPERIMENTAL) {
addAcquisitionOperation(authority, keyword);
}
break;
}
case 1: addPlatform (authority, keyword); break;
case 2: addInstrument(authority, keyword); break;
}
}
}
}
/**
* 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.
*/
@SuppressWarnings("null")
private void addContentInfo() {
final Map<List<String>, List<Variable>> contents = new HashMap<>(4);
for (final Variable variable : decoder.getVariables()) {
if (variable.getRole() == VariableRole.COVERAGE) {
final List<org.apache.sis.internal.netcdf.Dimension> 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(contents, Arrays.asList(names), variable);
}
}
final String processingLevel = stringValue(PROCESSING_LEVEL);
for (final List<Variable> group : contents.values()) {
/*
* Instantiate a CoverageDescription for each distinct set of netCDF dimensions
* (e.g. longitude,latitude,time). This separation is based on the fact that a
* coverage has only one domain for every range of values.
*/
newCoverage(false);
setProcessingLevelCode(null, processingLevel);
for (final Variable variable : group) {
addSampleDimension(variable);
final CharSequence[] names = variable.getAttributeAsStrings(FLAG_NAMES, ' ');
final CharSequence[] meanings = variable.getAttributeAsStrings(FLAG_MEANINGS, ' ');
final Vector masks = variable.getAttributeAsVector (FLAG_MASKS);
final Vector values = variable.getAttributeAsVector (FLAG_VALUES);
final int s1 = (names != null) ? names.length : 0;
final int s2 = (meanings != null) ? meanings.length : 0;
final int s3 = (masks != null) ? masks .size() : 0;
final int s4 = (values != null) ? values.size() : 0;
final int length = Math.max(s1, Math.max(s2, Math.max(s3, s4)));
for (int i=0; i<length; i++) {
addSampleValueDescription(variable,
(i < s1) ? names [i] : null,
(i < s2) ? meanings [i] : null,
(i < s3) ? masks .get(i) : null,
(i < s4) ? values.get(i) : null);
}
}
}
}
/**
* Adds metadata about a sample dimension (or band) from the given variable.
* This is the {@code <mrc:dimension>} element in XML.
*
* @param variable the netCDF variable.
*/
private void addSampleDimension(final Variable variable) {
newSampleDimension();
final String name = trim(variable.getName());
if (name != null) {
final DefaultNameFactory f = decoder.nameFactory;
final StringBuilder buffer = new StringBuilder(20);
variable.writeDataTypeName(buffer);
setBandIdentifier(f.createMemberName(null, name, f.createTypeName(null, buffer.toString())));
}
final String id = variable.getAttributeAsString(CF.STANDARD_NAME);
if (id != null && !id.equals(name)) {
addBandName(variable.getAttributeAsString(ACDD.standard_name_vocabulary), id);
}
final String description = trim(variable.getDescription());
if (description != null && !description.equals(name) && !description.equals(id)) {
addBandDescription(description);
}
setSampleUnits(variable.getUnit());
setTransferFunction(variable.getAttributeAsNumber(CDM.SCALE_FACTOR),
variable.getAttributeAsNumber(CDM.ADD_OFFSET));
addContentType(forCodeName(CoverageContentType.class, stringValue(ACDD.coverage_content_type)));
}
/**
* Adds metadata about the meaning of a sample value.
* This is the {@code <mrc:rangeElementDescription>} element in XML.
*
* <p><b>Note:</b> ISO 19115 range elements are approximately equivalent to
* {@code org.apache.sis.coverage.Category} in the {@code sis-coverage} module.</p>
*
* @param variable the netCDF variable.
* @param name one of the elements in the {@link AttributeNames#FLAG_NAMES} attribute, or {@code null}.
* @param meaning one of the elements in the {@link AttributeNames#FLAG_MEANINGS} attribute or {@code null}.
* @param mask one of the elements in the {@link AttributeNames#FLAG_MASKS} attribute or {@code null}.
* @param value one of the elements in the {@link AttributeNames#FLAG_VALUES} attribute or {@code null}.
*/
private void addSampleValueDescription(final Variable variable,
final CharSequence name, final CharSequence meaning, final Number mask, final Number value)
{
addSampleValueDescription(name, meaning);
// TODO: create a record from values (and possibly from the masks).
// if (pixel & mask == value) then we have that range element.
}
/**
* Adds a globally unique identifier for the current netCDF {@linkplain #decoder}.
* The current implementation builds the identifier from the following attributes:
*
* <ul>
* <li>{@code AttributeNames.IDENTIFIER.VOCABULARY} used as the {@linkplain Identifier#getAuthority() authority}.</li>
* <li>{@code AttributeNames.IDENTIFIER.TEXT}, or {@link ucar.nc2.NetcdfFile#getId()} if no identifier attribute was found,
* or the filename without extension if {@code getId()} returned nothing.</li>
* </ul>
*
* This method should be invoked last, after we made our best effort to set the title.
*/
private void addFileIdentifier() {
String identifier = stringValue(IDENTIFIER.TEXT);
String authority;
if (identifier != null) {
authority = stringValue(IDENTIFIER.VOCABULARY);
} else {
identifier = decoder.getId();
if (identifier == null) {
identifier = IOUtilities.filenameWithoutExtension(decoder.getFilename());
if (identifier == null) {
return;
}
}
authority = null;
}
if (authority == null) {
addTitleOrIdentifier(identifier, Scope.RESOURCE);
} else {
addIdentifier(authority, identifier, Scope.RESOURCE);
}
}
/**
* Creates an ISO {@code Metadata} object from the information found in the netCDF file.
* The returned metadata is unmodifiable, for allowing the caller to share a unique instance.
*
* @return the ISO metadata object.
* @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 Metadata read() throws IOException, DataStoreException {
for (final CoordinateReferenceSystem crs : decoder.getReferenceSystemInfo()) {
addReferenceSystem(crs);
if (verticalCRS == null) {
verticalCRS = CRS.getVerticalComponent(crs, false);
}
}
addResourceScope(ScopeCode.DATASET, null);
addIdentificationInfo(addCitation());
for (final String service : SERVICES) {
final String name = stringValue(service);
if (name != null) {
addResourceScope(ScopeCode.SERVICE, name);
}
}
addAcquisitionInfo();
addContentInfo();
/*
* Add the dimension information, if any. This metadata node
* is built from the netCDF CoordinateSystem objects.
*/
boolean hasGrids = false;
for (final Grid cs : decoder.getGrids()) {
if (cs.getSourceDimensions() >= Grid.MIN_DIMENSION &&
cs.getTargetDimensions() >= Grid.MIN_DIMENSION)
{
addSpatialRepresentationInfo(cs);
hasGrids = true;
}
}
setISOStandards(hasGrids);
addFileIdentifier();
/*
* Deperture: UnidataDD2MI.xsl puts the source in Metadata.dataQualityInfo.lineage.statement.
* However since ISO 19115:2014, Metadata.resourceLineage.statement seems a more appropriate place.
* See https://issues.apache.org/jira/browse/SIS-361
*/
for (final String path : searchPath) {
decoder.setSearchPath(path);
addLineage(stringValue(HISTORY));
addSource(stringValue(SOURCE), null, null);
}
decoder.setSearchPath(searchPath);
final DefaultMetadata metadata = build(false);
addCompleteMetadata(createURI(stringValue(METADATA_LINK)));
metadata.transitionTo(DefaultMetadata.State.FINAL);
return metadata;
}
}