| /* |
| * 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.Map; |
| import java.util.HashMap; |
| import java.util.LinkedHashMap; |
| import java.util.Arrays; |
| import java.util.Locale; |
| import java.util.logging.Level; |
| import java.util.logging.LogRecord; |
| import java.io.IOException; |
| import java.nio.ByteOrder; |
| import java.nio.ByteBuffer; |
| import java.nio.file.Files; |
| import java.nio.file.Path; |
| import java.nio.channels.ReadableByteChannel; |
| import java.nio.charset.StandardCharsets; |
| import javax.xml.bind.annotation.XmlTransient; |
| import javax.measure.Unit; |
| import javax.measure.quantity.Angle; |
| import org.opengis.util.FactoryException; |
| import org.opengis.parameter.ParameterValueGroup; |
| import org.opengis.parameter.ParameterDescriptor; |
| import org.opengis.parameter.ParameterDescriptorGroup; |
| import org.opengis.parameter.ParameterNotFoundException; |
| import org.opengis.referencing.operation.MathTransform; |
| import org.opengis.referencing.operation.MathTransformFactory; |
| import org.opengis.referencing.operation.Transformation; |
| import org.opengis.referencing.operation.NoninvertibleTransformException; |
| import org.apache.sis.referencing.operation.transform.InterpolatedTransform; |
| import org.apache.sis.internal.system.Loggers; |
| import org.apache.sis.internal.system.DataDirectory; |
| import org.apache.sis.internal.referencing.Formulas; |
| import org.apache.sis.parameter.ParameterBuilder; |
| import org.apache.sis.parameter.Parameters; |
| import org.apache.sis.util.collection.Cache; |
| import org.apache.sis.util.logging.Logging; |
| import org.apache.sis.util.resources.Errors; |
| import org.apache.sis.util.resources.Messages; |
| import org.apache.sis.measure.Units; |
| |
| |
| /** |
| * The provider for <cite>"National Transformation version 2"</cite> (EPSG:9615). |
| * This transform requires data that are not bundled by default with Apache SIS. |
| * |
| * @author Simon Reynard (Geomatys) |
| * @author Martin Desruisseaux (Geomatys) |
| * @version 1.0 |
| * @since 0.7 |
| * @module |
| */ |
| @XmlTransient |
| public final class NTv2 extends AbstractProvider { |
| /** |
| * Serial number for inter-operability with different versions. |
| */ |
| private static final long serialVersionUID = -4027618007780159180L; |
| |
| /** |
| * The operation parameter descriptor for the <cite>"Latitude and longitude difference file"</cite> parameter value. |
| * The file extension is typically {@code ".gsb"}. There is no default value. |
| * |
| * <!-- Generated by ParameterNameTableGenerator --> |
| * <table class="sis"> |
| * <caption>Parameter names</caption> |
| * <tr><td> EPSG: </td><td> Latitude and longitude difference file </td></tr> |
| * </table> |
| * <b>Notes:</b> |
| * <ul> |
| * <li>No default value</li> |
| * </ul> |
| */ |
| private static final ParameterDescriptor<Path> FILE; |
| |
| /** |
| * The group of all parameters expected by this coordinate operation. |
| */ |
| public static final ParameterDescriptorGroup PARAMETERS; |
| static { |
| final ParameterBuilder builder = builder(); |
| FILE = builder |
| .addIdentifier("8656") |
| .addName("Latitude and longitude difference file") |
| .create(Path.class, null); |
| PARAMETERS = builder |
| .addIdentifier("9615") |
| .addName("NTv2") |
| .createGroup(FILE); |
| } |
| |
| /** |
| * Creates a new provider. |
| */ |
| public NTv2() { |
| super(2, 2, PARAMETERS); |
| } |
| |
| /** |
| * Returns the base interface of the {@code CoordinateOperation} instances that use this method. |
| * |
| * @return fixed to {@link Transformation}. |
| */ |
| @Override |
| public Class<Transformation> getOperationType() { |
| return Transformation.class; |
| } |
| |
| /** |
| * Creates a transform from the specified group of parameter values. |
| * |
| * @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 |
| { |
| final Parameters pg = Parameters.castOrWrap(values); |
| return InterpolatedTransform.createGeodeticTransformation(factory, getOrLoad(pg.getMandatoryValue(FILE))); |
| } |
| |
| /** |
| * 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. |
| */ |
| @SuppressWarnings("null") |
| static DatumShiftGridFile<Angle,Angle> getOrLoad(final Path file) 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 (ReadableByteChannel in = Files.newByteChannel(resolved)) { |
| DatumShiftGridLoader.log(NTv2.class, file); |
| final Loader loader = new Loader(in, file); |
| grid = loader.readGrid(); |
| loader.reportWarnings(); |
| } catch (IOException | NoninvertibleTransformException | RuntimeException e) { |
| throw DatumShiftGridLoader.canNotLoad("NTv2", file, e); |
| } |
| grid = grid.useSharedData(); |
| } |
| } finally { |
| handler.putAndUnlock(grid); |
| } |
| } |
| return grid.castTo(Angle.class, Angle.class); |
| } |
| |
| |
| |
| |
| /** |
| * Loaders of NTv2 data. Instances of this class exist only at loading time. |
| * |
| * @author Simon Reynard (Geomatys) |
| * @author Martin Desruisseaux (Geomatys) |
| * @version 1.0 |
| * @since 0.7 |
| * @module |
| */ |
| private static final class Loader extends DatumShiftGridLoader { |
| /** |
| * Size of a record. This value applies to both the header records and the data records. |
| * In the case of header records, this is the size of the key plus the size of the value. |
| */ |
| private static final int RECORD_LENGTH = 16; |
| |
| /** |
| * Maximum number of characters of a key in a header record. |
| */ |
| private static final int KEY_LENGTH = 8; |
| |
| /** |
| * Type of data allowed in header records. |
| */ |
| private static final int STRING_TYPE = 0, INTEGER_TYPE = 1, DOUBLE_TYPE = 2; |
| |
| /** |
| * Some known keywords that may appear in NTv2 header records. |
| */ |
| private static final Map<String,Integer> TYPES; |
| static { |
| final Map<String,Integer> types = new HashMap<>(32); |
| final Integer string = STRING_TYPE; // Autoboxing |
| final Integer integer = INTEGER_TYPE; |
| final Integer real = DOUBLE_TYPE; |
| types.put("NUM_OREC", integer); // Number of records in the header - usually 11 |
| types.put("NUM_SREC", integer); // Number of records in the header of sub-grids - usually 11 |
| types.put("NUM_FILE", integer); // Number of sub-grids |
| types.put("GS_TYPE", string); // Units: "SECONDS", "MINUTES" or "DEGREES" |
| types.put("VERSION", string); // Grid version |
| types.put("SYSTEM_F", string); // Source CRS |
| types.put("SYSTEM_T", string); // Target CRS |
| types.put("MAJOR_F", real); // Semi-major axis of source ellipsoid (in metres) |
| types.put("MINOR_F", real); // Semi-minor axis of source ellipsoid (in metres) |
| types.put("MAJOR_T", real); // Semi-major axis of target ellipsoid (in metres) |
| types.put("MINOR_T", real); // Semi-minor axis of target ellipsoid (in metres) |
| types.put("SUB_NAME", string); // Sub-grid identifier |
| types.put("PARENT", string); // Parent grid |
| types.put("CREATED", string); // Creation time |
| types.put("UPDATED", string); // Update time |
| types.put("S_LAT", real); // Southmost φ value |
| types.put("N_LAT", real); // Northmost φ value |
| types.put("E_LONG", real); // Eastmost λ value - west is positive, east is negative |
| types.put("W_LONG", real); // Westmost λ value - west is positive, east is negative |
| types.put("LAT_INC", real); // Increment on φ axis |
| types.put("LONG_INC", real); // Increment on λ axis - positive toward west |
| types.put("GS_COUNT", integer); // Number of sub-grid records following |
| TYPES = types; |
| } |
| |
| /** |
| * The header content. Keys are strings like {@code "VERSION"}, {@code "SYSTEM_F"}, |
| * <var>etc.</var>. Values are {@link String}, {@link Integer} or {@link Double}. |
| * If some keys are unrecognized, they will be put in this map with the {@code null} value |
| * and the {@link #hasUnrecognized} field will be set to {@code true}. |
| */ |
| private final Map<String,Object> header; |
| |
| /** |
| * {@code true} if the {@code header} map contains at least one key associated to a null value. |
| */ |
| private boolean hasUnrecognized; |
| |
| /** |
| * Number of grids remaining in the file. This value is set in the constructor, |
| * then decremented at every call to {@link #readGrid()}. |
| */ |
| private int remainingGrids; |
| |
| /** |
| * Creates a new reader for the given channel. |
| * This constructor parses the header immediately, but does not read any grid. |
| * |
| * @param channel where to read data from. |
| * @param file path to the longitude and latitude difference file. Used for parameter declaration and error reporting. |
| * @throws FactoryException if a data record can not be parsed. |
| */ |
| Loader(final ReadableByteChannel channel, final Path file) throws IOException, FactoryException { |
| super(channel, ByteBuffer.allocate(4096), file); |
| this.header = new LinkedHashMap<>(); |
| ensureBufferContains(RECORD_LENGTH); |
| if (isLittleEndian(buffer.getInt(KEY_LENGTH))) { |
| buffer.order(ByteOrder.LITTLE_ENDIAN); |
| } |
| /* |
| * Read the overview header. It is normally made of the first 11 records documented in TYPES map: |
| * NUM_OREC, NUM_SREC, NUM_FILE, GS_TYPE, VERSION, SYSTEM_F, SYSTEM_T, MAJOR_F, MINOR_F, MAJOR_T, |
| * MINOR_T. |
| */ |
| readHeader(11, "NUM_OREC"); |
| remainingGrids = (Integer) get("NUM_FILE"); |
| if (remainingGrids < 1) { |
| throw new FactoryException(Errors.format(Errors.Keys.UnexpectedValueInElement_2, "NUM_FILE", remainingGrids)); |
| } |
| } |
| |
| /** |
| * Reads the next grid, starting at the current position. A NTv2 file can have many grids. |
| * This can be used for grids having different resolutions depending on the geographic area. |
| * The first grid can cover a large area with a coarse resolution, and next grids cover smaller |
| * areas overlapping the first grid but with finer resolution. |
| * |
| * Current SIS implementation does not yet handle the above-cited hierarchy of grids. |
| * For now we just take the first one. |
| * |
| * <p>NTv2 grids contain also information about shifts accuracy. This is not yet handled by SIS, |
| * except for determining an approximate grid cell resolution.</p> |
| */ |
| final DatumShiftGridFile<Angle,Angle> readGrid() throws IOException, FactoryException, NoninvertibleTransformException { |
| if (--remainingGrids < 0) { |
| throw new FactoryException(Errors.format(Errors.Keys.CanNotRead_1, file)); |
| } |
| final Object[] overviewKeys = header.keySet().toArray(); |
| readHeader((Integer) get("NUM_SREC"), "NUM_SREC"); |
| /* |
| * Extract the geographic bounding box and cell size. While different units are allowed, |
| * in practice we usually have seconds of angle. This units has the advantage of allowing |
| * all floating-point values to be integers. |
| * |
| * Note that the longitude values in NTv2 files are positive WEST. |
| */ |
| final Unit<Angle> unit; |
| final double precision; |
| final String name = (String) get("GS_TYPE"); |
| if (name.equalsIgnoreCase("SECONDS")) { // Most common value |
| unit = Units.ARC_SECOND; |
| precision = SECOND_PRECISION; // Used only as a hint; will not hurt if wrong. |
| } else if (name.equalsIgnoreCase("MINUTES")) { |
| unit = Units.ARC_MINUTE; |
| precision = SECOND_PRECISION / 60; // Used only as a hint; will not hurt if wrong. |
| } else if (name.equalsIgnoreCase("DEGREES")) { |
| unit = Units.DEGREE; |
| precision = SECOND_PRECISION / DEGREES_TO_SECONDS; // Used only as a hint; will not hurt if wrong. |
| } else { |
| throw new FactoryException(Errors.format(Errors.Keys.UnexpectedValueInElement_2, "GS_TYPE", name)); |
| } |
| final double ymin = (Double) get("S_LAT"); |
| final double ymax = (Double) get("N_LAT"); |
| final double xmin = (Double) get("E_LONG"); // Sign reversed compared to usual convention. |
| final double xmax = (Double) get("W_LONG"); // Idem. |
| final double dy = (Double) get("LAT_INC"); |
| final double dx = (Double) get("LONG_INC"); // Positive toward west. |
| final Integer declared = (Integer) header.get("GS_COUNT"); |
| final int width = Math.toIntExact(Math.round((xmax - xmin) / dx + 1)); |
| final int height = Math.toIntExact(Math.round((ymax - ymin) / dy + 1)); |
| final int count = Math.multiplyExact(width, height); |
| if (declared != null && count != declared) { |
| throw new FactoryException(Errors.format(Errors.Keys.UnexpectedValueInElement_2, "GS_COUNT", declared)); |
| } |
| /* |
| * Construct the grid. The sign of longitude translations will need to be reversed in order to have |
| * longitudes increasing toward East. We set isCellValueRatio = true (by the arguments given to the |
| * DatumShiftGridFile constructor) because this is required by InterpolatedTransform implementation. |
| * This setting implies that we divide translation values by dx or dy at reading time. Note that this |
| * free us from reversing the sign of longitude translations in the code below; instead, this reversal |
| * will be handled by grid.coordinateToGrid MathTransform and its inverse. |
| */ |
| final DatumShiftGridFile.Float<Angle,Angle> grid = new DatumShiftGridFile.Float<>(2, |
| unit, unit, true, -xmin, ymin, -dx, dy, width, height, PARAMETERS, file); |
| @SuppressWarnings("MismatchedReadAndWriteOfArray") final float[] tx = grid.offsets[0]; |
| @SuppressWarnings("MismatchedReadAndWriteOfArray") final float[] ty = grid.offsets[1]; |
| for (int i=0; i<count; i++) { |
| ensureBufferContains(4 * Float.BYTES); |
| ty[i] = (float) (buffer.getFloat() / dy); // Division by dx and dy because isCellValueRatio = true. |
| tx[i] = (float) (buffer.getFloat() / dx); |
| final double accuracy = Math.min(buffer.getFloat() / dy, buffer.getFloat() / dx); |
| if (accuracy > 0 && !(accuracy >= grid.accuracy)) { // Use '!' for replacing the initial NaN. |
| grid.accuracy = accuracy; // Smallest non-zero accuracy. |
| } |
| } |
| /* |
| * We need an estimation of translation accuracy, in order to decide when to stop iterations |
| * during inverse transformations. If we did not found that information in the file, compute |
| * an arbitrary default accuracy. |
| */ |
| final double size = Math.max(dx, dy); |
| if (Double.isNaN(grid.accuracy)) { |
| grid.accuracy = Units.DEGREE.getConverterTo(unit).convert(Formulas.ANGULAR_TOLERANCE) / size; |
| } |
| header.keySet().retainAll(Arrays.asList(overviewKeys)); // Keep only overview records. |
| return DatumShiftGridCompressed.compress(grid, null, precision / size); |
| } |
| |
| /** |
| * Returns {@code true} if the given value seems to be stored in little endian order. |
| */ |
| private static boolean isLittleEndian(final int n) { |
| return Integer.compareUnsigned(n, Integer.reverseBytes(n)) > 0; |
| } |
| |
| /** |
| * Reads a string at the given position in the buffer. |
| */ |
| private String readString(final int position, int length) { |
| final byte[] array = buffer.array(); |
| while (length > position && array[position + length - 1] <= ' ') length--; |
| return new String(array, position, length, StandardCharsets.US_ASCII).trim(); |
| } |
| |
| /** |
| * Reads all records found in the header, starting from the current buffer position. |
| * It may be the overview header (in which case we expect {@code NUM_OREC} records) |
| * or a sub-grid header (in which case we expect {@code NUM_SREC} records). |
| * |
| * @param numRecords default number of expected records (usually 11). |
| * @param numkey key of the record giving the number of records: {@code "NUM_OREC"} or {@code "NUM_SREC"}. |
| */ |
| private void readHeader(int numRecords, final String numkey) throws IOException, FactoryException { |
| int position = buffer.position(); |
| for (int i=0; i < numRecords; i++) { |
| ensureBufferContains(RECORD_LENGTH); |
| final String key = readString(position, KEY_LENGTH).toUpperCase(Locale.US); |
| position += KEY_LENGTH; |
| final Integer type = TYPES.get(key); |
| final Comparable<?> value; |
| if (type == null) { |
| value = null; |
| hasUnrecognized = true; |
| } else switch (type) { |
| case STRING_TYPE: { |
| value = readString(position, RECORD_LENGTH - KEY_LENGTH); |
| break; |
| } |
| case INTEGER_TYPE: { |
| final int n = buffer.getInt(position); |
| if (key.equals(numkey)) { |
| numRecords = n; |
| } |
| value = n; |
| break; |
| } |
| case DOUBLE_TYPE: { |
| value = buffer.getDouble(position); |
| break; |
| } |
| default: throw new AssertionError(type); |
| } |
| final Object old = header.put(key, value); |
| if (old != null && !old.equals(value)) { |
| throw new FactoryException(Errors.format(Errors.Keys.KeyCollision_1, key)); |
| } |
| buffer.position(position += RECORD_LENGTH - KEY_LENGTH); |
| } |
| } |
| |
| /** |
| * Returns the value for the given key, or thrown an exception if the value is not found. |
| */ |
| private Object get(final String key) throws FactoryException { |
| final Object value = header.get(key); |
| if (value != null) { |
| return value; |
| } |
| throw new FactoryException(Errors.format(Errors.Keys.PropertyNotFound_2, file, key)); |
| } |
| |
| /** |
| * If we had any warnings during the loading process, report them now. |
| */ |
| void reportWarnings() { |
| if (hasUnrecognized) { |
| final StringBuilder keywords = new StringBuilder(); |
| for (final Map.Entry<String,Object> entry : header.entrySet()) { |
| if (entry.getValue() == null) { |
| if (keywords.length() != 0) { |
| keywords.append(", "); |
| } |
| keywords.append(entry.getKey()); |
| } |
| } |
| final LogRecord record = Messages.getResources(null).getLogRecord(Level.WARNING, |
| Messages.Keys.UnknownKeywordInRecord_2, file, keywords.toString()); |
| record.setLoggerName(Loggers.COORDINATE_OPERATION); |
| Logging.log(NTv2.class, "createMathTransform", record); |
| } |
| } |
| } |
| } |