001/*
002 *    GeoAPI - Java interfaces for OGC/ISO standards
003 *    Copyright © 2008-2024 Open Geospatial Consortium, Inc.
004 *    http://www.geoapi.org
005 *
006 *    Licensed under the Apache License, Version 2.0 (the "License");
007 *    you may not use this file except in compliance with the License.
008 *    You may obtain a copy of the License at
009 *
010 *        http://www.apache.org/licenses/LICENSE-2.0
011 *
012 *    Unless required by applicable law or agreed to in writing, software
013 *    distributed under the License is distributed on an "AS IS" BASIS,
014 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 *    See the License for the specific language governing permissions and
016 *    limitations under the License.
017 */
018package org.opengis.test.referencing;
019
020import java.util.Set;
021import java.util.List;
022import java.util.ArrayList;
023import java.util.LinkedHashSet;
024import java.util.function.Supplier;
025
026import org.opengis.referencing.cs.*;
027import org.opengis.referencing.crs.*;
028import org.opengis.referencing.datum.*;
029import org.opengis.referencing.operation.Conversion;
030import org.opengis.test.ValidatorContainer;
031
032import static org.junit.jupiter.api.Assertions.*;
033import static org.opengis.test.referencing.Utilities.getName;
034import static org.opengis.test.referencing.Utilities.getAxisDirections;
035
036
037/**
038 * Validates {@link CoordinateReferenceSystem} and related objects from the
039 * {@code org.opengis.referencing.crs} package.
040 *
041 * <p>This class is provided for users wanting to override the validation methods. When the default
042 * behavior is sufficient, the {@link org.opengis.test.Validators} static methods provide a more
043 * convenient way to validate various kinds of objects.</p>
044 *
045 * @author  Martin Desruisseaux (Geomatys)
046 * @version 3.1
047 * @since   2.2
048 */
049public class CRSValidator extends ReferencingValidator {
050    /**
051     * The axis names mandated by ISO 19111 for some particular kind of CRS.
052     * See ISO 19111:2019 table 16 at page 27.
053     */
054    private static final String[]
055            GEOCENTRIC_AXIS_NAME = {"geocentric X", "geocentric Y", "geocentric Z"},
056            GEOGRAPHIC_AXIS_NAME = {"geodetic latitude", "geodetic longitude", "ellipsoidal height"},
057            PROJECTED_AXIS_NAME  = {"northing", "southing", "easting", "westing", "ellipsoidal height"},
058            SPHERICAL_AXIS_NAME  = {"spherical latitude", "geocentric latitude", "geocentric co-latitude",
059                                    "spherical longitude", "geodetic longitude", "geocentric radius"},
060            VERTICAL_AXIS_NAME   = {"depth", "gravity-related height", "gravity-related depth"};
061    /*
062     * Note: the ISO table does not mention "gravity-related depth" as a standard name.
063     * However, this name is used in the EPSG database and seems a natural complement
064     * to the "gravity-related height" standard name.
065     */
066
067    /**
068     * {@code true} if validation of the conversion by {@link #validateGeneralDerivedCRS} is under way.
069     * Used in order to avoid never-ending recursivity.
070     *
071     * @todo Replace by a more general mechanism in {@link ValidatorContainer}.
072     */
073    private final ThreadLocal<Boolean> VALIDATING = new ThreadLocal<>();
074
075    /**
076     * {@code true} if standard names shall be enforced when such names are defined by an OGC/ISO
077     * standard. For example, the ISO 19111 standard constraints the {@link GeographicCRS} axis names
078     * to <q>geodetic latitude</q>, <q>geodetic longitude</q> and <q>ellipsoidal height</q> (if 3D) names.
079     * Those axis names will be verified by this validator, unless this fields is explicitly set to {@code false}.
080     *
081     * @see #validate(GeodeticCRS)
082     * @see #validate(ProjectedCRS)
083     * @see #validate(VerticalCRS)
084     *
085     * @since 3.1
086     */
087    public boolean enforceStandardNames = true;
088
089    /**
090     * Creates a new validator instance.
091     *
092     * @param container  the set of validators to use for validating other kinds of objects
093     *                   (see {@linkplain #container field javadoc}).
094     */
095    public CRSValidator(final ValidatorContainer container) {
096        super(container, "org.opengis.referencing.crs");
097    }
098
099    /**
100     * For each interface implemented by the given object, invokes the corresponding
101     * {@code validate(…)} method defined in this class (if any).
102     *
103     * @param  object  the object to dispatch to {@code validate(…)} methods, or {@code null}.
104     * @return number of {@code validate(…)} methods invoked in this class for the given object.
105     */
106    @SuppressWarnings("SuspiciousIndentAfterControlStatement")
107    public int dispatch(final CoordinateReferenceSystem object) {
108        int n = 0;
109        if (object != null) {
110            if (object instanceof GeodeticCRS)    {validate((GeodeticCRS)    object); n++;}
111            if (object instanceof ProjectedCRS)   {validate((ProjectedCRS)   object); n++;} else
112            if (object instanceof DerivedCRS)     {validate((DerivedCRS)     object); n++;} // Implied by above case.
113            if (object instanceof EngineeringCRS) {validate((EngineeringCRS) object); n++;}
114            if (object instanceof VerticalCRS)    {validate((VerticalCRS)    object); n++;}
115            if (object instanceof TemporalCRS)    {validate((TemporalCRS)    object); n++;}
116            if (object instanceof CompoundCRS)    {validate((CompoundCRS)    object); n++;}
117            if (n == 0) {
118                validateIdentifiedObject(object);
119                container.validate(object.getCoordinateSystem());
120            }
121        }
122        return n;
123    }
124
125    /**
126     * Validates the given coordinate reference system. If the {@link #enforceStandardNames}
127     * field is set to {@code true} (which is the default), then this method expects the axes
128     * to have the following names:
129     *
130     * <ul>
131     *   <li>For ellipsoidal coordinate system, <q>geodetic latitude</q>,
132     *       <q>geodetic longitude</q> and <q>ellipsoidal height</q> (if 3D).</li>
133     *   <li>For Cartesian coordinate system, <q>geocentric X</q>,
134     *       <q>geocentric Y</q> and <q>geocentric Z</q>.</li>
135     *   <li>For spherical coordinate system, <q>spherical latitude</q>,
136     *       <q>spherical longitude</q> and <q>geocentric radius</q>.</li>
137     * </ul>
138     *
139     * @param  object  the object to validate, or {@code null}.
140     *
141     * @since 3.1
142     */
143    public void validate(final GeodeticCRS object) {
144        if (object == null) {
145            return;
146        }
147        validateIdentifiedObject(object);
148        final CoordinateSystem cs = object.getCoordinateSystem();
149        mandatory(cs, "GeodeticCRS: shall have a CoordinateSystem.");
150        if (cs instanceof EllipsoidalCS) {
151            container.validate((EllipsoidalCS) cs);
152            if (enforceStandardNames) {
153                assertStandardNames("GeographicCRS", cs, GEOGRAPHIC_AXIS_NAME);
154            }
155        } else if (cs instanceof CartesianCS) {
156            container.validate((CartesianCS) cs);
157            final Set<AxisDirection> axes = getAxisDirections(cs);
158            validate(axes);
159            assertTrue(axes.remove(AxisDirection.GEOCENTRIC_X), "GeocentricCRS: expected Geocentric X axis direction.");
160            assertTrue(axes.remove(AxisDirection.GEOCENTRIC_Y), "GeocentricCRS: expected Geocentric Y axis direction.");
161            assertTrue(axes.remove(AxisDirection.GEOCENTRIC_Z), "GeocentricCRS: expected Geocentric Z axis direction.");
162            assertTrue(axes.isEmpty(), "GeocentricCRS: unknown axis direction.");
163            if (enforceStandardNames) {
164                assertStandardNames("GeocentricCRS", cs, GEOCENTRIC_AXIS_NAME);
165            }
166        } else if (cs instanceof SphericalCS) {
167            container.validate((SphericalCS) cs);
168            if (enforceStandardNames) {
169                assertStandardNames("GeocentricCRS", cs, SPHERICAL_AXIS_NAME);
170            }
171        } else if (cs != null) {
172            fail("GeodeticCRS: unknown CoordinateSystem of type " + cs.getClass().getCanonicalName() + '.');
173        }
174        final GeodeticDatum datum = object.getDatum();
175        mandatory(datum, "GeodeticCRS: shall have a Datum.");
176        container.validate(datum);
177    }
178
179    /**
180     * Validates the given coordinate reference system. If the {@link #enforceStandardNames}
181     * field is set to {@code true} (which is the default), then this method expects the axes
182     * to have the following names:
183     *
184     * <ul>
185     *   <li><q>northing</q> or <q>southing</q>, <q>easting</q> or <q>westing</q>.</li>
186     * </ul>
187     *
188     * @param  object  the object to validate, or {@code null}.
189     */
190    public void validate(final ProjectedCRS object) {
191        if (object == null) {
192            return;
193        }
194        validateIdentifiedObject(object);
195
196        final GeodeticCRS baseCRS = object.getBaseCRS();
197        mandatory(baseCRS, "ProjectedCRS: shall have a base CRS.");
198        validate(baseCRS);
199
200        final CartesianCS cs = object.getCoordinateSystem();
201        mandatory(cs, "ProjectedCRS: shall have a CoordinateSystem.");
202        container.validate(cs);
203        if (enforceStandardNames) {
204            assertStandardNames("ProjectedCRS", cs, PROJECTED_AXIS_NAME);
205        }
206        final GeodeticDatum datum = object.getDatum();
207        mandatory(datum, "ProjectedCRS: shall have a Datum.");
208        container.validate(datum);
209
210        validateGeneralDerivedCRS(object);
211    }
212
213    /**
214     * Validates the given coordinate reference system.
215     *
216     * @param  object  the object to validate, or {@code null}.
217     */
218    public void validate(final DerivedCRS object) {
219        if (object == null) {
220            return;
221        }
222        validateIdentifiedObject(object);
223
224        final CoordinateReferenceSystem baseCRS = object.getBaseCRS();
225        mandatory(baseCRS, "DerivedCRS: shall have a base CRS.");
226        dispatch(baseCRS);
227
228        final CoordinateSystem cs = object.getCoordinateSystem();
229        mandatory(cs, "DerivedCRS: shall have a CoordinateSystem.");
230        container.validate(cs);
231
232        final Datum datum = object.getDatum();
233        mandatory(datum, "DerivedCRS: shall have a Datum.");
234        container.validate(datum);
235
236        validateGeneralDerivedCRS(object);
237    }
238
239    /**
240     * Validates the conversion in the given derived CRS. This method is private because
241     * it doesn't perform a full validation; only the one not already done by the public
242     * {@link #validate(ProjectedCRS)} and {@link #validate(DerivedCRS)} methods.
243     *
244     * @param  object  the object to validate, or {@code null}.
245     */
246    private void validateGeneralDerivedCRS(final DerivedCRS object) {
247        if (!Boolean.TRUE.equals(VALIDATING.get())) try {
248            VALIDATING.set(Boolean.TRUE);
249            final Conversion conversion = object.getConversionFromBase();
250            if (conversion != null) {
251                container.validate(conversion);
252                final CoordinateReferenceSystem   baseCRS = object.getBaseCRS();
253                final CoordinateReferenceSystem sourceCRS = conversion.getSourceCRS();
254                final CoordinateReferenceSystem targetCRS = conversion.getTargetCRS();
255                if (baseCRS != null && sourceCRS != null) {
256                    assertSame(baseCRS, sourceCRS,
257                            "DerivedCRS: The base CRS should be the source CRS of the conversion.");
258                }
259                if (targetCRS != null) {
260                    assertSame(object, targetCRS,
261                            "DerivedCRS: The derived CRS should be the target CRS of the conversion.");
262                }
263            }
264        } finally {
265            VALIDATING.set(Boolean.FALSE);
266        }
267    }
268
269    /**
270     * Validates the given coordinate reference system.
271     *
272     * @param  object  the object to validate, or {@code null}.
273     *
274     * @deprecated {@code ImageCRS} is replaced by {@link EngineeringCRS} as of ISO 19111:2019.
275     */
276    @Deprecated(since="3.1")
277    public void validate(final ImageCRS object) {
278        if (object == null) {
279            return;
280        }
281        validateIdentifiedObject(object);
282        final AffineCS cs = object.getCoordinateSystem();
283        mandatory(cs, "ImageCRS: shall have a CoordinateSystem.");
284        container.validate(cs);
285
286        final ImageDatum datum = object.getDatum();
287        mandatory(datum, "ImageCRS: shall have a Datum.");
288        container.validate(datum);
289    }
290
291    /**
292     * Validates the given coordinate reference system.
293     *
294     * @param  object  the object to validate, or {@code null}.
295     */
296    @SuppressWarnings("deprecation")
297    public void validate(final EngineeringCRS object) {
298        if (object == null) {
299            return;
300        }
301        validateIdentifiedObject(object);
302        final CoordinateSystem cs = object.getCoordinateSystem();
303        mandatory(cs, "EngineeringCRS: shall have a CoordinateSystem.");
304        container.validate(cs);
305        String message = "EngineeringCRS: illegal coordinate system type. Shall be one of"
306                       + " affine, Cartesian, cylindrical, linear, polar, or spherical.";
307        assertTrue(cs instanceof AffineCS      ||      // Include the CartesianCS case.
308                   cs instanceof CylindricalCS ||
309                   cs instanceof LinearCS      ||
310                   cs instanceof PolarCS       ||
311                   cs instanceof SphericalCS   ||
312                   cs instanceof UserDefinedCS,
313                message);
314
315        assertFalse(cs instanceof EllipsoidalCS ||
316                    cs instanceof VerticalCS    ||
317                    cs instanceof ParametricCS  ||
318                    cs instanceof TimeCS,
319                message);
320
321        final Datum datum = object.getDatum();
322        mandatory(datum, "EngineeringCRS: shall have a Datum.");
323        container.validate(datum);
324    }
325
326    /**
327     * Validates the given coordinate reference system. If the {@link #enforceStandardNames}
328     * field is set to {@code true} (which is the default), then this method expects the axes
329     * to have the following names:
330     *
331     * <ul>
332     *   <li><q>depth</q> or <q>gravity-related height</q>.</li>
333     * </ul>
334     *
335     * @param  object  the object to validate, or {@code null}.
336     */
337    public void validate(final VerticalCRS object) {
338        if (object == null) {
339            return;
340        }
341        validateIdentifiedObject(object);
342        final VerticalCS cs = object.getCoordinateSystem();
343        mandatory(cs, "VerticalCRS: shall have a CoordinateSystem.");
344        container.validate(cs);
345        if (enforceStandardNames) {
346            assertStandardNames("VerticalCRS", cs, VERTICAL_AXIS_NAME);
347        }
348        final VerticalDatum datum = object.getDatum();
349        mandatory(datum, "VerticalCRS: shall have a Datum.");
350        container.validate(datum);
351    }
352
353    /**
354     * Validates the given coordinate reference system.
355     *
356     * @param  object  the object to validate, or {@code null}.
357     */
358    public void validate(final TemporalCRS object) {
359        if (object == null) {
360            return;
361        }
362        validateIdentifiedObject(object);
363        final TimeCS cs = object.getCoordinateSystem();
364        mandatory(cs, "TemporalCRS: shall have a CoordinateSystem.");
365        container.validate(cs);
366
367        final TemporalDatum datum = object.getDatum();
368        mandatory(datum, "TemporalCRS: shall have a Datum.");
369        container.validate(datum);
370    }
371
372    /**
373     * Validates the given coordinate reference system.
374     * This method will validate every individual components in the given compound CRS.
375     *
376     * @param  object  the object to validate, or {@code null}.
377     *
378     * @since 3.1
379     */
380    public void validate(final CompoundCRS object) {
381        if (object == null) {
382            return;
383        }
384        validateIdentifiedObject(object);
385        final CoordinateSystem cs = object.getCoordinateSystem();
386        mandatory(cs, "CompoundCRS: shall have a CoordinateSystem.");
387        container.validate(cs);
388        AxisDirection[] directions = null;
389        if (cs != null) {
390            directions = new AxisDirection[cs.getDimension()];
391            for (int i=0; i<directions.length; i++) {
392                CoordinateSystemAxis axis = cs.getAxis(i);
393                if (axis != null) {
394                    directions[i] = axis.getDirection();
395                }
396            }
397        }
398        /*
399         * Verify the components, potentially with nested compound CRS.
400         */
401        final List<CoordinateReferenceSystem> components = object.getComponents();
402        mandatory(components, "CompoundCRS: shall have components.");
403        if (components != null) {
404            int dimension = 0;
405            // If the above 'mandatory(…)' call accepted an empty list, we accept it too.
406            assertNotEquals(1, components.size(), "CompoundCRS: shall have at least 2 components.");
407            for (final CoordinateReferenceSystem component : components) {
408                dispatch(component);
409                dimension = compareAxes(component.getCoordinateSystem(), directions, dimension);
410            }
411            if (directions != null) {
412                assertEquals(directions.length, dimension, "CompoundCRS: unexpected sum of the dimension of components.");
413            }
414        }
415        /*
416         * Verify the components again, but without nested compound CRS.
417         */
418        final List<SingleCRS> singles = object.getSingleComponents();
419        mandatory(singles, "CompoundCRS: shall have components.");
420        if (singles != null) {
421            int dimension = 0;
422            assertNotEquals(1, singles.size(), "CompoundCRS: shall have at least 2 components.");
423            for (final SingleCRS component : singles) {
424                dispatch(component);
425                dimension = compareAxes(component.getCoordinateSystem(), directions, dimension);
426            }
427            if (directions != null) {
428                assertEquals(directions.length, dimension, "CompoundCRS: unexpected sum of the dimension of components.");
429            }
430        }
431    }
432
433    /**
434     * Checks if the axis directions of the given coordinate system are the expected one.
435     *
436     * @param  cs          the coordinate system to validate, or {@code null} if none.
437     * @param  directions  the expected directions, or {@code null} if unknown.
438     * @param  index       index of the first element in the {@code directions} array.
439     * @return index after the last element in the {@code directions} array.
440     */
441    private static int compareAxes(final CoordinateSystem cs, final AxisDirection[] directions, int index) {
442        if (directions != null) {
443            assertNotNull("CompoundCRS: missing coordinate system for component.");
444            final int dimension = cs.getDimension();
445            assertTrue(index + dimension <= directions.length, "CompoundCRS: components have too many dimensions.");
446            for (int i=0; i<dimension; i++) {
447                final AxisDirection expected = directions[index++];
448                if (expected != null) {
449                    final int d = i;        // Because lambda functions require final values.
450                    final Supplier<String> message = () -> "CompoundCRS: inconsistent axis at dimension " + d + ".";
451                    final CoordinateSystemAxis axis = cs.getAxis(d);
452                    assertNotNull(axis, message);
453                    assertEquals(expected, axis.getDirection(), message);
454                }
455            }
456        }
457        return index;
458    }
459
460    /**
461     * Verifies that the given coordinate system uses the given standard axis names.
462     *
463     * @param type           type of coordinate system to report in error messages.
464     * @param cs             the coordinate system to validate.
465     * @param standardNames  the expected standard axis names.
466     */
467    private static void assertStandardNames(final String type, final CoordinateSystem cs, final String[] standardNames) {
468        final int dimension = cs.getDimension();
469        final Set<String> names = new LinkedHashSet<>(dimension * 4/3 + 1);
470        for (int i=0; i<dimension; i++) {
471            final String name = getName(cs.getAxis(i));
472            if (name != null && !names.add(toLowerCase(name.trim()))) {
473                fail(type + ": duplicated axis name: " + name);
474            }
475        }
476        final List<String> notFound = new ArrayList<>(names.size());
477        for (final String name : standardNames) {
478            if (!names.remove(name)) {
479                notFound.add(name);
480            }
481        }
482        if (!names.isEmpty()) {
483            fail(type + ": Non-standard axis names: " + names + ". Expected some of " + notFound + '.');
484        }
485    }
486
487    /**
488     * Returns the given string in lower cases, except for the last letter if it is single.
489     * The intent is to leave the trailing X, Y or Z case unchanged in "geocentric X",
490     * "geocentric Y" and "geocentric Z" axis names.
491     *
492     * @param  name  the name in mixed case.
493     * @return the given string in lower case, except last letter if single.
494     */
495    static String toLowerCase(final String name) {
496        int s = name.length();
497        if (s >= 3) {
498            s -= Character.charCount(name.codePointBefore(s));
499            final int c = name.codePointBefore(s);
500            if (Character.isSpaceChar(c)) {
501                s -= Character.charCount(c);
502                return name.substring(0, s).toLowerCase().concat(name.substring(s));
503            }
504        }
505        return name.toLowerCase();
506    }
507}