/* (c) 2014 Open Source Geospatial Foundation - all rights reserved * (c) 2001 - 2013 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.catalog.util; import static org.geotools.coverage.grid.io.GridCoverage2DReader.ELEVATION_DOMAIN; import static org.geotools.coverage.grid.io.GridCoverage2DReader.HAS_ELEVATION_DOMAIN; import static org.geotools.coverage.grid.io.GridCoverage2DReader.HAS_TIME_DOMAIN; import static org.geotools.coverage.grid.io.GridCoverage2DReader.TIME_DOMAIN; import java.io.IOException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.TimeZone; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; import org.geoserver.ows.kvp.TimeParser; import org.geotools.coverage.grid.io.AbstractGridCoverage2DReader; import org.geotools.coverage.grid.io.DimensionDescriptor; import org.geotools.coverage.grid.io.GranuleSource; import org.geotools.coverage.grid.io.GridCoverage2DReader; import org.geotools.coverage.grid.io.StructuredGridCoverage2DReader; import org.geotools.data.Query; import org.geotools.factory.CommonFactoryFinder; import org.geotools.feature.FeatureCollection; import org.geotools.feature.visitor.UniqueVisitor; import org.geotools.util.Converters; import org.geotools.util.DateRange; import org.geotools.util.NumberRange; import org.geotools.util.Range; import org.geotools.util.Utilities; import org.geotools.util.logging.Logging; import org.opengis.filter.FilterFactory; import org.opengis.filter.PropertyIsBetween; import org.opengis.filter.expression.PropertyName; /** * Centralizes the metadata extraction and parsing used to read dimension informations out of a * coverage reader * * @author Andrea Aime - GeoSolutions */ public class ReaderDimensionsAccessor { /** UTC_TIME_ZONE */ private static final TimeZone UTC_TIME_ZONE = TimeZone.getTimeZone("UTC"); private static final Logger LOGGER = Logging.getLogger(ReaderDimensionsAccessor.class); private static final String UTC_PATTERN = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; private static FilterFactory FF = CommonFactoryFinder.getFilterFactory(); /** * Comparator for the TreeSet made either by Date objects, or by DateRange objects */ private static final Comparator<Object> TEMPORAL_COMPARATOR = new Comparator<Object>() { @Override public int compare(Object o1, Object o2) { // the domain can be a mix of dates and ranges if(o1 instanceof Date) { if(o1 instanceof DateRange) { return ((Date) o1).compareTo(((DateRange) o2).getMinValue()); } else { return ((Date) o1).compareTo((Date) o2); } } else if(o1 instanceof DateRange) { if(o2 instanceof Date) { return ((DateRange) o1).getMinValue().compareTo((Date) o2); } else { return ((DateRange) o1).getMinValue().compareTo(((DateRange) o2).getMinValue()); } } throw new IllegalArgumentException("Unxpected object type found, was expecting date or date range but found " + o1 + " and " + o2); } }; /** * Comparator for TreeSet made either by Double objects, or by NumberRange objects */ private static final Comparator<Object> ELEVATION_COMPARATOR = new Comparator<Object>() { @Override public int compare(Object o1, Object o2) { if(o1 instanceof Double) { if(o2 instanceof Double) { return ((Double) o1).compareTo((Double) o2); } else if(o2 instanceof NumberRange) { return ((Double) o1).compareTo(((NumberRange<Double>) o2).getMinValue()); } } else if(o1 instanceof NumberRange) { if(o2 instanceof NumberRange) { return ((NumberRange<Double>) o1).getMinValue().compareTo(((NumberRange<Double>) o2).getMinValue()); } else { return ((NumberRange<Double>) o1).getMinValue().compareTo((Double) o2); } } throw new IllegalArgumentException("Unxpected object type found, was expecting double or range of doubles but found " + o1 + " and " + o2); } }; private final GridCoverage2DReader reader; private final List<String> metadataNames= new ArrayList<String>(); public ReaderDimensionsAccessor(GridCoverage2DReader reader) throws IOException { Utilities.ensureNonNull("reader", reader); this.reader = reader; final String[] dimensions = reader.getMetadataNames(); if (dimensions != null) { metadataNames.addAll(Arrays.asList(dimensions)); } } /** * True if the reader has a time dimension * * * @throws IOException */ public boolean hasTime() throws IOException { return "true".equalsIgnoreCase(reader.getMetadataValue(HAS_TIME_DOMAIN)); } /** * Returns the full set of time values supported by the raster, sorted by time. * They are either {@link Date} objects, or {@link DateRange} objects, according to what * the underlying reader provides. * * * @throws IOException */ public TreeSet<Object> getTimeDomain() throws IOException { if (!hasTime()) { Collections.emptySet(); } final SimpleDateFormat df = getTimeFormat(); String domain = reader.getMetadataValue(TIME_DOMAIN); String[] timeInstants = domain.split("\\s*,\\s*"); TreeSet<Object> values = new TreeSet<Object>(TEMPORAL_COMPARATOR); for (String tp : timeInstants) { try { values.add(parseTimeOrRange(df, tp)); } catch (ParseException e) { LOGGER.log(Level.WARNING, e.getMessage(), e); } } return values; } /** * Returns the set of time values supported by the raster, sorted by time, in the * specified range. * They are either {@link Date} objects, or {@link DateRange} objects, according to what * the underlying reader provides. * * * @throws IOException */ public TreeSet<Object> getTimeDomain(DateRange range, int maxEntries) throws IOException { if (!hasTime()) { Collections.emptySet(); } TreeSet<Object> result = null; if(reader instanceof StructuredGridCoverage2DReader) { StructuredGridCoverage2DReader sr = (StructuredGridCoverage2DReader) reader; result = getDimensionValuesInRange("time", range, maxEntries, sr); } // if we got here, the optimization did not work, do the normal path if(result == null) { result = new TreeSet<Object>(); TreeSet<Object> fullDomain = getElevationDomain(); for (Object o : fullDomain) { if(o instanceof Date) { if(range.contains((Date) o)) { result.add(o); } } else if(o instanceof DateRange) { if(range.intersects((DateRange) o)) { result.add(o); } } } } return result; } /** * Parses either a time expression in ISO format, or a time period in start/end format * @param df * @param timeOrRange * * @throws ParseException */ private Object parseTimeOrRange(SimpleDateFormat df, String timeOrRange) throws ParseException { if(timeOrRange.contains("/")) { String[] splitted = timeOrRange.split("/"); final String strStart = splitted[0]; final String strEnd = splitted[1]; if(strStart != null && strStart.equals(strEnd)) { return df.parse(strStart); } else { Date start = df.parse(strStart); Date end = df.parse(strEnd); return new DateRange(start, end); } } else { return df.parse(timeOrRange); } } /** * Parses the specified value as a NumberRange if it's in the min/max form, as a Double otherwise * @param val * */ private Object parseNumberOrRange(String val) { if(val.contains("/")) { String[] splitted = val.split("/"); final String strStart = splitted[0]; final String strEnd = splitted[1]; if(strStart.equals(strEnd)) { return Double.parseDouble(strStart); } double start = Double.parseDouble(strStart); double end = Double.parseDouble(strEnd); return new NumberRange<Double>(Double.class, start, end); } else { return Double.parseDouble(val); } } /** * Returns the max value for the time, either as a single {@link Date} or {@link DateRange} * according to what the underlying reader provides * * * @throws IOException */ public Date getMaxTime() throws IOException { if (!hasTime()) { return null; } final String currentTime = reader .getMetadataValue(AbstractGridCoverage2DReader.TIME_DOMAIN_MAXIMUM); if (currentTime == null) { return null; } try { return getTimeFormat().parse(currentTime); } catch (ParseException e) { throw new RuntimeException("Failed to get CURRENT time from coverage reader", e); } } /** * Returns the min value for the time * * * @throws IOException */ public Date getMinTime() throws IOException { if (!hasTime()) { return null; } final String currentTime = reader .getMetadataValue(AbstractGridCoverage2DReader.TIME_DOMAIN_MINIMUM); if (currentTime == null) { return null; } try { return getTimeFormat().parse(currentTime); } catch (ParseException e) { throw new RuntimeException("Failed to get minimum time from coverage reader", e); } } /** * Returns a {@link SimpleDateFormat} using the UTC_PATTERN and the UTC time zone * * */ public SimpleDateFormat getTimeFormat() { final SimpleDateFormat df = new SimpleDateFormat(UTC_PATTERN); df.setTimeZone(UTC_TIME_ZONE); return df; } /** * True if the reader has a elevation dimension * * * @throws IOException */ public boolean hasElevation() throws IOException { return "true".equalsIgnoreCase(reader.getMetadataValue(HAS_ELEVATION_DOMAIN)); } /** * Returns the full set of elevation values (either as Double or NumberRange), sorted from smaller to higher * * * @throws IOException */ public TreeSet<Object> getElevationDomain() throws IOException { if (!hasElevation()) { return null; } // parse the values from the reader, they are exposed as strings... String[] elevationValues = reader.getMetadataValue(ELEVATION_DOMAIN).split(","); TreeSet<Object> elevations = new TreeSet<Object>(ELEVATION_COMPARATOR); for (String val : elevationValues) { try { elevations.add(parseNumberOrRange(val)); } catch (Exception e) { LOGGER.log(Level.WARNING, e.getMessage(), e); } } return elevations; } /** * Returns the set of elevation values supported by the raster, sorted from smaller to bigger, in the * specified range. * They are either {@link Double} objects, or {@link NumberRange} objects, according to what * the underlying reader provides. * * @throws IOException */ public TreeSet<Object> getElevationDomain(NumberRange range, int maxEntries) throws IOException { if (!hasElevation()) { Collections.emptySet(); } // special optimization for structured coverage readers TreeSet<Object> result = null; if(reader instanceof StructuredGridCoverage2DReader) { StructuredGridCoverage2DReader sr = (StructuredGridCoverage2DReader) reader; result = getDimensionValuesInRange("elevation", range, maxEntries, sr); } // if we got here, the optimization did not work, do the normal path if(result == null) { result = new TreeSet<Object>(); TreeSet<Object> fullDomain = getElevationDomain(); for (Object o : fullDomain) { if(o instanceof Double) { if(range.contains((Number) o)) { result.add(o); } } else if(o instanceof NumberRange) { if(range.intersects((NumberRange) o)) { result.add(o); } } } } return result; } private TreeSet<Object> getDimensionValuesInRange(String dimensionName, Range range, int maxEntries, StructuredGridCoverage2DReader sr) throws IOException { final String name = sr.getGridCoverageNames()[0]; List<DimensionDescriptor> descriptors = sr.getDimensionDescriptors(name); for (DimensionDescriptor descriptor : descriptors) { // do we find the time, and can we optimize? if (dimensionName.equalsIgnoreCase(descriptor.getName()) && descriptor.getEndAttribute() == null) { GranuleSource gs = sr.getGranules(name, true); final Query query = new Query(gs.getSchema().getName().getLocalPart()); // The NetCDF plug-in gets a corrupted cache if we provide a property list // query.setPropertyNames(Arrays.asList(descriptor.getStartAttribute())); final PropertyName attribute = FF.property(descriptor.getStartAttribute()); final PropertyIsBetween rangeFilter = FF.between(attribute, FF.literal(range.getMinValue()), FF.literal(range.getMaxValue())); query.setFilter(rangeFilter); query.setMaxFeatures(maxEntries); FeatureCollection collection = gs.getGranules(query); // collect all unique values (can't do ranges now, we don't have a multi-attribute unique visitor) UniqueVisitor visitor = new UniqueVisitor(attribute); collection.accepts(visitor, null); TreeSet<Object> result = new TreeSet<>(visitor.getUnique()); return result; } } return null; } /** * Returns the max value for the elevation (as a Double, or as a NumberRange) * * * @throws IOException */ public Double getMaxElevation() throws IOException { if (!hasElevation()) { return null; } final String elevation = reader .getMetadataValue(AbstractGridCoverage2DReader.ELEVATION_DOMAIN_MAXIMUM); if (elevation == null) { return null; } try { return Double.parseDouble(elevation); } catch (NumberFormatException e) { throw new RuntimeException("Failed to get maximum elevation from coverage reader", e); } } /** * Returns the min value for the elevation (as a Double, or as a NumbeRange) * * * @throws IOException */ public Double getMinElevation() throws IOException { if (!hasElevation()) { return null; } final String elevation = reader .getMetadataValue(AbstractGridCoverage2DReader.ELEVATION_DOMAIN_MINIMUM); if (elevation == null) { return null; } try { return Double.parseDouble(elevation); } catch (NumberFormatException e) { throw new RuntimeException("Failed to get minimum elevation from coverage reader", e); } } /** * Lists the custom domains of a raster data set * */ public List<String> getCustomDomains() { if (metadataNames.isEmpty()) { return Collections.emptyList(); } Set<String> names = new HashSet<String>(metadataNames); TreeSet<String> result = new TreeSet<String>(); for (String name : names) { if(name.startsWith("HAS_") && name.endsWith("_DOMAIN")) { String dimension = name.substring(4, name.length() - 7); if(names.contains(dimension + "_DOMAIN") && !"TIME".equals(dimension) && !"ELEVATION".equals(dimension)) { result.add(dimension); } } } return new ArrayList<String>(result); } /** * Return the domain datatype (if available) * @param domainName * * @throws IOException */ public String getDomainDatatype(final String domainName) throws IOException { return reader.getMetadataValue(domainName.toUpperCase() + "_DOMAIN_DATATYPE"); } /** * True if the reader has a dimension with the given name * @throws IOException */ public boolean hasDomain(String name) throws IOException { Utilities.ensureNonNull("name", name); return "true".equalsIgnoreCase(reader.getMetadataValue("HAS_" + name.toUpperCase() + "_DOMAIN")); } /** * Returns the full set of values for the given dimension * @throws IOException */ public List<String> getDomain(String name) throws IOException { String[] values = reader.getMetadataValue(name.toUpperCase() + "_DOMAIN").split(","); List<String> valueSet = new ArrayList<String>(); for (String val : values) { valueSet.add(val); } return valueSet; } /** * Extracts the custom domain lowest value (using String sorting) * * @throws IOException */ public String getCustomDomainDefaultValue(String name) throws IOException { Utilities.ensureNonNull("name", name); // see if we have an optimize way to get the minimum String minimum = reader.getMetadataValue(name.toUpperCase() + "_DOMAIN_MINIMUM"); if (minimum != null) { return minimum; } // ok, get the full domain then List<String> domain = getDomain(name); if (domain.isEmpty()) { return null; } else { return domain.get(0); } } /** * Checks if this dimension has a range (min/max) or just a domain * @param domain * */ public boolean hasRange(String domain) { return metadataNames.contains(domain + "_DOMAIN_MAXIMUM") && metadataNames.contains(domain + "_DOMAIN_MINIMUM"); } /** * Checks if this dimension has a resolution * @param domain * */ public boolean hasResolution(String domain) { Utilities.ensureNonNull("name", domain); return metadataNames.contains(domain.toUpperCase() + "_DOMAIN_RESOLUTION"); } public Collection<Object> convertDimensionValue(String name, String value) { List<Object> result = new ArrayList<Object>(); try { String typeName = getDomainDatatype(name); if (typeName != null) { Class<?> type = Class.forName(typeName); if (type == java.util.Date.class) { result.addAll(new TimeParser().parse(value)); } else { for (String element : value.split(",")) { result.add(Converters.convert(element, type)); } } } else { result.add(value); } } catch (Exception e) { LOGGER.log(Level.WARNING, "Failed to convert dimension value: ", e); result.add(value); } return result; } public List<Object> convertDimensionValue(String name, List<String> value) { List<Object> list = new ArrayList<Object>(); for (String val : value) { list.addAll(convertDimensionValue(name, val)); } return list; } }