001/*
002 *    GeoAPI - Java interfaces for OGC/ISO standards
003 *    Copyright © 2015-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.time.Instant;
021import java.time.OffsetDateTime;
022import java.time.temporal.Temporal;
023import java.time.chrono.ChronoZonedDateTime;
024import javax.measure.Unit;
025import javax.measure.UnitConverter;
026import javax.measure.IncommensurableException;
027import javax.measure.quantity.Angle;
028import javax.measure.quantity.Length;
029import org.opengis.metadata.Identifier;
030import org.opengis.metadata.extent.Extent;
031import org.opengis.metadata.extent.GeographicBoundingBox;
032import org.opengis.metadata.extent.GeographicDescription;
033import org.opengis.metadata.extent.GeographicExtent;
034import org.opengis.metadata.extent.TemporalExtent;
035import org.opengis.metadata.extent.VerticalExtent;
036import org.opengis.parameter.ParameterValue;
037import org.opengis.parameter.ParameterValueGroup;
038import org.opengis.referencing.ObjectDomain;
039import org.opengis.referencing.IdentifiedObject;
040import org.opengis.referencing.datum.Ellipsoid;
041import org.opengis.referencing.datum.PrimeMeridian;
042import org.opengis.referencing.datum.GeodeticDatum;
043import org.opengis.referencing.cs.AxisDirection;
044import org.opengis.referencing.cs.CoordinateSystem;
045import org.opengis.referencing.cs.CoordinateSystemAxis;
046import org.opengis.referencing.crs.CoordinateReferenceSystem;
047import org.opengis.referencing.crs.VerticalCRS;
048import org.opengis.referencing.cs.VerticalCS;
049import org.opengis.temporal.Period;
050import org.opengis.temporal.TemporalPrimitive;
051import org.opengis.test.TestCase;
052import org.opentest4j.AssertionFailedError;
053
054import static java.lang.Double.isNaN;
055import static org.junit.jupiter.api.Assertions.*;
056import static org.opengis.test.Assertions.assertUnicodeIdentifierEquals;
057
058
059/**
060 * Base class of {@link CoordinateReferenceSystem} implementation tests.
061 * This base class provides {@code verify(…)} methods that subclasses can override if they need to alter
062 * the object verifications.
063 *
064 * @author  Martin Desruisseaux (Geomatys)
065 * @version 3.1
066 * @since   3.1
067 */
068@SuppressWarnings("strictfp")   // Because we still target Java 11.
069public strictfp abstract class ReferencingTestCase extends TestCase {
070    /**
071     * Creates a test case initialized to default values.
072     */
073    protected ReferencingTestCase() {
074    }
075
076    /**
077     * Returns the given wrapper as a primitive value, or NaN if null.
078     *
079     * @param  value  the value to unwrap.
080     * @return the unwrapped value.
081     */
082    private static double toPrimitive(final Double value) {
083        return (value != null) ? value : Double.NaN;
084    }
085
086    /**
087     * Converts the given date to Julian days.
088     *
089     * @param  time  the date to convert.
090     * @return the Julian days for the given date.
091     */
092    private static double julian(final Instant time) {
093        return (time.getEpochSecond() - (-2440588L * (24*60*60) + (12*60*60))) / (24*60*60.0);
094    }
095
096    /**
097     * Infers a value from the extent as an instant and computes the union with a lower or upper bounds.
098     *
099     * @param  bound  the current lower ({@code begin == true}) or upper ({@code begin == false}) bound.
100     * @param  extent the extent from which to read a bound.
101     * @param  begin  {@code true} for the start time, or {@code false} for the end time.
102     * @return the new bound value.
103     */
104    private static Instant union(final Instant bound, final TemporalPrimitive extent, final boolean begin) {
105        if (extent instanceof Period) {
106            final var period = (Period) extent;
107            final Temporal date = (begin ? period.getBeginning() : period.getEnding()).getPosition();
108            final Instant instant;
109            if (date instanceof Instant) {
110                instant = (Instant) date;
111            } else if (date instanceof OffsetDateTime) {
112                instant = ((OffsetDateTime) date).toInstant();
113            } else if (date instanceof ChronoZonedDateTime) {
114                instant = ((ChronoZonedDateTime) date).toInstant();
115            } else {
116                return bound;
117            }
118            if (instant != null && (bound == null || (begin ? instant.isBefore(bound) : instant.isAfter(bound)))) {
119                return instant;
120            }
121        }
122        return bound;
123    }
124
125    /**
126     * Compares the name and identifier of the given {@code object} against the expected values.
127     * This method allows for some flexibilities:
128     *
129     * <ul>
130     *   <li>For {@link IdentifiedObject#getName()}:
131     *     <ul>
132     *       <li>Only the value returned by {@link Identifier#getCode()} is verified.
133     *           The code space, authority and version are ignored.</li>
134     *       <li>Only the characters that are valid for Unicode identifiers are compared (ignoring case), as documented in
135     *           {@link org.opengis.test.Assertions#assertUnicodeIdentifierEquals Assertions.assertUnicodeIdentifierEquals(…)}.</li>
136     *     </ul>
137     *   </li>
138     *   <li>For {@link IdentifiedObject#getIdentifiers()}:
139     *     <ul>
140     *       <li>Only the value returned by {@link Identifier#getCode()} is verified.
141     *           The code space, authority and version are ignored.</li>
142     *       <li>The identifiers collection can contain more identifiers than the expected one,
143     *           and the expected identifier does not need to be first.</li>
144     *       <li>The comparison is case-insensitive.</li>
145     *     </ul>
146     *   </li>
147     * </ul>
148     *
149     * If the given {@code object} is {@code null}, then this method does nothing.
150     * Deciding if {@code null} objects are allowed or not is {@link org.opengis.test.Validator}'s job.
151     *
152     * @param object      the object to verify, or {@code null} if none.
153     * @param name        the expected name (ignoring code space), or {@code null} if unrestricted.
154     * @param identifier  the expected identifier code (ignoring code space), or {@code null} if unrestricted.
155     */
156    protected void verifyIdentification(final IdentifiedObject object, final String name, final String identifier) {
157        if (object != null) {
158            if (name != null) {
159                assertUnicodeIdentifierEquals(name, Utilities.getName(object), true, "getName().getCode()");
160            }
161            if (identifier != null) {
162                for (final Identifier id : object.getIdentifiers()) {
163                    assertNotNull(id, "getName().getIdentifiers()");
164                    if (identifier.equalsIgnoreCase(id.getCode())) {
165                        return;
166                    }
167                }
168                fail("getName().getIdentifiers(): element “" + identifier + "” not found.");
169            }
170        }
171    }
172
173    /**
174     * Compares the name, axis lengths and inverse flattening factor of the given ellipsoid against the expected values.
175     * This method allows for some flexibilities:
176     *
177     * <ul>
178     *   <li>{@link Ellipsoid#getName()} allows for the same flexibilities as the one documented in
179     *       {@link #verifyIdentification verifyIdentification(…)}.</li>
180     *   <li>{@link Ellipsoid#getSemiMajorAxis()} does not need to use the unit of measurement given
181     *       by the {@code axisUnit} argument. Unit conversion will be applied as needed.</li>
182     * </ul>
183     *
184     * The tolerance thresholds are 0.5 unit of the last digits of the values found in the EPSG database:
185     * <ul>
186     *   <li>3 decimal digits for {@code semiMajor} values in metres.</li>
187     *   <li>9 decimal digits for {@code inverseFlattening} values.</li>
188     * </ul>
189     *
190     * If the given {@code ellipsoid} is {@code null}, then this method does nothing.
191     * Deciding if {@code null} datum are allowed or not is {@link org.opengis.test.Validator}'s job.
192     *
193     * @param ellipsoid          the ellipsoid to verify, or {@code null} if none.
194     * @param name               the expected name (ignoring code space), or {@code null} if unrestricted.
195     * @param semiMajor          the expected semi-major axis length, in units given by the {@code axisUnit} argument.
196     * @param inverseFlattening  the expected inverse flattening factor.
197     * @param axisUnit           the unit of the {@code semiMajor} argument (not necessarily the actual unit of the ellipsoid).
198     *
199     * @see GeodeticDatum#getEllipsoid()
200     */
201    protected void verifyFlattenedSphere(final Ellipsoid ellipsoid, final String name,
202            final double semiMajor, final double inverseFlattening, final Unit<Length> axisUnit)
203    {
204        if (ellipsoid != null) {
205            if (name != null) {
206                assertUnicodeIdentifierEquals(name, Utilities.getName(ellipsoid), true,
207                        "Ellipsoid.getName().getCode()");
208            }
209            final Unit<Length> actualUnit = ellipsoid.getAxisUnit();
210            assertNotNull(actualUnit, "Ellipsoid.getAxisUnit()");
211            assertEquals(semiMajor,
212                    actualUnit.getConverterTo(axisUnit).convert(ellipsoid.getSemiMajorAxis()),
213                    units.metre().getConverterTo(axisUnit).convert(5E-4),
214                    "Ellipsoid.getSemiMajorAxis()");
215            assertEquals(inverseFlattening, ellipsoid.getInverseFlattening(), 5E-10,
216                    "Ellipsoid.getInverseFlattening()");
217        }
218    }
219
220    /**
221     * Compares the name and Greenwich longitude of the given prime meridian against the expected values.
222     * This method allows for some flexibilities:
223     *
224     * <ul>
225     *   <li>{@link PrimeMeridian#getName()} allows for the same flexibilities as the one documented in
226     *       {@link #verifyIdentification verifyIdentification(…)}.</li>
227     *   <li>{@link PrimeMeridian#getGreenwichLongitude()} does not need to use the unit of measurement given
228     *       by the {@code angularUnit} argument. Unit conversion will be applied as needed.</li>
229     * </ul>
230     *
231     * The tolerance threshold is 0.5 unit of the last digit of the values found in the EPSG database:
232     * <ul>
233     *   <li>7 decimal digits for {@code greenwichLongitude} values in degrees.</li>
234     * </ul>
235     *
236     * If the given {@code primeMeridian} is {@code null}, then this method does nothing.
237     * Deciding if {@code null} prime meridians are allowed or not is {@link org.opengis.test.Validator}'s job.
238     *
239     * @param primeMeridian       the prime meridian to verify, or {@code null} if none.
240     * @param name                the expected name (ignoring code space), or {@code null} if unrestricted.
241     * @param greenwichLongitude  the expected Greenwich longitude, in units given by the {@code angularUnit} argument.
242     * @param angularUnit         the unit of the {@code greenwichLongitude} argument (not necessarily the actual unit of the prime meridian).
243     *
244     * @see GeodeticDatum#getPrimeMeridian()
245     */
246    protected void verifyPrimeMeridian(final PrimeMeridian primeMeridian, final String name,
247            final double greenwichLongitude, final Unit<Angle> angularUnit)
248    {
249        if (primeMeridian != null) {
250            if (name != null) {
251                assertUnicodeIdentifierEquals(name, Utilities.getName(primeMeridian), true,
252                        "PrimeMeridian.getName().getCode()");
253            }
254            final Unit<Angle> actualUnit = primeMeridian.getAngularUnit();
255            assertNotNull(actualUnit, "PrimeMeridian.getAngularUnit()");
256            assertEquals(greenwichLongitude,
257                    actualUnit.getConverterTo(angularUnit).convert(primeMeridian.getGreenwichLongitude()),
258                    units.degree().getConverterTo(angularUnit).convert(5E-8),
259                    "PrimeMeridian.getGreenwichLongitude()");
260        }
261    }
262
263    /**
264     * Compares the type, axis units and directions of the given coordinate system against the expected values.
265     * This method does not verify the coordinate system name because it is usually not significant.
266     * This method does not verify axis names neither because the names specified by ISO 19111 and ISO 19162 differ.
267     *
268     * <p>If the given {@code cs} is {@code null}, then this method does nothing.
269     * Deciding if {@code null} coordinate systems are allowed or not is {@link org.opengis.test.Validator}'s job.</p>
270     *
271     * @param  cs          the coordinate system to verify, or {@code null} if none.
272     * @param  type        the expected coordinate system type.
273     * @param  directions  the expected axis directions. The length of this array determines the expected {@code cs} dimension.
274     * @param  axisUnits   the expected axis units. If the array length is less than the {@code cs} dimension,
275     *                     then the last unit is repeated for all remaining dimensions.
276     *                     If the array length is greater, than extra units are ignored.
277     *
278     * @see CoordinateReferenceSystem#getCoordinateSystem()
279     */
280    protected void verifyCoordinateSystem(final CoordinateSystem cs, final Class<? extends CoordinateSystem> type,
281            final AxisDirection[] directions, final Unit<?>... axisUnits)
282    {
283        if (cs != null) {
284            assertEquals(directions.length, cs.getDimension(), "CoordinateSystem.getDimension()");
285            for (int i=0; i<directions.length; i++) {
286                final CoordinateSystemAxis axis = cs.getAxis(i);
287                assertNotNull(axis, "CoordinateSystem.getAxis(*)");
288                assertEquals(directions[i], axis.getDirection(), "CoordinateSystem.getAxis(*).getDirection()");
289                assertEquals(axisUnits[Math.min(i, axisUnits.length-1)], axis.getUnit(), "CoordinateSystem.getAxis(*).getUnit()");
290            }
291        }
292    }
293
294    /**
295     * Compares an operation parameter against the expected value.
296     * This method allows for some flexibilities:
297     *
298     * <ul>
299     *   <li>The parameter does not need to use the unit of measurement given by the {@code unit} argument.
300     *       Unit conversion should be applied as needed by the {@link ParameterValue#doubleValue(Unit)} method.</li>
301     * </ul>
302     *
303     * If the given {@code group} is {@code null}, then this method does nothing.
304     * Deciding if {@code null} parameters are allowed or not is {@link org.opengis.test.Validator}'s job.
305     *
306     * @param group  the parameter group containing the parameter to test.
307     * @param name   the name of the parameter to test.
308     * @param value  the expected parameter value when expressed in units given by the {@code unit} argument.
309     * @param unit   the units of measurement of the {@code value} argument
310     *               (not necessarily the unit actually used by the implementation).
311     */
312    protected void verifyParameter(final ParameterValueGroup group, final String name, final double value, final Unit<?> unit) {
313        if (group != null) {
314            final ParameterValue<?> param = group.parameter(name);
315            assertNotNull(param, name);
316            assertEquals(param.doubleValue(unit), value, StrictMath.abs(value * 1E-10), name);
317        }
318    }
319
320    /**
321     * Compares the geographic description and bounding box of the given extent against the expected values.
322     * This method allows for some flexibilities:
323     *
324     * <ul>
325     *   <li>For {@link GeographicDescription} elements:
326     *     <ul>
327     *       <li>Descriptions are considered optional. If the given {@code extent} does not contain any
328     *           {@code GeographicDescription} element, then the given {@code description} argument is ignored.</li>
329     *       <li>If the given {@code extent} contains more than one {@code GeographicDescription} element, then only
330     *           one of them (not necessarily the first one) needs to have the given {@code description} value.
331     *           Other elements are ignored.</li>
332     *     </ul>
333     *   </li>
334     *   <li>For {@link GeographicBoundingBox} elements:
335     *     <ul>
336     *       <li>Bounding boxes are considered optional. If the given {@code extent} does not contain any
337     *           {@code GeographicBoundingBox} element, then all given bound arguments are ignored.</li>
338     *       <li>If the given {@code extent} contains more than one {@code GeographicBoundingBox} element,
339     *           then the union of them is compared against the given bound arguments.</li>
340     *     </ul>
341     *   </li>
342     * </ul>
343     *
344     * The tolerance threshold is 0.005° since geographic bounding box are only approximate information.
345     *
346     * <p>If the given {@code extent} is {@code null}, then this method does nothing.
347     * Deciding if {@code null} extents are allowed or not is {@link org.opengis.test.Validator}'s job.</p>
348     *
349     * @param extent              the extent to verify, or {@code null} if none.
350     * @param description         the expected area, or {@code null} if unrestricted.
351     * @param southBoundLatitude  the expected minimum latitude,  or NaN if unrestricted.
352     * @param westBoundLongitude  the expected minimum longitude, or NaN if unrestricted.
353     * @param northBoundLatitude  the expected maximum latitude,  or NaN if unrestricted.
354     * @param eastBoundLongitude  the expected maximum longitude, or NaN if unrestricted.
355     *
356     * @see ObjectDomain#getDomainOfValidity()
357     */
358    protected void verifyGeographicExtent(final Extent extent, final String description,
359            final double southBoundLatitude, final double westBoundLongitude,
360            final double northBoundLatitude, final double eastBoundLongitude)
361    {
362        if (extent != null) {
363            double ymin = Double.POSITIVE_INFINITY;
364            double xmin = Double.POSITIVE_INFINITY;
365            double ymax = Double.NEGATIVE_INFINITY;
366            double xmax = Double.NEGATIVE_INFINITY;
367            String unknownArea = null;
368            for (final GeographicExtent e : extent.getGeographicElements()) {
369                if (e instanceof GeographicBoundingBox) {
370                    final GeographicBoundingBox bbox = (GeographicBoundingBox) e;
371                    double t;
372                    if ((t = bbox.getSouthBoundLatitude()) < ymin) ymin = t;
373                    if ((t = bbox.getWestBoundLongitude()) < xmin) xmin = t;
374                    if ((t = bbox.getNorthBoundLatitude()) > ymax) ymax = t;
375                    if ((t = bbox.getEastBoundLongitude()) > xmax) xmax = t;
376                }
377                /*
378                 * Description: optional, but if present we allow any number of identifiers
379                 * provided that at least one contain the expected string.
380                 */
381                if (description != null && e instanceof GeographicDescription) {
382                    final String area = ((GeographicDescription) e).getGeographicIdentifier().getCode();
383                    if (description.equals(area)) {
384                        unknownArea = null;
385                        break;
386                    }
387                    if (unknownArea == null) {
388                        unknownArea = area;         // For reporting an error message if we do not find the expected area.
389                    }
390                }
391            }
392            if (unknownArea != null) {
393                assertEquals(description, unknownArea, "GeographicDescription");
394            }
395            /*
396             * WKT 2 specification said that BBOX precision should be about 0.01°.
397             */
398            if (!isNaN(southBoundLatitude) && ymin != Double.POSITIVE_INFINITY) assertEquals(southBoundLatitude, ymin, 0.005, "getSouthBoundLatitude()");
399            if (!isNaN(westBoundLongitude) && xmin != Double.POSITIVE_INFINITY) assertEquals(westBoundLongitude, xmin, 0.005, "getWestBoundLongitude()");
400            if (!isNaN(northBoundLatitude) && ymax != Double.NEGATIVE_INFINITY) assertEquals(northBoundLatitude, ymax, 0.005, "getNorthBoundLatitude()");
401            if (!isNaN(eastBoundLongitude) && xmax != Double.NEGATIVE_INFINITY) assertEquals(eastBoundLongitude, xmax, 0.005, "getEastBoundLongitude()");
402        }
403    }
404
405    /**
406     * Compares the vertical elements of the given extent against the expected values.
407     * This method allows for some flexibilities:
408     *
409     * <ul>
410     *   <li>Vertical extents are considered optional. If the given {@code extent} does not contain any
411     *       {@code VerticalExtent} element, then this method does nothing.</li>
412     *   <li>{@link VerticalExtent#getMinimumValue()} and {@link VerticalExtent#getMaximumValue() getMaximumValue()}
413     *       do not need to use the unit of measurement given by the {@code unit} argument. Unit conversions will be
414     *       applied as needed if {@link VerticalExtent#getVerticalCRS()} returns a non-null value.</li>
415     *   <li>If the given {@code extent} contains more than one {@code VerticalExtent} element,
416     *       then the union of them is compared against the given bound arguments.</li>
417     * </ul>
418     *
419     * <p>If the given {@code extent} is {@code null}, then this method does nothing.
420     * Deciding if {@code null} extents are allowed or not is {@link org.opengis.test.Validator}'s job.</p>
421     *
422     * @param extent        the extent to verify, or {@code null} if none.
423     * @param minimumValue  the expected minimal vertical value, or NaN if unrestricted.
424     * @param maximumValue  the expected maximal vertical value, or NaN if unrestricted.
425     * @param tolerance     the tolerance threshold to use for comparison.
426     * @param unit          the unit of {@code minimumValue}, {@code maximumValue} and {@code tolerance} arguments,
427     *                      or {@code null} for skipping the unit conversion.
428     *
429     * @see ObjectDomain#getDomainOfValidity()
430     */
431    protected void verifyVerticalExtent(final Extent extent,
432            final double minimumValue, final double maximumValue, final double tolerance, final Unit<?> unit)
433    {
434        if (extent != null) {
435            double min = Double.POSITIVE_INFINITY;
436            double max = Double.NEGATIVE_INFINITY;
437            for (final VerticalExtent e : extent.getVerticalElements()) {
438                double minValue = toPrimitive(e.getMinimumValue());
439                double maxValue = toPrimitive(e.getMaximumValue());
440                if (unit != null) {
441                    final VerticalCRS crs = e.getVerticalCRS();
442                    if (crs != null) {
443                        final VerticalCS cs = crs.getCoordinateSystem();
444                        if (cs != null) {
445                            assertEquals(1, cs.getDimension(),
446                                    "VerticalExtent.getVerticalCRS().getCoordinateSystem().getDimension()");
447                            final CoordinateSystemAxis axis = cs.getAxis(0);
448                            if (axis != null) {
449                                final Unit<?> u = axis.getUnit();
450                                if (u != null) {
451                                    final UnitConverter c;
452                                    try {
453                                        c = u.getConverterToAny(unit);
454                                    } catch (IncommensurableException ex) {
455                                        throw new AssertionFailedError("Expected VerticalExtent in units of “"
456                                                + unit + "” but got units of “" + u + "”.", ex);
457                                    }
458                                    minValue = c.convert(minValue);
459                                    maxValue = c.convert(maxValue);
460                                }
461                            }
462                        }
463                    }
464                }
465                if (minValue < min) min = minValue;
466                if (maxValue > max) max = maxValue;
467            }
468            if (!isNaN(minimumValue) && min != Double.POSITIVE_INFINITY) assertEquals(minimumValue, min, tolerance, "VerticalExtent.getMinimumValue()");
469            if (!isNaN(maximumValue) && max != Double.NEGATIVE_INFINITY) assertEquals(maximumValue, max, tolerance, "VerticalExtent.getMaximumValue()");
470        }
471    }
472
473    /**
474     * Compares the temporal elements of the given extent against the expected values.
475     * This method allows for some flexibilities:
476     *
477     * <ul>
478     *   <li>Temporal extents are considered optional. If the given {@code extent} does not contain any
479     *       {@code TemporalExtent} element, or if extent bounds can not be represented as {@link Instant}
480     *       objects, then this method does nothing.</li>
481     *   <li>If the given {@code extent} contains more than one {@code TemporalExtent} element,
482     *       then the union of them is compared against the given bound arguments.</li>
483     * </ul>
484     *
485     * If the given {@code extent} is {@code null}, then this method does nothing.
486     * Deciding if {@code null} extents are allowed or not is {@link org.opengis.test.Validator}'s job.
487     *
488     * @param extent     the extent to verify, or {@code null} if none.
489     * @param startTime  the expected start time, or {@code null} if unrestricted.
490     * @param endTime    the expected end time, or {@code null} if unrestricted.
491     * @param tolerance  the tolerance threshold to use for comparison, in unit of days.
492     *
493     * @see ObjectDomain#getDomainOfValidity()
494     */
495    protected void verifyTimeExtent(final Extent extent, final Instant startTime, final Instant endTime, final double tolerance) {
496        if (extent != null) {
497            Instant min = null;
498            Instant max = null;
499            for (final TemporalExtent e : extent.getTemporalElements()) {
500                final TemporalPrimitive p = e.getExtent();
501                min = union(min, p, true);
502                max = union(max, p, false);
503            }
504            if (startTime != null && min != null) {
505                assertEquals(julian(startTime), julian(min), tolerance, "TemporalExtent start time (julian days)");
506            }
507            if (endTime != null && max != null) {
508                assertEquals(julian(endTime), julian(max), tolerance, "TemporalExtent end time (julian days)");
509            }
510        }
511    }
512}