/* * Geotoolkit.org - An Open Source Java GIS Toolkit * http://www.geotoolkit.org * * (C) 2011-2012, Open Source Geospatial Foundation (OSGeo) * (C) 2011-2012, Geomatys * * 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.geotoolkit.metadata.netcdf; import java.net.URI; import java.net.URISyntaxException; import java.util.Date; import java.util.List; import java.util.Set; import java.util.EnumSet; import java.util.LinkedHashSet; import java.util.Map; import java.util.HashMap; import java.util.Collection; import java.util.logging.Level; import java.io.IOException; import javax.measure.Unit; import javax.measure.UnitConverter; import javax.measure.IncommensurableException; import ucar.nc2.Group; import ucar.nc2.Attribute; import ucar.nc2.NetcdfFile; import ucar.nc2.VariableIF; import ucar.nc2.VariableSimpleIF; import ucar.nc2.dataset.NetcdfDataset; import ucar.nc2.dataset.CoordinateSystem; import ucar.nc2.dataset.CoordinateAxis; import ucar.nc2.constants.AxisType; import ucar.nc2.constants.CF; import ucar.nc2.units.DateUnit; import ucar.nc2.units.DateFormatter; import org.opengis.metadata.Metadata; import org.opengis.metadata.Identifier; import org.opengis.metadata.citation.*; import org.opengis.metadata.content.*; import org.opengis.metadata.maintenance.ScopeCode; import org.opengis.metadata.constraint.Restriction; import org.opengis.metadata.spatial.CellGeometry; import org.opengis.metadata.spatial.GridSpatialRepresentation; import org.opengis.metadata.spatial.SpatialRepresentationType; import org.opengis.metadata.identification.DataIdentification; import org.opengis.metadata.identification.TopicCategory; import org.opengis.metadata.identification.KeywordType; import org.opengis.metadata.identification.Keywords; import org.opengis.metadata.extent.Extent; import org.opengis.referencing.operation.TransformException; import org.opengis.util.InternationalString; import org.opengis.util.NameFactory; import org.apache.sis.util.ArraysExt; import org.apache.sis.util.CharSequences; import org.apache.sis.util.ArgumentChecks; import org.apache.sis.geometry.GeneralEnvelope; import org.geotoolkit.factory.FactoryFinder; import org.apache.sis.util.iso.DefaultNameSpace; import org.geotoolkit.image.io.WarningProducer; import org.apache.sis.util.iso.Types; import org.geotoolkit.internal.image.io.Warnings; import org.geotoolkit.internal.image.io.NetcdfVariable; import org.apache.sis.metadata.iso.DefaultMetadata; import org.apache.sis.metadata.iso.DefaultIdentifier; import org.apache.sis.metadata.iso.citation.*; import org.apache.sis.metadata.iso.constraint.DefaultLegalConstraints; import org.apache.sis.metadata.iso.spatial.DefaultDimension; import org.apache.sis.metadata.iso.spatial.DefaultGridSpatialRepresentation; import org.apache.sis.metadata.iso.identification.DefaultDataIdentification; import org.apache.sis.metadata.iso.identification.DefaultKeywords; import org.apache.sis.metadata.iso.content.DefaultBand; import org.apache.sis.metadata.iso.content.DefaultRangeElementDescription; import org.apache.sis.metadata.iso.content.DefaultCoverageDescription; import org.apache.sis.metadata.iso.content.DefaultImageDescription; import org.apache.sis.metadata.iso.extent.DefaultGeographicBoundingBox; import org.apache.sis.metadata.iso.distribution.DefaultDistributor; import org.apache.sis.metadata.iso.distribution.DefaultDistribution; import org.apache.sis.metadata.iso.extent.DefaultExtent; import org.apache.sis.metadata.iso.extent.DefaultGeographicDescription; import org.apache.sis.metadata.iso.extent.DefaultVerticalExtent; import org.apache.sis.metadata.iso.extent.DefaultTemporalExtent; import org.apache.sis.metadata.iso.quality.DefaultDataQuality; import org.apache.sis.metadata.iso.lineage.DefaultLineage; import org.geotoolkit.referencing.adapters.NetcdfCRSBuilder; import org.geotoolkit.resources.Errors; import org.apache.sis.referencing.CommonCRS; import org.apache.sis.measure.Units; import static java.util.Collections.singleton; import static org.apache.sis.util.iso.Types.toInternationalString; /** * Mapping from NetCDF metadata to ISO 19115-2 metadata. The {@link String} constants declared in * the {@linkplain NetcdfMetadata parent class} are the name of attributes examined by this class. * The attribute values are extracted using the {@link NetcdfFile#findGlobalAttributeIgnoreCase(String)} * or {@link Group#findAttributeIgnoreCase(String)} methods. The current implementation searches the * attribute values in the following places, in that order: * <p> * <ol> * <li>{@code "NCISOMetadata"} group</li> * <li>{@code "CFMetadata"} group</li> * <li>Global attributes</li> * <li>{@code "THREDDSMetadata"} group</li> * </ol> * <p> * The {@code "CFMetadata"} group has precedence over the global attributes because the * {@linkplain #LONGITUDE longitude} and {@linkplain #LATITUDE latitude} resolutions are * often more accurate in that group. * * {@section Known limitations} * <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 #FLAG_VALUES} and {@link #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) * @version 3.20 * * @since 3.20 * @module */ public class NetcdfMetadataReader extends NetcdfMetadata { /** * Names of groups where to search for metadata, in precedence order. * The {@code null} value stands for global attributes. * <p> * REMINDER: if modified, update class javadoc too. */ private static final String[] GROUP_NAMES = {"NCISOMetadata", "CFMetadata", null, "THREDDSMetadata"}; /** * The NetCDF file from which to extract ISO metadata. * This file is set at construction time. * <p> * This {@code NetcdfMetadataReader} class does <strong>not</strong> close this file. * Closing this file after usage is the user responsibility. */ protected final NetcdfFile file; /** * The groups where to look for metadata, in precedence order. The first group shall be * {@code null}, which stands for global attributes. All other groups shall be non-null * values for the {@code "NCISOMetadata"}, {@code "THREDDSMetadata"} and {@code "CFMetadata"} * groups, if they exist. */ private final Group[] groups; /** * The object to use for parsing dates, created when first needed. */ private transient DateFormatter dateFormat; /** * The name factory, created when first needed. */ private transient NameFactory nameFactory; /** * The contact, used at metadata creation time for avoiding to construct identical objects * more than once. * * <p>The point of contact is stored in two places. The semantic of those two methods 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> * <p>An object very similar is used as the creator. The point of contact and the creator * are practically identical except for their role attribute.</p> */ private transient ResponsibleParty pointOfContact; /** * Creates a new <cite>NetCDF to ISO</cite> mapper for the given file. While this constructor * accepts arbitrary {@link NetcdfFile} instance, the {@link NetcdfDataset} subclass is * necessary in order to get coordinate system information. * * @param file The NetCDF file from which to parse metadata. * @param owner Typically the {@link org.geotoolkit.image.io.SpatialImageReader} instance * using this decoder, or {@code null}. */ public NetcdfMetadataReader(final NetcdfFile file, final WarningProducer owner) { super(owner); ArgumentChecks.ensureNonNull("file", file); this.file = file; final Group[] groups = new Group[GROUP_NAMES.length]; int count = 0; for (final String name : GROUP_NAMES) { if (name != null) { final Group group = file.findGroup(name); if (group == null) { continue; // Group not found - do not increment the counter. } groups[count] = group; } count++; } this.groups = ArraysExt.resize(groups, count); } /** * Reports a warning. * * @param method The method in which the warning occurred. * @param exception The exception to log. */ private void warning(final String method, final Exception exception) { Warnings.log(this, Level.WARNING, NetcdfMetadataReader.class, method, exception); } /** * Returns the NetCDF attribute of the given name in the given group, or {@code null} if none. * This method is invoked for every global and group attributes to be read by this class (but * not {@linkplain VariableSimpleIF variable} attributes), thus providing a single point where * subclasses can filter the attributes to be read. The {@code name} argument is typically (but * is not restricted too) one of the constants defined in this class. * * @param group The group in which to search the attribute, or {@code null} for global attributes. * @param name The name of the attribute to search (can not be null). * @return The attribute, or {@code null} if none. */ protected Attribute getAttribute(final Group group, final String name) { return (group != null) ? group.findAttributeIgnoreCase(name) : file.findGlobalAttributeIgnoreCase(name); } /** * Returns the attribute of the given name in the given group, as a string. * This method considers empty strings as {@code null}. * * @param group The group in which to search the attribute, or {@code null} for global attributes. * @param name The name of the attribute to search. * @return The attribute value, or {@code null} if none or empty. */ private String getStringValue(final Group group, final String name) { if (name != null) { // For createResponsibleParty(...) convenience. final Attribute attribute = getAttribute(group, name); if (attribute != null && attribute.isString()) { String value = attribute.getStringValue(); if (value != null && !(value = value.trim()).isEmpty()) { return value; } } } return null; } /** * Returns the attribute of the given name, searching in all groups. * * @param name The name of the attribute to search. * @return The attribute value, or {@code null} if none. */ private String getStringValue(final String name) { for (final Group group : groups) { final String value = getStringValue(group, name); if (value != null) { return value; } } return null; } /** * Returns the attribute of the given name in the given group, as a number. * * @param group The group in which to search the attribute, or {@code null} for global attributes. * @param name The name of the attribute to search. * @return The attribute value, or {@code null} if none or unparseable. */ private Number getNumericValue(final Group group, final String name) { final Attribute attribute = getAttribute(group, name); if (attribute != null) { Number value = attribute.getNumericValue(); if (value == null) { String asString = attribute.getStringValue(); if (asString != null) { asString = asString.trim(); final int s = asString.indexOf(' '); if (s >= 0) { // Sometime, numeric values as string are followed by // a unit of measurement. We ignore that unit for now... asString = asString.substring(0, s); } try { value = Double.valueOf(asString); } catch (NumberFormatException e) { warning("getNumericValue", e); } } } return value; } return null; } /** * Returns the attribute of the given name, searching in all groups. * * @param name The name of the attribute to search. * @return The attribute value, or {@code null} if none. */ private Number getNumericValue(final String name) { for (final Group group : groups) { final Number value = getNumericValue(group, name); if (value != null) { return value; } } return null; } /** * Returns the attribute of the given name in the given group, as a date. * * @param group The group in which to search the attribute, or {@code null} for global attributes. * @param name The name of the attribute to search. * @return The attribute value, or {@code null} if none or unparseable. */ private Date getDateValue(final Group group, final String name) { final String date = getStringValue(group, name); if (date != null) { if (dateFormat == null) { dateFormat = new DateFormatter(); } final Date result = dateFormat.getISODate(date); if (result == null) { Warnings.log(this, Level.WARNING, NetcdfMetadataReader.class, "getDateValue", Errors.Keys.UnparsableAttribute_2, name, date); } return result; } return null; } /** * Returns the attribute of the given name, searching in all groups. * * @param name The name of the attribute to search. * @return The attribute value, or {@code null} if none. */ private Date getDateValue(final String name) { for (final Group group : groups) { final Date value = getDateValue(group, name); if (value != null) { return value; } } return null; } /** * Returns the attribute of the given name in the given group, as a unit of measurement. * * @param group The group in which to search the attribute, or {@code null} for global attributes. * @param name The name of the attribute to search. * @return The attribute value, or {@code null} if none or unparseable. * * @todo Current Units.valueOf(String) implementation ignore direction in "degrees_east" or * "degrees_west". We need to take that in account (with "degrees_west" to "degrees_east" * converter that reverse the sign). */ private Unit<?> getUnitValue(final Group group, final String name) { final String unit = getStringValue(group, name); if (unit != null) { switch (unit) { /* * A few cases found in NetCDF file. This is a temporary patch * while we wait for a more efficient Unit parser. */ case "meter2 second-1": return Units.METRE.multiply(Units.METRES_PER_SECOND); case "meter second-1": // Fall through case "m s**-1": return Units.METRES_PER_SECOND; case "kg m**-2": return Units.KILOGRAM.multiply(Units.METRE.pow(-2)); case "m of water": return Units.METRE; } try { return Units.valueOf(unit); } catch (IllegalArgumentException e) { warning("getUnitValue", e); } } return null; } /** * Adds the given element in the given collection if the element is not already present in the * collection. We define this method because the metadata API use collections while the Geotk * implementation uses lists. The lists are usually very short (typically 0 or 1 element), so * the call to {@link List#contains(Object)} should be cheap. */ private static <T> void addIfAbsent(final Collection<T> collection, final T element) { if (!collection.contains(element)) { collection.add(element); } } /** * 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 isDefined(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 isDefined(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 isDefined(final OnlineResource resource, final String url) { return (url == null) || (resource != null && isDefined(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 isDefined(final Address address, final String email) { return (email == null) || (address != null && isDefined(address.getElectronicMailAddresses(), email)); } /** * 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) { if (url != null) try { final DefaultOnlineResource resource = new DefaultOnlineResource(new URI(url)); resource.setProtocol("http"); resource.setApplicationProfile("web browser"); resource.setFunction(OnLineFunction.INFORMATION); return resource; } catch (URISyntaxException e) { warning("createOnlineResource", e); } return null; } /** * 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.getElectronicMailAddresses().add(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(); contact.setAddress(address); contact.setOnlineResource(url); return contact; } return null; } /** * Returns a globally unique identifier for the current NetCDF {@linkplain #file}. * The default implementation builds the identifier from the following attributes: * <p> * <ul> * <li>{@value #NAMING_AUTHORITY} used as the {@linkplain Identifier#getAuthority() authority}.</li> * <li>{@value #IDENTIFIER}, or {@link NetcdfFile#getId()} if no identifier attribute was found.</li> * </ul> * * @return The globally unique identifier, or {@code null} if none. * @throws IOException If an I/O operation was necessary but failed. */ private Identifier getFileIdentifier() throws IOException { String identifier = getStringValue(IDENTIFIER); if (identifier == null) { identifier = file.getId(); if (identifier == null) { return null; } } final String namespace = getStringValue(NAMING_AUTHORITY); return new DefaultIdentifier((namespace != null) ? new DefaultCitation(namespace) : null, identifier); } /** * Creates a {@code ResponsibleParty} element if at least one of the name, email or URL * attributes is defined. * <p> * Implementation note: this method tries to reuse the existing {@link #pointOfContact} instance, * or part of it, if it is suitable. * * @param keys The group of attribute names to use for fetching the values. * @param group The group in which to read the attributes values. * @return The responsible party, or {@code null} if none. * @throws IOException If an I/O operation was necessary but failed. * * @see #CREATOR * @see #CONTRIBUTOR * @see #PUBLISHER */ private ResponsibleParty createResponsibleParty(final Group group, final Responsible keys, final boolean isPointOfContact) throws IOException { final String individualName = getStringValue(group, keys.NAME); final String organisationName = getStringValue(group, keys.INSTITUTION); final String email = getStringValue(group, keys.EMAIL); final String url = getStringValue(group, keys.URL); if (individualName == null && organisationName == null && email == null && url == null) { return null; } Role role = Types.forCodeName(Role.class, getStringValue(group, keys.ROLE), true); if (role == null) { role = isPointOfContact ? Role.POINT_OF_CONTACT : keys.DEFAULT_ROLE; } ResponsibleParty party = pointOfContact; Contact contact = null; Address address = null; OnlineResource resource = null; if (party != null) { contact = party.getContactInfo(); if (contact != null) { address = contact.getAddress(); resource = contact.getOnlineResource(); } if (!isDefined(resource, url)) { resource = null; contact = null; // Clear the parents all the way up to the root. party = null; } if (!isDefined(address, email)) { address = null; contact = null; // Clear the parents all the way up to the root. party = null; } if (party != null) { if (!isDefined(party.getOrganisationName(), organisationName) || !isDefined(party.getIndividualName(), individualName)) { party = null; } } } if (party == 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. final DefaultResponsibleParty np = new DefaultResponsibleParty(role); np.setIndividualName(individualName); np.setOrganisationName(toInternationalString(organisationName)); np.setContactInfo(contact); party = np; } } return party; } /** * Creates a {@code Citation} element if at least one of the required attributes * is non-null. This method will reuse the {@link #pointOfContact} field, if non-null. * * @param identifier The citation {@code <gmd:identifier> attribute. * @throws IOException If an I/O operation was necessary but failed. */ private Citation createCitation(final Identifier identifier) throws IOException { String title = getStringValue(TITLE); if (title == null) { title = getStringValue("full_name"); // THREDDS attribute documented in TITLE javadoc. if (title == null) { title = getStringValue("name"); // THREDDS attribute documented in TITLE javadoc. if (title == null) { title = file.getTitle(); } } } final Date creation = getDateValue(DATE_CREATED); final Date modified = getDateValue(DATE_MODIFIED); final Date issued = getDateValue(DATE_ISSUED); final String references = getStringValue(REFERENCES); final DefaultCitation citation = new DefaultCitation(title); if (identifier != null) { citation.getIdentifiers().add(identifier); } if (creation != null) citation.getDates().add(new DefaultCitationDate(creation, DateType.CREATION)); if (modified != null) citation.getDates().add(new DefaultCitationDate(modified, DateType.REVISION)); if (issued != null) citation.getDates().add(new DefaultCitationDate(issued, DateType.PUBLICATION)); if (pointOfContact != null) { // Same responsible party than the contact, except for the role. final DefaultResponsibleParty np = new DefaultResponsibleParty(Role.ORIGINATOR); np.setIndividualName (pointOfContact.getIndividualName()); np.setOrganisationName(pointOfContact.getOrganisationName()); np.setContactInfo (pointOfContact.getContactInfo()); citation.getCitedResponsibleParties().add(np); } for (final Group group : groups) { final ResponsibleParty contributor = createResponsibleParty(group, CONTRIBUTOR, false); if (contributor != null && contributor != pointOfContact) { addIfAbsent(citation.getCitedResponsibleParties(), contributor); } } if (references != null) { citation.setOtherCitationDetails(singleton(toInternationalString(references))); } return citation.isEmpty() ? null : citation; } /** * Creates a {@code DataIdentification} element if at least one of the required attributes * is non-null. This method will reuse the {@link #pointOfContact} field, if non-null. * * @param identifier The citation {@code <gmd:identifier> attribute. * @param publisher The publisher names, built by the caller in an opportunist way. * @throws IOException If an I/O operation was necessary but failed. */ private DataIdentification createIdentificationInfo(final Identifier identifier, final Set<InternationalString> publisher) throws IOException { DefaultDataIdentification identification = null; Set<InternationalString> project = null; DefaultLegalConstraints constraints = null; boolean hasExtent = false; for (final Group group : groups) { final Keywords standard = createKeywords(group, KeywordType.THEME, true); final Keywords keywords = createKeywords(group, KeywordType.THEME, false); final String topic = getStringValue(group, TOPIC_CATEGORY); final String type = getStringValue(group, DATA_TYPE); final String credits = getStringValue(group, ACKNOWLEDGMENT); final String license = getStringValue(group, LICENSE); final String access = getStringValue(group, ACCESS_CONSTRAINT); final Extent extent = hasExtent ? null : createExtent(group); if (standard!=null || keywords!=null || topic != null || type!=null || credits!=null || license!=null || access!= null || extent!=null) { if (identification == null) { identification = new DefaultDataIdentification(); } if (topic != null) addIfAbsent(identification.getTopicCategories(), Types.forEnumName(TopicCategory.class, topic)); if (type != null) addIfAbsent(identification.getSpatialRepresentationTypes(), Types.forCodeName(SpatialRepresentationType.class, type, true)); if (standard != null) addIfAbsent(identification.getDescriptiveKeywords(), standard); if (keywords != null) addIfAbsent(identification.getDescriptiveKeywords(), keywords); if (credits != null) addIfAbsent(identification.getCredits(), Types.toInternationalString(credits)); if (license != null) addIfAbsent(identification.getResourceConstraints(), constraints = new DefaultLegalConstraints(license)); if (access != null) { for (final CharSequence token : CharSequences.split(access, ',')) { final String t = token.toString(); if (!t.isEmpty()) { if (constraints == null) { identification.getResourceConstraints().add(constraints = new DefaultLegalConstraints()); } addIfAbsent(constraints.getAccessConstraints(), Types.forCodeName(Restriction.class, t, true)); } } } if (extent != null) { // 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. identification.getExtents().add(extent); hasExtent = true; } } project = addIfNonNull(project, toInternationalString(getStringValue(group, PROJECT))); } final Citation citation = createCitation(identifier); final String summary = getStringValue(SUMMARY); final String purpose = getStringValue(PURPOSE); if (identification == null) { if (citation==null && summary==null && purpose==null && project==null && publisher==null && pointOfContact==null) { return null; } identification = new DefaultDataIdentification(); } identification.setCitation(citation); identification.setAbstract(toInternationalString(summary)); identification.setPurpose (toInternationalString(purpose)); if (pointOfContact != null) { identification.getPointOfContacts().add(pointOfContact); } addKeywords(identification, project, "project"); // Not necessarily the same string than PROJECT. addKeywords(identification, publisher, "dataCenter"); identification.setSupplementalInformation(toInternationalString(getStringValue(COMMENT))); return identification; } /** * Adds the given keywords to the given identification info if the given set is non-null. */ private static void addKeywords(final DefaultDataIdentification addTo, final Set<InternationalString> words, final String type) { if (words != null) { final DefaultKeywords keywords = new DefaultKeywords(); keywords.setKeywords(words); keywords.setType(Types.forCodeName(KeywordType.class, type, true)); addTo.getDescriptiveKeywords().add(keywords); } } /** * Returns the keywords if at least one required attribute is found, or {@code null} otherwise. * * @throws IOException If an I/O operation was necessary but failed. */ private Keywords createKeywords(final Group group, final KeywordType type, final boolean standard) throws IOException { final String list = getStringValue(group, standard ? STANDARD_NAME : KEYWORDS); DefaultKeywords keywords = null; if (list != null) { final Set<InternationalString> words = new LinkedHashSet<>(); for (String keyword : list.split(getKeywordSeparator(group))) { keyword = keyword.trim(); if (!keyword.isEmpty()) { words.add(toInternationalString(keyword)); } } if (!words.isEmpty()) { keywords = new DefaultKeywords(); keywords.setKeywords(words); keywords.setType(type); final String vocabulary = getStringValue(group, standard ? STANDARD_NAME_VOCABULARY : VOCABULARY); if (vocabulary != null) { keywords.setThesaurusName(new DefaultCitation(vocabulary)); } } } return keywords; } /** * Returns the string to use as a keyword separator. This separator is used for parsing * the {@value org.geotoolkit.metadata.netcdf.NetcdfMetadata#KEYWORDS} attribute value. * The default implementation returns {@code ","}. Subclasses can override this method * in an other separator (possibly determined from the file content) is desired. * * @param group The NetCDF group from which keywords are read. * @return The string to use as a keyword separator, as a regular expression. * @throws IOException If an I/O operation was necessary but failed. */ protected String getKeywordSeparator(final Group group) throws IOException { return ","; } /** * Creates a {@code <gmd:spatialRepresentationInfo>} element from the given NetCDF coordinate * system. Subclasses can override this method if they need to complete the information * provided in the returned object. * * @param cs The NetCDF coordinate system. * @return The grid spatial representation info. * @throws IOException If an I/O operation was necessary but failed. */ @SuppressWarnings("fallthrough") protected GridSpatialRepresentation createSpatialRepresentationInfo(final CoordinateSystem cs) throws IOException { final DefaultGridSpatialRepresentation grid = new DefaultGridSpatialRepresentation(); grid.setNumberOfDimensions(cs.getRankDomain()); /* * The caller (which is the read() method) has verified that the file is an instance * of NetcdfDataset. */ final NetcdfCRSBuilder builder = new NetcdfCRSBuilder((NetcdfDataset) file, owner); builder.setCoordinateSystem(cs); for (final Map.Entry<ucar.nc2.Dimension,CoordinateAxis> entry : builder.getAxesDomain().entrySet()) { final CoordinateAxis axis = entry.getValue(); final int i = axis.getDimensions().indexOf(entry.getKey()); Dimension rsat = null; Double resolution = null; final AxisType at = axis.getAxisType(); if (at != null) { boolean valid = false; switch (at) { case Lon: valid = true; // fallthrough case GeoX: rsat = LONGITUDE; break; case Lat: valid = true; // fallthrough case GeoY: rsat = LATITUDE; break; case Height: valid = true; // fallthrough case GeoZ: case Pressure: rsat = VERTICAL; break; case Time: valid = true; // fallthrough case RunTime: rsat = TIME; break; } if (valid) { final Number res = getNumericValue(rsat.RESOLUTION); if (res != null) { resolution = (res instanceof Double) ? (Double) res : res.doubleValue(); } } } final DefaultDimension dimension = new DefaultDimension(); if (rsat != null) { dimension.setDimensionName(rsat.DEFAULT_NAME_TYPE); dimension.setResolution(resolution); } dimension.setDimensionSize(axis.getShape(i)); grid.getAxisDimensionProperties().add(dimension); } grid.setCellGeometry(CellGeometry.AREA); return grid; } /** * Returns the extent declared in the given group, or {@code null} if none. */ private Extent createExtent(final Group group) { DefaultExtent extent = null; final Number xmin = getNumericValue(group, LONGITUDE.MINIMUM); final Number xmax = getNumericValue(group, LONGITUDE.MAXIMUM); final Number ymin = getNumericValue(group, LATITUDE .MINIMUM); final Number ymax = getNumericValue(group, LATITUDE .MAXIMUM); final Number zmin = getNumericValue(group, VERTICAL .MINIMUM); final Number zmax = getNumericValue(group, VERTICAL .MAXIMUM); if (xmin != null || xmax != null || ymin != null || ymax != null) { extent = new DefaultExtent(); final UnitConverter cλ = getConverterTo(getUnitValue(group, LONGITUDE.UNITS), Units.DEGREE); final UnitConverter cφ = getConverterTo(getUnitValue(group, LATITUDE .UNITS), Units.DEGREE); extent.getGeographicElements().add(new DefaultGeographicBoundingBox( valueOf(xmin, cλ), valueOf(xmax, cλ), valueOf(ymin, cφ), valueOf(ymax, cφ))); } if (zmin != null || zmax != null) { if (extent == null) { extent = new DefaultExtent(); } final UnitConverter c = getConverterTo(getUnitValue(group, VERTICAL.UNITS), Units.METRE); double min = valueOf(zmin, c); double max = valueOf(zmax, c); if (CF.POSITIVE_DOWN.equals(getStringValue(group, VERTICAL.POSITIVE))) { final double tmp = min; min = -max; max = -tmp; } extent.getVerticalElements().add(new DefaultVerticalExtent(min, max, CommonCRS.Vertical.MEAN_SEA_LEVEL.crs())); } /* * Temporal extent. */ Date startTime = getDateValue(group, TIME.MINIMUM); Date endTime = getDateValue(group, TIME.MAXIMUM); if (startTime == null && endTime == null) { final Number tmin = getNumericValue(group, TIME.MINIMUM); final Number tmax = getNumericValue(group, TIME.MAXIMUM); if (tmin != null || tmax != null) { final Attribute attribute = getAttribute(group, TIME.UNITS); if (attribute != null) { final String symbol = attribute.getStringValue(); if (symbol != null) try { final DateUnit unit = new DateUnit(symbol); if (tmin != null) startTime = unit.makeDate(tmin.doubleValue()); if (tmax != null) endTime = unit.makeDate(tmax.doubleValue()); } catch (Exception e) { // Declared by the DateUnit constructor. warning("createExtent", e); } } } } if (startTime != null || endTime != null) { if (extent == null) { extent = new DefaultExtent(); } final GeneralEnvelope env = new GeneralEnvelope(CommonCRS.Temporal.JAVA.crs()); env.setRange(0, (startTime != null) ? startTime.getTime() : Double.NaN, (endTime != null) ? endTime.getTime() : Double.NaN); final DefaultTemporalExtent e = new DefaultTemporalExtent(); try { e.setBounds(env); } catch (TransformException ex) { throw new AssertionError(ex); // Should never happen. } extent.getTemporalElements().add(e); } final String identifier = getStringValue(GEOGRAPHIC_IDENTIFIER); if (identifier != null) { if (extent == null) { extent = new DefaultExtent(); } extent.getGeographicElements().add(new DefaultGeographicDescription(null, identifier)); } return extent; } /** * Returns the converter from the given source unit (which may be {@code null}) to the * given target unit, or {@code null} if none or incompatible. */ private UnitConverter getConverterTo(final Unit<?> source, final Unit<?> target) { if (source != null) try { return source.getConverterToAny(target); } catch (IncommensurableException e) { warning("getConverterTo", e); } return null; } /** * Returns the values of the given number if non-null, or NaN if null. If the given * converter is non-null, it is applied. */ private static double valueOf(final Number value, final UnitConverter converter) { double n = Double.NaN; if (value != null) { n = value.doubleValue(); if (converter != null) { n = converter.convert(n); } } return n; } /** * Creates a {@code <gmd:contentInfo>} elements from all applicable NetCDF attributes. * * @return The content information. * @throws IOException If an I/O operation was necessary but failed. */ private Collection<DefaultCoverageDescription> createContentInfo() throws IOException { final Map<List<ucar.nc2.Dimension>, DefaultCoverageDescription> contents = new HashMap<>(4); final String processingLevel = getStringValue(PROCESSING_LEVEL); final List<? extends VariableIF> variables = file.getVariables(); for (final VariableSimpleIF variable : variables) { if (!NetcdfVariable.isCoverage(variable, variables, 2)) { // Same exclusion criterion than the one applied in NetcdfImageReader.getImageNames(). continue; } /* * 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. */ final List<ucar.nc2.Dimension> vardim = variable.getDimensions(); DefaultCoverageDescription content = contents.get(vardim); if (content == null) { /* * If there is some NetCDF attributes that can be stored only in the ImageDescription * subclass, instantiate that subclass. Otherwise instantiate the more generic class. */ if (processingLevel != null) { content = new DefaultImageDescription(); ((DefaultImageDescription) content).setProcessingLevelCode(new DefaultIdentifier(processingLevel)); } else { content = new DefaultCoverageDescription(); } contents.put(vardim, content); } content.getDimensions().add(createSampleDimension(variable)); final Object[] names = getSequence(variable, FLAG_NAMES, false); final Object[] meanings = getSequence(variable, FLAG_MEANINGS, false); final Object[] masks = getSequence(variable, FLAG_MASKS, true); final Object[] values = getSequence(variable, FLAG_VALUES, true); final int length = Math.max(masks.length, Math.max(values.length, Math.max(names.length, meanings.length))); for (int i=0; i<length; i++) { final RangeElementDescription element = createRangeElementDescription(variable, i < names .length ? (String) names [i] : null, i < meanings.length ? (String) meanings[i] : null, i < masks .length ? (Number) masks [i] : null, i < values .length ? (Number) values [i] : null); if (element != null) { content.getRangeElementDescriptions().add(element); } } } return contents.values(); } /** * Returns the sequence of string values for the given attribute, or an empty array if none. */ private static Object[] getSequence(final VariableSimpleIF variable, final String name, final boolean numeric) { final Attribute attribute = variable.findAttributeIgnoreCase(name); if (attribute != null) { boolean hasValues = false; final Object[] values = new Object[attribute.getLength()]; for (int i=0; i<values.length; i++) { if (numeric) { if ((values[i] = attribute.getNumericValue(i)) != null) { hasValues = true; } } else { String value = attribute.getStringValue(i); if (value != null && !(value = value.trim()).isEmpty()) { values[i] = value.replace('_', ' '); hasValues = true; } } } if (hasValues) { return values; } } return CharSequences.EMPTY_ARRAY; } /** * Creates a {@code <gmd:dimension>} element from the given NetCDF variable. Subclasses can * override this method if they need to complete the information provided in the returned * object. * * @param variable The NetCDF variable. * @return The sample dimension information. * @throws IOException If an I/O operation was necessary but failed. */ protected Band createSampleDimension(final VariableSimpleIF variable) throws IOException { final DefaultBand band = new DefaultBand(); String name = variable.getShortName(); if (name != null && !(name = name.trim()).isEmpty()) { if (nameFactory == null) { nameFactory = FactoryFinder.getNameFactory(null); } final StringBuilder type = new StringBuilder(variable.getDataType().getPrimitiveClassType().getSimpleName()); for (int i=variable.getShape().length; --i>=0;) { type.append("[]"); } // TODO: should be band.setName(...) with ISO 19115:2011. // Sequence identifiers are supposed to be numbers only. band.setSequenceIdentifier(nameFactory.createMemberName(null, name, nameFactory.createTypeName(null, type.toString()))); } String descriptor = variable.getDescription(); if (descriptor != null && !(descriptor = descriptor.trim()).isEmpty() && !descriptor.equals(name)) { band.setDescriptor(toInternationalString(descriptor)); } //TODO: Can't store the units, because the Band interface restricts it to length. // We need the SampleDimension interface proposed in ISO 19115 revision draft. // band.setUnits(Units.valueOf(variable.getUnitsString())); return band; } /** * Creates a {@code <gmd:rangeElementDescription>} elements from the given information. * <p> * <b>Note:</b> ISO 19115 range elements are approximatively equivalent to * {@link org.geotoolkit.coverage.Category} in the {@code geotk-coverage} module. * * @param variable The NetCDF variable. * @param name One of the elements in the {@value #FLAG_NAMES} attribute, or {@code null}. * @param meaning One of the elements in the {@value #FLAG_MEANINGS} attribute or {@code null}. * @param mask One of the elements in the {@value #FLAG_MASKS} attribute or {@code null}. * @param value One of the elements in the {@value #FLAG_VALUES} attribute or {@code null}. * @return The sample dimension information or {@code null} if none. * @throws IOException If an I/O operation was necessary but failed. */ private RangeElementDescription createRangeElementDescription(final VariableSimpleIF variable, final String name, final String meaning, final Number mask, final Number value) throws IOException { if (name != null && meaning != null) { final DefaultRangeElementDescription element = new DefaultRangeElementDescription(); element.setName(toInternationalString(name)); element.setDefinition(toInternationalString(meaning)); // TODO: create a record from values (and possibly from the masks). // if (pixel & mask == value) then we have that range element. return element; } return null; } /** * Creates an ISO {@code Metadata} object from the information found in the NetCDF file. * * @return The ISO metadata object. * @throws IOException If an I/O operation was necessary but failed. */ public Metadata read() throws IOException { final DefaultMetadata metadata = new DefaultMetadata(); metadata.setMetadataStandardName("ISO 19115-2 Geographic Information - Metadata Part 2 Extensions for imagery and gridded data"); metadata.setMetadataStandardVersion("ISO 19115-2:2009(E)"); final Identifier identifier = getFileIdentifier(); if (identifier != null) { String code = identifier.getCode(); final Citation authority = identifier.getAuthority(); if (authority != null) { final InternationalString title = authority.getTitle(); if (title != null) { code = title.toString() + DefaultNameSpace.DEFAULT_SEPARATOR + code; } } metadata.setFileIdentifier(code); } metadata.setDateStamp(getDateValue(METADATA_CREATION)); metadata.getHierarchyLevels().add(ScopeCode.DATASET); final String wms = getStringValue("wms_service"); final String wcs = getStringValue("wcs_service"); if (wms != null || wcs != null) { metadata.getHierarchyLevels().add(ScopeCode.SERVICE); } /* * Add the ResponsibleParty which is declared in global attributes, or in * the THREDDS attributes if no information was found in global attributes. */ for (final Group group : groups) { final ResponsibleParty party = createResponsibleParty(group, CREATOR, true); if (party != null && party != pointOfContact) { addIfAbsent(metadata.getContacts(), party); if (pointOfContact == null) { pointOfContact = party; } } } /* * Add the publisher AFTER the creator, because this method may * reuse the 'creator' field (if non-null and if applicable). */ Set<InternationalString> publisher = null; DefaultDistribution distribution = null; for (final Group group : groups) { final ResponsibleParty party = createResponsibleParty(group, PUBLISHER, false); if (party != null) { if (distribution == null) { distribution = new DefaultDistribution(); metadata.setDistributionInfo(singleton(distribution)); } final DefaultDistributor distributor = new DefaultDistributor(party); // TODO: There is some transfert option, etc. that we could set there. // See UnidataDD2MI.xsl for options for OPeNDAP, THREDDS, etc. addIfAbsent(distribution.getDistributors(), distributor); publisher = addIfNonNull(publisher, toInternationalString(party.getIndividualName())); } // Also add history. final String history = getStringValue(HISTORY); if (history != null) { final DefaultDataQuality quality = new DefaultDataQuality(); final DefaultLineage lineage = new DefaultLineage(); lineage.setStatement(toInternationalString(history)); quality.setLineage(lineage); addIfAbsent(metadata.getDataQualityInfo(), quality); } } /* * Add the identification info AFTER the responsible parties (both creator and publisher), * because this method will reuse the 'creator' and 'publisher' information (if non-null). */ final DataIdentification identification = createIdentificationInfo(identifier, publisher); if (identification != null) { metadata.getIdentificationInfo().add(identification); } metadata.setContentInfo(createContentInfo()); /* * Add the dimension information, if any. This metadata node * is built from the NetCDF CoordinateSystem objects. */ if (file instanceof NetcdfDataset) { final NetcdfDataset ds = (NetcdfDataset) file; final EnumSet<NetcdfDataset.Enhance> mode = EnumSet.copyOf(ds.getEnhanceMode()); if (mode.add(NetcdfDataset.Enhance.CoordSystems)) { ds.enhance(mode); } for (final CoordinateSystem cs : ds.getCoordinateSystems()) { if (cs.getRankDomain() >= NetcdfVariable.MIN_DIMENSION && cs.getRankRange() >= NetcdfVariable.MIN_DIMENSION) { metadata.getSpatialRepresentationInfo().add(createSpatialRepresentationInfo(cs)); } } } return metadata; } }