blob: bd14569dec17c749fc0968e18e8708a0e8e0962a [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.internal.earth.netcdf;
import java.util.Set;
import java.util.Map;
import java.util.HashMap;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.regex.Pattern;
import org.opengis.referencing.crs.ProjectedCRS;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.TransformException;
import org.apache.sis.storage.netcdf.AttributeNames;
import org.apache.sis.internal.netcdf.Convention;
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.Linearizer;
import org.apache.sis.internal.netcdf.Node;
import org.apache.sis.internal.referencing.provider.Sinusoidal;
import org.apache.sis.internal.referencing.provider.Equirectangular;
import org.apache.sis.internal.referencing.provider.PolarStereographicA;
import org.apache.sis.referencing.operation.transform.TransferFunction;
import org.apache.sis.referencing.operation.transform.MathTransforms;
import org.apache.sis.referencing.operation.matrix.Matrix3;
import org.apache.sis.referencing.CommonCRS;
import org.apache.sis.measure.NumberRange;
import ucar.nc2.constants.CF;
/**
* Global Change Observation Mission - Climate (GCOM-C) conventions. This class provides customizations to the netCDF reader
* for decoding <cite>Shikisai</cite> GCOM-C files produced by Japan Aerospace Exploration Agency (JAXA), version 1.00.
* The file format is HDF5 and variables are like below (simplified):
*
* {@preformat text
* group: Geometry_data {
* variables:
* float Latitude(161, 126)
* string Unit = "degree"
* string Dim0 = "Line grids"
* string Dim1 = "Pixel grids"
* int Resampling_interval = 10
* float Longitude(161, 126)
* string Unit = "degree"
* string Dim0 = "Line grids"
* string Dim1 = "Pixel grids"
* int Resampling_interval = 10
* }
* group: Image_data {
* variables:
* ushort SST(1599, 1250) // Note: different size than (latitude, longitude) variables.
* string dim0 = "Line grids"
* string dim1 = "Piexl grids" // Note: typo in "Pixel"
* ushort Error_DN = 65535
* ushort Land_DN = 65534
* ushort Cloud_error_DN = 65533
* ushort Retrieval_error_DN = 65532
* ushort Maximum_valid_DN = 65531
* ushort Minimum_valid_DN = 0
* float Slope = 0.0012
* float Offset = -10
* string Unit = "degree"
* }
* group: Global_attributes {
* string :Algorithm_developer = "Japan Aerospace Exploration Agency (JAXA)"
* string :Dataset_description = "Sea surface temperatures determined by using the TIR 1 and 2 data of SGLI"
* string :Satellite = "Global Change Observation Mission - Climate (GCOM-C)"
* string :Scene_start_time = "20181201 09:14:16.797"
* string :Scene_end_time = "20181201 09:18:11.980"
* }
* group: Processing_attributes {
* string :Contact_point = "JAXA/Earth Observation Research Center (EORC)"
* string :Processing_organization = "JAXA/GCOM-C science project"
* string :Processing_UT = "20181202 04:42:09"
* }
* }
*
* Observations:
* <ul class="verbose">
* <li>There is no {@code convention} attribute, so we have to rely on something else for detecting this convention.</li>
* <li>The size of latitude and longitude variables is not the same than the size of image data.
* This particularity is handled by {@link #gridToDataIndices(Variable)}.</li>
* <li>The {@code dim0} and {@code dim1} attribute names in image data have a different case
* than the attributes in longitude and latitude variables. Furthermore a value contains a typo.
* This particularity is handled by {@link #nameOfDimension(Variable, int)}.</li>
* <li>The {@code Slope} and {@code Offset} attribute names are different than the names defined in CF-Convention.
* This particularity is handled by {@link #transferFunction(Variable)}.</li>
* <li>There is more than one sentinel values for missing values.
* All attribute names for missing values have {@code "_DN"} suffix.
* This particularity is handled by {@link #nodataValues(Variable)}.</li>
* <li>The global attributes have different names than CF-Convention.
* This particularity is handled by {@link #mapAttributeName(String)}.</li>
* </ul>
*
* @author Alexis Manin (Geomatys)
* @author Martin Desruisseaux (Geomatys)
* @version 1.1
*
* @see <a href="http://global.jaxa.jp/projects/sat/gcom_c/">SHIKISAI (GCOM-C) on JAXA</a>
* @see <a href="https://en.wikipedia.org/wiki/Global_Change_Observation_Mission">GCOM on Wikipedia</a>
*
* @since 1.0
* @module
*/
public final class GCOM_C extends Convention {
/**
* Sentinel value to search in the {@code "Satellite"} attribute for determining if GCOM-C conventions apply.
*/
private static final Pattern SENTINEL_VALUE = Pattern.compile(".*\\bGCOM-C\\b.*");
/**
* Mapping from ACDD or CF-Convention attribute names to names of attributes used by GCOM-C.
*/
private static final Map<String,String> ATTRIBUTES;
static {
final Map<String,String> m = new HashMap<>();
m.put(AttributeNames.TITLE, "Product_name"); // identification­Info / citation / title
m.put(AttributeNames.PRODUCT_VERSION, "Product_version"); // identification­Info / citation / edition
m.put(AttributeNames.IDENTIFIER.TEXT, "Product_file_name"); // identification­Info / citation / identifier / code
m.put(AttributeNames.DATE_CREATED, "Processing_UT"); // identification­Info / citation / date
m.put(AttributeNames.CREATOR.INSTITUTION, "Processing_organization"); // identification­Info / citation / citedResponsibleParty
m.put(AttributeNames.SUMMARY, "Dataset_description"); // identification­Info / abstract
m.put(AttributeNames.PLATFORM.TEXT, "Satellite"); // acquisition­Information / platform / identifier
m.put(AttributeNames.INSTRUMENT.TEXT, "Sensor"); // acquisition­Information / platform / instrument / identifier
m.put(AttributeNames.PROCESSING_LEVEL, "Product_level"); // content­Info / processing­Level­Code
m.put(AttributeNames.SOURCE, "Input_files"); // data­Quality­Info / lineage / source / description
m.put(AttributeNames.TIME.MINIMUM, "Scene_start_time"); // identification­Info / extent / temporal­Element / extent
m.put(AttributeNames.TIME.MAXIMUM, "Scene_end_time"); // identification­Info / extent / temporal­Element / extent
ATTRIBUTES = m;
}
/**
* Name of the group defining map projection parameters, localization grid, <i>etc</i>.
*/
private static final String GEOMETRY_DATA = "Geometry_data";
/**
* Names of attributes for sample values having "no-data" meaning.
* All those names have {@value #SUFFIX} suffix.
*/
private static final String[] NO_DATA = {
"Error_DN",
"Land_DN",
"Cloud_error_DN",
"Retrieval_error_DN"
};
/**
* Suffix of all attribute names enumerated in {@link #NO_DATA}.
*/
private static final String SUFFIX = "_DN";
/**
* Creates a new instance of GCOM-C conventions.
*/
public GCOM_C() {
}
/**
* Detects if this set of conventions applies to the given netCDF file.
* This methods fetches the {@code "Global_attributes/Satellite"} attribute,
* which is expected to contain the following value:
*
* <blockquote>Global Change Observation Mission - Climate (GCOM-C)</blockquote>
*
* We test only for presence of {@code "GCOM-C"} in order to allow
* for some variations in exact text content.
*
* @param decoder the netCDF file to test.
* @return {@code true} if this set of conventions can apply.
*/
@Override
protected boolean isApplicableTo(final Decoder decoder) {
final String[] path = decoder.getSearchPath();
decoder.setSearchPath("Global_attributes");
final String s = decoder.stringValue("Satellite");
decoder.setSearchPath(path); // Must reset the decoder in its original state.
return (s != null) && SENTINEL_VALUE.matcher(s).matches();
}
/**
* Specifies a list of groups where to search for named attributes, in preference order.
* This is used for ISO 19115 metadata. The {@code null} name stands for the root group.
*
* @return name of groups where to search for global attributes, in preference order.
*/
@Override
public String[] getSearchPath() {
return new String[] {"Global_attributes", null, "Processing_attributes"};
}
/**
* Returns the name of an attribute in this convention which is equivalent to the attribute of given name in CF-Convention.
* The given parameter is a name from <cite>CF conventions</cite> or from <cite>Attribute Convention for Dataset Discovery
* (ACDD)</cite>.
*
* @param name an attribute name from CF or ACDD convention.
* @return the attribute name expected to be found in a netCDF file for GCOM-C, or {@code name} if unknown.
*/
@Override
public String mapAttributeName(final String name) {
return ATTRIBUTES.getOrDefault(name, name);
}
/**
* Returns whether the given variable is used as a coordinate system axis, a coverage or something else.
*
* @param variable the variable for which to get the role, or {@code null}.
* @return role of the given variable, or {@code null} if the given variable was null.
*/
@Override
public VariableRole roleOf(final Variable variable) {
VariableRole role = super.roleOf(variable);
if (role == VariableRole.COVERAGE) {
/*
* Exclude (for now) some variables associated to longitude and latitude: Obs_time, Sensor_zenith, Solar_zenith.
* In a future version we should probably keep them but store them in their own resource aggregate.
*/
final String group = variable.getGroupName();
if (GEOMETRY_DATA.equalsIgnoreCase(group)) {
role = VariableRole.OTHER;
}
}
return role;
}
// ┌────────────────────────────────────────────────────────────────────────────────────────────┐
// │ COVERAGE DOMAIN │
// └────────────────────────────────────────────────────────────────────────────────────────────┘
/**
* Returns the attribute-specified name of the dimension at the given index, or {@code null} if unspecified.
* See {@link Convention#nameOfDimension(Variable, int)} for a more detailed explanation of this information.
* The implementation in this class fixes a typo found in some {@code "Dim1"} attribute values and generates
* the values when they are known to be missing.
*
* @param dataOrAxis the variable for which to get the attribute-specified name of the dimension.
* @param index zero-based index of the dimension for which to get the name.
* @return dimension name as specified by attributes, or {@code null} if none.
*/
@Override
public String nameOfDimension(final Variable dataOrAxis, final int index) {
String name = super.nameOfDimension(dataOrAxis, index);
if (name == null) {
if ("QA_flag".equals(dataOrAxis.getName())) {
/*
* The "QA_flag" variable is missing "Dim0" and "Dim1" attribute in GCOM-C version 1.00.
* However not all GCOM-C files use a localization grid. We use the presence of spatial
* resolution attribute as a sentinel value for now.
*/
if (dataOrAxis.getAttributeType("Spatial_resolution") != null) {
switch (index) {
case 0: name = "Line grids"; break;
case 1: name = "Pixel grids"; break;
}
}
}
} else if ("Piexl grids".equalsIgnoreCase(name)) { // Typo in GCOM-C version 1.00.
name = "Pixel grids";
}
return name;
}
/**
* Returns an enumeration of two-dimensional non-linear transforms that may be tried in attempts to make
* localization grid more linear.
*
* @param decoder the netCDF file for which to determine linearizers that may possibly apply.
* @return enumeration of two-dimensional non-linear transforms to try.
*/
@Override
public Set<Linearizer> linearizers(final Decoder decoder) {
return Collections.singleton(Linearizer.GROUND_TRACK);
}
/**
* Returns the name of nodes (variables or groups) that may define the map projection parameters.
* For GCOM files, this is {@value #GEOMETRY_DATA}.
*
* @param data the variable for which to get the grid mapping node.
* @return name of nodes that may contain the grid mapping, or an empty set if none.
*/
@Override
public Set<String> nameOfMappingNode(final Variable data) {
final Set<String> names = new LinkedHashSet<>(4);
names.add(GEOMETRY_DATA);
names.addAll(super.nameOfMappingNode(data)); // Fallback if geometry data does not exist.
return names;
}
/**
* Returns the map projection definition for the given data variable.
* This method expects the following attribute names in the {@value #GEOMETRY_DATA} group:
*
* {@preformat text
* group: Geometry_data {
* // group attributes:
* string Image_projection = "EQA (sinusoidal equal area) projection from 0-deg longitude"
* float Upper_left_longitude = 115.17541
* float Upper_left_latitude = 80.0
* float Upper_right_longitude = 172.7631
* float Upper_right_latitude = 80.0
* float Lower_left_longitude = 58.47609
* float Lower_left_latitude = 70.0
* float Lower_right_longitude = 87.714134
* float Lower_right_latitude = 70.0
* }
* }
*
* @param node the group of variables from which to read attributes.
* @return the map projection definition as a modifiable map, or {@code null} if none.
*/
@Override
public Map<String,Object> projection(final Node node) {
final String name = node.getAttributeAsString("Image_projection");
if (name == null) {
return super.projection(node);
}
final String method;
final int s = name.indexOf(' ');
final String code = (s >= 0) ? name.substring(0, s) : name;
if (code.equalsIgnoreCase("EQA")) {
method = Sinusoidal.NAME;
} else if (code.equalsIgnoreCase("EQR")) {
method = Equirectangular.NAME;
} else if (code.equalsIgnoreCase("PS")) {
method = PolarStereographicA.NAME;
} else {
return super.projection(node);
}
final Map<String,Object> definition = new HashMap<>(4);
definition.put(CF.GRID_MAPPING_NAME, method);
definition.put(CONVERSION_NAME, name);
return definition;
}
/**
* The attributes storing values of the 4 corners in degrees with (latitude, longitude) axis order.
* This is used by {@link #projection(Node)} for inferring a "grid to CRS" transform.
*/
private static final String[] CORNERS = {
"Upper_left_latitude",
"Upper_left_longitude",
"Upper_right_latitude",
"Upper_right_longitude",
"Lower_left_latitude",
"Lower_left_longitude",
"Lower_right_latitude",
"Lower_right_longitude"
};
/**
* Returns the <cite>grid to CRS</cite> transform for the given node.
* This method is invoked after call to {@link #projection(Node)} resulted in creation of a projected CRS.
* The {@linkplain ProjectedCRS#getBaseCRS() base CRS} shall have (latitude, longitude) axes in degrees.
*
* @param node the same node than the one given to {@link #projection(Node)}.
* @param baseToCRS conversion from (latitude, longitude) in degrees to the projected CRS.
* @return the "grid corner to CRS" transform, or {@code null} if none or unknown.
* @throws TransformException if a coordinate operation was required but failed.
*/
@Override
public MathTransform gridToCRS(final Node node, final MathTransform baseToCRS) throws TransformException {
final double[] corners = new double[CORNERS.length];
for (int i=0; i<corners.length; i++) {
corners[i] = node.getAttributeAsNumber(CORNERS[i]);
}
baseToCRS.transform(corners, 0, corners, 0, corners.length / 2);
/*
* Compute spans of data (typically in metres) as the average of the spans on both sides
* (width as length of top and bottom edges, height as length of left and right edges).
* This code assumes (easting, northing) axes — this is currently not verified.
*/
double sx = ((corners[2] - corners[0]) + (corners[6] - corners[4])) / 2;
double sy = ((corners[1] - corners[5]) + (corners[3] - corners[7])) / 2;
/*
* Transform the spans into pixel sizes (resolution), then build the transform.
*/
sx /= (node.getAttributeAsNumber("Number_of_pixels") - 1);
sy /= (node.getAttributeAsNumber("Number_of_lines") - 1);
if (Double.isFinite(sx) && Double.isFinite(sy)) {
final Matrix3 m = new Matrix3();
m.m00 = sx;
m.m11 = -sy;
m.m02 = corners[0];
m.m12 = corners[1];
return MathTransforms.linear(m);
}
return super.gridToCRS(node, baseToCRS);
}
/**
* Returns the default prime meridian, ellipsoid, datum or CRS to use if no information is found in the netCDF file.
* While GCOM documentation said that the datum is WGS 84, we have found that the map projection applied use spherical
* formulas.
*
* @param spherical ignored, since we assume a sphere in all cases.
* @return information about geodetic objects to use if no explicit information is found in the file.
*/
@Override
public CommonCRS defaultHorizontalCRS(final boolean spherical) {
return CommonCRS.SPHERE;
}
// ┌────────────────────────────────────────────────────────────────────────────────────────────┐
// │ COVERAGE RANGE │
// └────────────────────────────────────────────────────────────────────────────────────────────┘
/**
* Returns the range of valid values, or {@code null} if unknown.
*
* @param data the variable to get valid range of values for.
* @param nodataValues the fill values and padding values.
* @return the range of valid values, or {@code null} if unknown.
*/
@Override
public NumberRange<?> validRange(final Variable data, final Set<Number> nodataValues) {
NumberRange<?> range = super.validRange(data, nodataValues);
if (range == null) {
final double min = data.getAttributeAsNumber("Minimum_valid_DN");
final double max = data.getAttributeAsNumber("Maximum_valid_DN");
if (Double.isFinite(min) && Double.isFinite(max)) {
range = NumberRange.createBestFit(min, true, max, true);
}
}
return range;
}
/**
* Returns all no-data values declared for the given variable, or an empty map if none.
* The map keys are the no-data values (pad sample values or missing sample values).
* The map values are {@link String} instances containing the description of the no-data value.
*
* @param data the variable for which to get no-data values.
* @return no-data values with textual descriptions.
*/
@Override
public Map<Number,Object> nodataValues(final Variable data) {
final Map<Number, Object> pads = super.nodataValues(data);
for (String name : NO_DATA) {
final double value = data.getAttributeAsNumber(name);
if (Double.isFinite(value)) {
if (name.endsWith(SUFFIX)) {
name = name.substring(0, name.length() - SUFFIX.length());
}
pads.put(value, name.replace('_', ' '));
}
}
return pads;
}
/**
* Builds the function converting values from their packed formats in the variable to "real" values.
* This method is invoked only if {@link #validRange(Variable, Set)} returned a non-null value.
*
* @param data the variable from which to determine the transfer function.
* @return a transfer function built from the attributes defined in the given variable.
*/
@Override
public TransferFunction transferFunction(final Variable data) {
final TransferFunction tr = super.transferFunction(data);
if (tr.isIdentity()) {
final double slope = data.getAttributeAsNumber("Slope");
final double offset = data.getAttributeAsNumber("Offset");
if (Double.isFinite(slope)) tr.setScale (slope);
if (Double.isFinite(offset)) tr.setOffset(offset);
}
return tr;
}
}