blob: 453fa3c221b0e6aaece21a65533bbd0df590b7f0 [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.referencing.provider;
import java.util.Collections;
import java.util.Locale;
import java.util.NoSuchElementException;
import java.util.StringTokenizer;
import java.util.logging.Level;
import java.util.logging.LogRecord;
import java.io.BufferedReader;
import java.io.EOFException;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.Files;
import javax.xml.bind.annotation.XmlTransient;
import javax.measure.quantity.Angle;
import javax.measure.quantity.Length;
import org.opengis.parameter.ParameterValueGroup;
import org.opengis.parameter.ParameterDescriptor;
import org.opengis.parameter.ParameterDescriptorGroup;
import org.opengis.parameter.ParameterNotFoundException;
import org.opengis.parameter.InvalidParameterValueException;
import org.opengis.referencing.datum.Ellipsoid;
import org.opengis.referencing.operation.MathTransform;
import org.opengis.referencing.operation.MathTransformFactory;
import org.opengis.referencing.operation.NoninvertibleTransformException;
import org.opengis.util.FactoryException;
import org.apache.sis.internal.system.Loggers;
import org.apache.sis.internal.system.DataDirectory;
import org.apache.sis.internal.referencing.NilReferencingObject;
import org.apache.sis.parameter.ParameterBuilder;
import org.apache.sis.parameter.Parameters;
import org.apache.sis.measure.Units;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.util.resources.Errors;
import org.apache.sis.util.collection.Cache;
import org.apache.sis.referencing.CommonCRS;
import org.apache.sis.referencing.datum.DefaultEllipsoid;
import org.apache.sis.referencing.operation.transform.InterpolatedGeocentricTransform;
import static java.lang.Float.parseFloat;
/**
* The provider for <cite>"France geocentric interpolation"</cite> (ESPG:9655).
* This operation requires a grid file provided by the French mapping agency.
*
* <p><b>Source:</b> IGN document {@code NTG_88.pdf},
* <cite>"Grille de paramètres de transformation de coordonnées"</cite>
* at <a href="http://www.ign.fr">http://www.ign.fr</a>.</p>
*
* In principle, this operation method is designed specifically for the French mapping
* (e.g. EPSG:1053 <cite>"NTF to RGF93 (1)"</cite>) using the following hard-coded parameters:
* <ul>
* <li>Source ellipsoid: Clarke 1880</li>
* <li>Target ellipsoid: RGF93</li>
* <li>Initial X-axis translation: {@value #TX} (sign reversed)</li>
* <li>Initial Y-axis translation: {@value #TY} (sign reversed)</li>
* <li>Initial Z-axis translation: {@value #TZ} (sign reversed)</li>
* </ul>
*
* However the Apache SIS implementation is designed in such a way that this operation method
* could be used for other areas.
*
* @author Simon Reynard (Geomatys)
* @author Martin Desruisseaux (Geomatys)
* @version 0.8
* @since 0.7
* @module
*/
@XmlTransient
public class FranceGeocentricInterpolation extends GeodeticOperation {
/**
* Serial number for inter-operability with different versions.
*/
private static final long serialVersionUID = -4707304160205218546L;
/**
* Geocentric translation parameters to use as a first guess before to use the grid in France.
* The values of those parameters are specified by the NTG_88 document and apply only for France.
* If the geocentric interpolation is used for other area, other parameter values will be needed.
*
* <p>The values used by SIS are from source (RGF93) to target (NTF). This is the opposite of the
* direction defined in NTG_88. Consequently the signs need to be the opposite of NTG_88 values.</p>
*/
public static final double TX = 168, TY = 60, TZ = -320;
/**
* Precision of offset values in the grid file. The "GR3DF97A.txt" file uses a precision of 0.001.
* But we define here one more digit in case a user gives a more accurate grid.
*
* Note that value of {@code ulp((float) max(|TX|, |TY|, |TZ|))} is about 3E-5. Consequently the
* value of {@code PRECISION} should not be lower than 1E-4 (assuming that we want a power of 10).
*/
static final double PRECISION = 0.0001;
/**
* Accuracies of offset values. Accuracies in GR3D files are defined by standard-deviations rounded
* to integer values. The mapping given in NTG_88 document is:
*
* 01 = 5 cm,
* 02 = 10 cm,
* 03 = 20 cm,
* 04 = 50 cm and
* 99 &gt; 1 m.
*/
private static final double[] ACCURACY = {0.05, 0.1, 0.2, 0.5, 1};
/**
* The keyword expected at the beginning of every lines in the header.
*/
private static final String HEADER = "GR3D";
/**
* Name of the default grid file, as mentioned in the NTG_88 document.
* We use the 5 first characters ({@code "gr3df"}) as a sentinel value for French grid file.
*
* @see #isRecognized(Path)
*/
private static final String DEFAULT = "gr3df97a.txt";
/**
* The operation parameter descriptor for the <cite>Geocentric translation file</cite> parameter value.
*
* <!-- Generated by ParameterNameTableGenerator -->
* <table class="sis">
* <caption>Parameter names</caption>
* <tr><td> EPSG: </td><td> Geocentric translation file </td></tr>
* </table>
* <b>Notes:</b>
* <ul>
* <li>Default value: {@code gr3df97a.txt}</li>
* </ul>
*/
public static final ParameterDescriptor<Path> FILE;
/**
* The group of all parameters expected by this coordinate operation. The only parameter formally defined by EPSG
* is {@link #FILE}. All other parameters have been taken from {@link Molodensky} since geocentric interpolations
* can be though as a Molodensky operations with non-constant (ΔX, ΔY, ΔZ) geocentric translation terms.
*/
public static final ParameterDescriptorGroup PARAMETERS;
static {
final ParameterBuilder builder = builder();
FILE = builder
.addIdentifier("8727")
.addName("Geocentric translation file")
.create(Path.class, Paths.get(DEFAULT));
PARAMETERS = builder
.addIdentifier("9655")
.addName("France geocentric interpolation")
.createGroup(Molodensky.DIMENSION, // Not an EPSG parameter.
Molodensky.SRC_SEMI_MAJOR,
Molodensky.SRC_SEMI_MINOR,
Molodensky.TGT_SEMI_MAJOR,
Molodensky.TGT_SEMI_MINOR,
FILE);
}
/**
* Constructs a provider.
*/
public FranceGeocentricInterpolation() {
this(2, 2, PARAMETERS, new FranceGeocentricInterpolation[4]);
redimensioned[0] = this;
redimensioned[1] = new FranceGeocentricInterpolation(2, 3, PARAMETERS, redimensioned);
redimensioned[2] = new FranceGeocentricInterpolation(3, 2, PARAMETERS, redimensioned);
redimensioned[3] = new FranceGeocentricInterpolation(3, 3, PARAMETERS, redimensioned);
}
/**
* Constructs a provider for the given number of dimensions.
*
* @param sourceDimensions number of dimensions in the source CRS of this operation method.
* @param targetDimensions number of dimensions in the target CRS of this operation method.
* @param parameters description of parameters expected by this operation.
* @param redimensioned providers for all combinations between 2D and 3D cases, or {@code null}.
*/
FranceGeocentricInterpolation(final int sourceDimensions,
final int targetDimensions,
final ParameterDescriptorGroup parameters,
final GeodeticOperation[] redimensioned)
{
super(sourceDimensions, targetDimensions, parameters, redimensioned);
}
/**
* Returns {@code true} if the given path seems to be a grid published by the French mapping agency for France.
* In principle this <cite>"France geocentric interpolation"</cite> is designed specifically for use with the
* {@code "gr3df97a.txt"} grid, but in fact the Apache SIS implementation should be flexible enough for use
* with other area.
*
* @param file the grid file.
* @return {@code true} if the given file looks like a fie from the French mapping agency.
*/
public static boolean isRecognized(final Path file) {
return file.getFileName().toString().regionMatches(true, 0, DEFAULT, 0, 5);
}
/**
* The inverse of {@code FranceGeocentricInterpolation} is a different operation.
*
* @return {@code false}.
*/
@Override
public final boolean isInvertible() {
return false;
}
/**
* Notifies {@code DefaultMathTransformFactory} that map projections require values for the
* {@code "src_semi_major"}, {@code "src_semi_minor"} , {@code "tgt_semi_major"} and
* {@code "tgt_semi_minor"} parameters.
*
* @return 3, meaning that the operation requires source and target ellipsoids.
*/
@Override
public int getEllipsoidsMask() {
return 3;
}
/**
* Creates the source or the target ellipsoid. This is a temporary ellipsoid
* used only at {@link InterpolatedGeocentricTransform} time, then discarded.
*
* @param values the parameter group from which to get the axis lengths.
* @param semiMajor the descriptor for locating the semi-major axis parameter.
* @param semiMinor the descriptor for locating the semi-minor axis parameter.
* @param candidate an ellipsoid to return if the axis lengths match the lengths found in the parameters,
* or {@code null} if none. The intent is to use the pre-defined "GRS 1980" ellipsoid if
* we can, because that ellipsoid is defined by inverse flattening factor instead than by
* semi-minor axis length.
* @return a temporary ellipsoid encapsulating the axis lengths found in the parameters.
*/
private static Ellipsoid createEllipsoid(final Parameters values,
final ParameterDescriptor<Double> semiMajor,
final ParameterDescriptor<Double> semiMinor,
final Ellipsoid candidate)
{
final double semiMajorAxis = values.doubleValue(semiMajor); // Converted to metres.
final double semiMinorAxis = values.doubleValue(semiMinor); // Converted to metres.
if (candidate != null && Math.abs(candidate.getSemiMajorAxis() - semiMajorAxis) < 1E-6
&& Math.abs(candidate.getSemiMinorAxis() - semiMinorAxis) < 1E-6)
{
return candidate;
}
return DefaultEllipsoid.createEllipsoid(Collections.singletonMap(Ellipsoid.NAME_KEY,
NilReferencingObject.UNNAMED), semiMajorAxis, semiMinorAxis, Units.METRE);
}
/**
* Creates a transform from the specified group of parameter values.
* This method creates the transform from <em>target</em> to <em>source</em>
* (which is the direction that use the interpolation grid directly without iteration),
* then inverts the transform.
*
* @param factory the factory to use if this constructor needs to create other math transforms.
* @param values the group of parameter values.
* @return the created math transform.
* @throws ParameterNotFoundException if a required parameter was not found.
* @throws FactoryException if an error occurred while loading the grid.
*/
@Override
public MathTransform createMathTransform(final MathTransformFactory factory, final ParameterValueGroup values)
throws ParameterNotFoundException, FactoryException
{
boolean withHeights = false;
final Parameters pg = Parameters.castOrWrap(values);
final Integer dim = pg.getValue(Molodensky.DIMENSION);
if (dim != null) switch (dim) {
case 2: break;
case 3: withHeights = true; break;
default: throw new InvalidParameterValueException(Errors.format(
Errors.Keys.IllegalArgumentValue_2, "dim", dim), "dim", dim);
}
final Path file = pg.getMandatoryValue(FILE);
final DatumShiftGridFile<Angle,Length> grid = getOrLoad(file,
isRecognized(file) ? new double[] {TX, TY, TZ} : null, PRECISION);
MathTransform tr = createGeodeticTransformation(factory,
createEllipsoid(pg, Molodensky.TGT_SEMI_MAJOR,
Molodensky.TGT_SEMI_MINOR, CommonCRS.ETRS89.ellipsoid()), // GRS 1980 ellipsoid
createEllipsoid(pg, Molodensky.SRC_SEMI_MAJOR,
Molodensky.SRC_SEMI_MINOR, null), // Clarke 1880 (IGN) ellipsoid
withHeights, grid);
try {
tr = tr.inverse();
} catch (NoninvertibleTransformException e) {
throw new FactoryException(e); // Should never happen.
}
return tr;
}
/**
* Creates the actual math transform. The default implementation delegates to the static method defined in
* {@link InterpolatedGeocentricTransform}, but the {@link MolodenskyInterpolation} subclass will rather
* delegate to {@link org.apache.sis.referencing.operation.transform.InterpolatedMolodenskyTransform}.
*/
MathTransform createGeodeticTransformation(final MathTransformFactory factory,
final Ellipsoid source, final Ellipsoid target, final boolean withHeights,
final DatumShiftGridFile<Angle,Length> grid) throws FactoryException
{
return InterpolatedGeocentricTransform.createGeodeticTransformation(
factory, source, withHeights, target, withHeights, grid);
}
/**
* Returns the grid of the given name. This method returns the cached instance if it still exists,
* or load the grid otherwise.
*
* @param file name of the datum shift grid file to load.
* @param averages an "average" value for the offset in each dimension, or {@code null} if unknown.
* @param scale the factor by which to multiply each compressed value before to add to the average value.
*/
@SuppressWarnings("null")
static DatumShiftGridFile<Angle,Length> getOrLoad(final Path file, final double[] averages, final double scale)
throws FactoryException
{
final Path resolved = DataDirectory.DATUM_CHANGES.resolve(file).toAbsolutePath();
DatumShiftGridFile<?,?> grid = DatumShiftGridFile.CACHE.peek(resolved);
if (grid == null) {
final Cache.Handler<DatumShiftGridFile<?,?>> handler = DatumShiftGridFile.CACHE.lock(resolved);
try {
grid = handler.peek();
if (grid == null) {
try (BufferedReader in = Files.newBufferedReader(resolved)) {
DatumShiftGridLoader.log(FranceGeocentricInterpolation.class, file);
final DatumShiftGridFile.Float<Angle,Length> g = load(in, file);
grid = DatumShiftGridCompressed.compress(g, averages, scale);
} catch (IOException | NoninvertibleTransformException | RuntimeException e) {
// NumberFormatException, ArithmeticException, NoSuchElementException, possibly other.
throw DatumShiftGridLoader.canNotLoad(HEADER, file, e);
}
grid = grid.useSharedData();
}
} finally {
handler.putAndUnlock(grid);
}
}
return grid.castTo(Angle.class, Length.class);
}
/**
* Unconditionally loads the grid for the given file without in-memory compression.
*
* @param in reader of the RGF93 datum shift file.
* @param file path to the file being read, used for parameter declaration and error reporting.
* @throws IOException if an I/O error occurred.
* @throws NumberFormatException if a number can not be parsed.
* @throws NoSuchElementException if a data line is missing a value.
* @throws FactoryException if an problem is found with the file content.
* @throws ArithmeticException if the width or the height exceed the integer capacity.
*/
static DatumShiftGridFile.Float<Angle,Length> load(final BufferedReader in, final Path file)
throws IOException, FactoryException, NoninvertibleTransformException
{
DatumShiftGridFile.Float<Angle,Length> grid = null;
double x0 = 0;
double xf = 0;
double y0 = 0;
double yf = 0;
double Δx = 0;
double Δy = 0;
int nx = 0;
int ny = 0;
/*
* The header should be like below, but the only essential line for this class is the one
* starting with "GR3D1". We also check that "GR3D2" declares the expected interpolation.
*
* GR3D 002024 024 20370201
* GR3D1 -5.5000 10.0000 41.0000 52.0000 .1000 .1000
* GR3D2 INTERPOLATION BILINEAIRE
* GR3D3 PREC CM 01:5 02:10 03:20 04:50 99>100
*/
String line;
while (true) {
line = in.readLine();
if (line == null) {
throw new EOFException(Errors.format(Errors.Keys.UnexpectedEndOfFile_1, file));
}
final int length = CharSequences.skipTrailingWhitespaces(line, 0, line.length());
if (length <= 0) {
continue; // Skip empty lines.
}
int p = CharSequences.skipLeadingWhitespaces(line, 0, length);
if (line.charAt(p) == '#') {
continue; // Skip comment lines (not officially part of the format).
}
if (!line.regionMatches(true, p, HEADER, 0, HEADER.length())) {
break; // End of header.
}
if ((p += HEADER.length()) < length) {
final char c = line.charAt(p);
p = CharSequences.skipLeadingWhitespaces(line, p+1, length);
switch (c) {
case '1': {
if (grid != null) {
throw new FactoryException(Errors.format(Errors.Keys.DuplicatedElement_1, HEADER));
}
final double[] gridGeometry = CharSequences.parseDoubles(line.substring(p, length), ' ');
if (gridGeometry.length == 6) {
x0 = gridGeometry[0];
xf = gridGeometry[1];
y0 = gridGeometry[2];
yf = gridGeometry[3];
Δx = gridGeometry[4];
Δy = gridGeometry[5];
nx = Math.toIntExact(Math.round((xf - x0) / Δx + 1));
ny = Math.toIntExact(Math.round((yf - y0) / Δy + 1));
grid = new DatumShiftGridFile.Float<>(3,
Units.DEGREE, Units.METRE, false,
x0, y0, Δx, Δy, nx, ny, PARAMETERS, file);
}
break;
}
case '2': {
final String interp = line.substring(p, length);
if (!interp.matches("(?i)INTERPOLATION[^A-Z]+BILINEAIRE")) {
final LogRecord record = Errors.getResources((Locale) null).getLogRecord(
Level.WARNING, Errors.Keys.UnsupportedInterpolation_1, interp);
record.setLoggerName(Loggers.COORDINATE_OPERATION);
Logging.log(FranceGeocentricInterpolation.class, "createMathTransform", record);
// We declare 'createMathTransform' method because it is closer to public API.
}
break;
}
}
}
}
if (grid == null) {
throw new FactoryException(Errors.format(Errors.Keys.CanNotParseFile_2, HEADER, file));
}
/*
* Loads the data with the sign of all offsets reversed. Data columns are
*
* (unknown), longitude, latitude, tX, tY, tZ, accuracy code, data sheet (ignored)
*
* where the longitude and latitude values are in RGF93 system.
* Example:
*
* 00002 -5.500000000 41.000000000 -165.027 -67.100 315.813 99 -0158
* 00002 -5.500000000 41.100000000 -165.169 -66.948 316.007 99 -0157
* 00002 -5.500000000 41.200000000 -165.312 -66.796 316.200 99 -0157
*
* Translation values in the IGN file are from NTF to RGF93, but Apache SIS implementation needs
* the opposite direction (from RGF93 to NTF). The reason is that SIS expect the source datum to
* be the datum in which longitude and latitude values are expressed.
*/
final float[] tX = grid.offsets[0];
final float[] tY = grid.offsets[1];
final float[] tZ = grid.offsets[2];
do {
final StringTokenizer t = new StringTokenizer(line.trim());
t.nextToken(); // Ignored
final double x = Double.parseDouble(t.nextToken()); // Longitude in degrees
final double y = Double.parseDouble(t.nextToken()); // Latitude in degrees
final int i = Math.toIntExact(Math.round((x - x0) / Δx)); // Column index
final int j = Math.toIntExact(Math.round((y - y0) / Δy)); // Row index
if (i < 0 || i >= nx) {
throw new FactoryException(Errors.format(Errors.Keys.ValueOutOfRange_4, "x", x, x0, xf));
}
if (j < 0 || j >= ny) {
throw new FactoryException(Errors.format(Errors.Keys.ValueOutOfRange_4, "y", y, y0, yf));
}
final int p = j*nx + i;
if (!Double.isNaN(tX[p]) || !Double.isNaN(tY[p]) || !Double.isNaN(tZ[p])) {
throw new FactoryException(Errors.format(Errors.Keys.ValueAlreadyDefined_1, x + ", " + y));
}
tX[p] = -parseFloat(t.nextToken()); // See javadoc for the reason why we reverse the sign.
tY[p] = -parseFloat(t.nextToken());
tZ[p] = -parseFloat(t.nextToken());
final double accuracy = ACCURACY[Math.min(ACCURACY.length - 1,
Math.max(0, Integer.parseInt(t.nextToken()) - 1))];
if (!(accuracy >= grid.accuracy)) { // Use '!' for replacing the initial NaN.
grid.accuracy = accuracy;
}
} while ((line = in.readLine()) != null);
return grid;
}
}