Passed
Push — master ( 1ad806...2634d1 )
by Doug
63:26
created

AutoConversion::isWithinCRSBoundingArea()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 2
c 0
b 0
f 0
nc 2
nop 0
dl 0
loc 5
ccs 2
cts 2
cp 1
crap 2
rs 10
1
<?php
2
/**
3
 * PHPCoord.
4
 *
5
 * @author Doug Wright
6
 */
7
declare(strict_types=1);
8
9
namespace PHPCoord\CoordinateOperation;
10
11
use DateTimeImmutable;
12
use PHPCoord\Point\CompoundPoint;
13
use PHPCoord\CoordinateReferenceSystem\Compound;
14
use PHPCoord\CoordinateReferenceSystem\CoordinateReferenceSystem;
15
use PHPCoord\CoordinateReferenceSystem\Geocentric;
16
use PHPCoord\CoordinateReferenceSystem\Geographic;
17
use PHPCoord\CoordinateReferenceSystem\Geographic2D;
18
use PHPCoord\CoordinateReferenceSystem\Geographic3D;
19
use PHPCoord\CoordinateReferenceSystem\Projected;
20
use PHPCoord\CoordinateReferenceSystem\Vertical;
21
use PHPCoord\Exception\UnknownConversionException;
22
use PHPCoord\Point\GeocentricPoint;
23
use PHPCoord\Point\GeographicPoint;
24
use PHPCoord\Geometry\BoundingArea;
25
use PHPCoord\Geometry\RegionMap;
26
use PHPCoord\Point\Point;
27
use PHPCoord\Point\ProjectedPoint;
28
use PHPCoord\UnitOfMeasure\Time\Time;
29
use PHPCoord\UnitOfMeasure\Time\Year;
30
use PHPCoord\Point\VerticalPoint;
31
32
use function abs;
33
use function array_column;
34
use function array_shift;
35
use function array_sum;
36
use function array_unique;
37
use function assert;
38
use function class_exists;
39
use function count;
40
use function in_array;
41
use function usort;
42
use function str_ends_with;
43
use function is_string;
44
45
/**
46
 * @internal
47
 */
48
trait AutoConversion
49
{
50 2393
    private int $maxChainDepth = 6; // if traits could have constants...
51
52 2393
    /**
53 226
     * @var array<string, array<int, array{path: array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>, accuracy: float}>>
54
     */
55
    private static array $completePathCache = [];
56 2213
57 2213
    /**
58
     * @return ($to is Compound ? CompoundPoint : ($to is Geocentric ? GeocentricPoint : ($to is Geographic ? GeographicPoint : ($to is Projected ? ProjectedPoint : ($to is Vertical ? VerticalPoint: Point)))))
0 ignored issues
show
Documentation Bug introduced by
The doc comment ($to at position 1 could not be parsed: Unknown type name '$to' at position 1 in ($to.
Loading history...
59 2177
     */
60 2177
    public function convert(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $to, bool $ignoreBoundaryRestrictions = false): Point
61 2177
    {
62
        if ($this->getCRS() == $to) {
63
            return $this;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this returns the type PHPCoord\CoordinateOperation\AutoConversion which is incompatible with the type-hinted return PHPCoord\Point\Point.
Loading history...
64 2177
        }
65
66
        $point = $this;
67 2213
        $path = $this->findOperationPath($this->getCRS(), $to, $ignoreBoundaryRestrictions);
68
69 2213
        foreach ($path as $step) {
70
            $target = CoordinateReferenceSystem::fromSRID($step['in_reverse'] ? $step['source_crs'] : $step['target_crs']);
71
            $point = $point->performOperation($step['operation'], $target, $step['in_reverse']);
72 2213
        }
73 2213
74
        return $point;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $point could return the type PHPCoord\CoordinateOperation\AutoConversion which is incompatible with the type-hinted return PHPCoord\Point\Point. Consider adding an additional type-check to rule them out.
Loading history...
75 2213
    }
76 2195
77 2177
    /**
78
     * Calculates if the point as constructed actually lies within the bounding box of the CRS.
79
     */
80
    public function isWithinCRSBoundingArea(): bool
81 2002
    {
82
        $pointAsWGS84 = $this->getPointForBoundaryCheck(); // some obscure CRSs might not be able to convert
83
84 2195
        return !$pointAsWGS84 instanceof GeographicValue || $this->crs->getBoundingArea()->containsPoint($pointAsWGS84);
85
    }
86 2195
87 2195
    /**
88 2195
     * @return array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>
89
     */
90 2159
    protected function findOperationPath(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target, bool $ignoreBoundaryRestrictions): array
91 2159
    {
92 110
        $boundaryCheckPoint = $ignoreBoundaryRestrictions ? null : $this->getPointForBoundaryCheck();
93
94
        // Iteratively calculate permutations of intermediate CRSs
95
        $candidatePaths = $this->buildTransformationPathsToCRS($source, $target);
96 2186
        usort($candidatePaths, static fn (array $a, array $b) => $a['accuracy'] <=> $b['accuracy'] ?: count($a['path']) <=> count($b['path']));
97 2186
98
        foreach ($candidatePaths as $candidatePath) {
99
            if ($this->validatePath($candidatePath['path'], $target, $boundaryCheckPoint)) {
100 2186
                return $candidatePath['path'];
101 2186
            }
102 61
        }
103 43
104 43
        throw new UnknownConversionException('Unable to perform conversion, please file a bug if you think this is incorrect');
105 2
    }
106
107
    /**
108
     * @param array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}> $candidatePath
109
     */
110
    protected function validatePath(array $candidatePath, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target, ?GeographicValue $boundaryCheckPoint): bool
111 2186
    {
112 98
        foreach ($candidatePath as $pathStep) {
113
            $operation = CoordinateOperations::getOperationData($pathStep['operation']);
0 ignored issues
show
Bug introduced by
The type PHPCoord\CoordinateOperation\CoordinateOperations was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
114
115 2177
            if ($boundaryCheckPoint) { // Filter out operations that only operate outside this point
116
                // First, eliminate based on bounding box if possible, that's quicker as only 4 corners
117
                if (!$operation['extent'] instanceof BoundingArea) {
118 2177
                    $bbox = BoundingArea::createFromExtentCodes($operation['extent'], true);
119 9
                    if (!$bbox->containsPoint($boundaryCheckPoint)) {
120 9
                        return false;
121
                    }
122
                }
123
124
                // Then (or only) check the point is inside the full, complex shape
125
                $polygon = $operation['extent'] instanceof BoundingArea ? $operation['extent'] : BoundingArea::createFromExtentCodes($operation['extent']);
126
                if ((!isset($bbox) || $polygon !== $bbox) && !$polygon->containsPoint($boundaryCheckPoint)) {
127 2177
                    return false;
128 2177
                }
129 166
            }
130
131
            $operation = CoordinateOperations::getOperationData($pathStep['operation']);
132
            $methodParams = CoordinateOperationMethods::getMethodData($operation['method'])['paramData'];
133
134 2177
            // filter out operations that use a 2D CRS as intermediate where this is a 3D point
135
            $currentCRS = $this->getCRS();
136
            if ($currentCRS instanceof Compound || count($currentCRS->getCoordinateSystem()->getAxes()) === 3) {
137
                if ($target instanceof Compound || count($target->getCoordinateSystem()->getAxes()) !== 2) {
138
                    $intermediateTarget = CoordinateReferenceSystem::fromSRID($pathStep['in_reverse'] ? $pathStep['source_crs'] : $pathStep['target_crs']);
139
                    if (!$intermediateTarget instanceof Compound && count($intermediateTarget->getCoordinateSystem()->getAxes()) === 2) {
140 2213
                        return false;
141
                    }
142 2213
                }
143 2213
            }
144 2213
145 2213
            // filter out operations that require an epoch if we don't have one
146 2213
            if (isset($methodParams['transformationReferenceEpoch']) && !$this->getCoordinateEpoch()) {
147
                return false;
148 2213
            }
149 413
150 413
            $operationParams = CoordinateOperations::getParamData($pathStep['operation']);
151 413
152
            // filter out operations that require a specific epoch
153 413
            if (isset($methodParams['transformationReferenceEpoch']) && $this->getCoordinateEpoch()) {
154 413
                assert($operationParams['transformationReferenceEpoch'] instanceof Time);
155 413
                $pointEpoch = Year::fromDateTime($this->getCoordinateEpoch());
156
                if (!(abs($pointEpoch->subtract($operationParams['transformationReferenceEpoch'])->getValue()) <= 0.001)) {
157 413
                    return false;
158 413
                }
159 413
            }
160 386
161 413
            // filter out operations that require a grid file that we don't have, or where boundaries are not being
162 404
            // checked (a formula-based conversion will always return *a* result, outside a grid boundary does not...)
163 404
            foreach ($operationParams as $paramName => $paramValue) {
164 404
                if (str_ends_with($paramName, 'File') && is_string($paramValue) && (!$boundaryCheckPoint || !class_exists($paramValue))) {
165
                    return false;
166
                }
167
            }
168
        }
169
170
        return true;
171 413
    }
172
173 413
    /**
174 413
     * Build the set of possible paths that lead from the current CRS to the target CRS.
175 386
     * @return array<int, array{path: array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>, accuracy: float}>
176
     */
177
    protected function buildTransformationPathsToCRS(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target): array
178 413
    {
179 413
        $iterations = 0;
180 413
        $sourceSRID = $source->getSRID();
181
        $targetSRID = $target->getSRID();
182
        $previousSimplePaths = [[$sourceSRID]];
183
        $cacheKey = $sourceSRID . '|' . $targetSRID;
184 2213
185
        if (!isset(self::$completePathCache[$cacheKey])) {
186
            $transformationsByCRS = self::buildSupportedTransformationsByCRS($source, $target);
187 413
            $transformationsByCRSPair = self::buildSupportedTransformationsByCRSPair($source, $target);
188
            self::$completePathCache[$cacheKey] = [];
189 413
190 413
            while ($iterations <= $this->maxChainDepth) {
191 386
                $completePaths = [];
192 386
                $simplePaths = [];
193 386
194
                foreach ($previousSimplePaths as $simplePath) {
195 386
                    $current = $simplePath[$iterations];
196 386
                    if ($current === $targetSRID) {
197 386
                        $completePaths[] = $simplePath;
198 386
                    } elseif (isset($transformationsByCRS[$current])) {
199 386
                        foreach ($transformationsByCRS[$current] as $next) {
200
                            if (!in_array($next, $simplePath, true)) {
201
                                $simplePaths[] = [...$simplePath, $next];
202
                            }
203 386
                        }
204 386
                    }
205 386
                }
206 386
207
                // Then expand each CRS->CRS permutation with the various ways of achieving that (can be lots :/)
208 386
                $fullPaths = $this->expandSimplePaths($transformationsByCRSPair, $completePaths, $sourceSRID, $targetSRID);
209 386
210
                $paths = [];
211
                foreach ($fullPaths as $fullPath) {
212
                    $paths[] = ['path' => $fullPath, 'accuracy' => array_sum(array_column($fullPath, 'accuracy'))];
213 413
                }
214
215
                $previousSimplePaths = $simplePaths;
216
                self::$completePathCache[$cacheKey] = [...self::$completePathCache[$cacheKey], ...$paths];
217
                ++$iterations;
218
            }
219
        }
220
221
        return self::$completePathCache[$cacheKey];
222 2195
    }
223
224 2195
    /**
225 50
     * @param  array<string, array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>> $transformationsByCRSPair
226
     * @param  array<int, array<int, string>>                                                                                                               $simplePaths
227 2158
     * @return array<int, array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>>
228
     */
229
    protected function expandSimplePaths(array $transformationsByCRSPair, array $simplePaths, string $fromSRID, string $toSRID): array
230
    {
231
        $fullPaths = [];
232 2195
        foreach ($simplePaths as $simplePath) {
233 1935
            $transformationsToMakePath = [[]];
234
            $from = array_shift($simplePath);
235
            assert($from === $fromSRID);
236
            do {
237
                $to = array_shift($simplePath);
238 1935
                $wipTransformationsInPath = [];
239
                foreach ($transformationsByCRSPair[$from . '|' . $to] ?? [] as $transformation) {
240
                    foreach ($transformationsToMakePath as $transformationToMakePath) {
241
                        $wipTransformationsInPath[] = [...$transformationToMakePath, $transformation];
242
                    }
243
                }
244
245
                $transformationsToMakePath = $wipTransformationsInPath;
246
                $from = $to;
247 1935
            } while (count($simplePath) > 0);
248 1926
            assert($to === $toSRID);
249
250
            foreach ($transformationsToMakePath as $transformationToMakePath) {
251 9
                $fullPaths[] = $transformationToMakePath;
252 9
            }
253
        }
254 9
255
        return $fullPaths;
256
    }
257
258
    /**
259 413
     * Boundary polygons are defined as WGS84, so theoretically all that needs to happen is
260
     * to conversion to WGS84 by calling ->convert(). However, that leads quickly to either circularity
261 413
     * when a conversion is possible, or an exception because not every CRS has a WGS84 transformation
262 413
     * available to it even when chaining.
263 413
     */
264 413
    protected function getPointForBoundaryCheck(): ?GeographicValue
265 413
    {
266 413
        if ($this instanceof CompoundPoint) {
267 413
            $point = $this->getHorizontalPoint();
268 413
        } else {
269 413
            $point = $this;
270 413
        }
271 413
272 413
        try {
273 413
            // try converting to WGS84 if possible...
274 413
            return $point->convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->asGeographicValue();
0 ignored issues
show
Bug introduced by
The method asGeographicValue() does not exist on PHPCoord\Point\ProjectedPoint. Did you maybe mean asGeographicPoint()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

274
            return $point->convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->/** @scrutinizer ignore-call */ asGeographicValue();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method asGeographicValue() does not exist on PHPCoord\Point\Point. It seems like you code against a sub-type of PHPCoord\Point\Point such as PHPCoord\Point\GeocentricPoint or PHPCoord\Point\GeographicPoint. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

274
            return $point->convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->/** @scrutinizer ignore-call */ asGeographicValue();
Loading history...
Bug introduced by
It seems like asGeographicValue() must be provided by classes using this trait. How about adding it as abstract method to this trait? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

274
            return $point->convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->/** @scrutinizer ignore-call */ asGeographicValue();
Loading history...
275 413
        } catch (UnknownConversionException) {
276
            /*
277
             * If Projected then either the point is inside the boundary by definition
278 413
             * or the user is deliberately exceeding the safe zone so safe to make a no-op either way.
279 413
             */
280 413
            if ($point instanceof ProjectedPoint) {
281 413
                return null;
282
            }
283 413
284 413
            /*
285
             * Otherwise, compensate for non-Greenwich Prime Meridian, but otherwise assume that coordinates
286 413
             * are interchangeable between the actual CRS and WGS84. Boundaries are only defined to the nearest
287 413
             * ≈1km so the error bound should be acceptable within the area of interest
288
             */
289
            if ($point instanceof GeographicPoint) {
290
                return new GeographicValue($point->getLatitude(), $point->getLongitude()->subtract($point->getCRS()->getDatum()->getPrimeMeridian()->getGreenwichLongitude()), null, $point->getCRS()->getDatum());
291 413
            }
292
293
            if ($point instanceof GeocentricPoint) {
294 413
                $asGeographic = $point->asGeographicValue();
295
296 413
                return new GeographicValue($asGeographic->getLatitude(), $asGeographic->getLongitude()->subtract($asGeographic->getDatum()->getPrimeMeridian()->getGreenwichLongitude()), null, $asGeographic->getDatum());
297 413
            }
298 413
299 413
            throw new UnknownConversionException();
300 413
        }
301 413
    }
302 413
303 413
    /**
304 413
     * @return array<string, array<string, string>>
305 413
     */
306 413
    protected static function buildSupportedTransformationsByCRS(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target): array
307 413
    {
308 413
        $regions = array_unique([$source->getBoundingArea()->getRegion(), $target->getBoundingArea()->getRegion(), RegionMap::REGION_GLOBAL]);
309 413
        $relevantRegionData = CoordinateOperations::getCustomTransformations();
310 413
        foreach ($regions as $region) {
311
            $regionData = match ($region) {
312
                RegionMap::REGION_GLOBAL => CRSTransformationsGlobal::getSupportedTransformations(),
313 413
                RegionMap::REGION_AFRICA => CRSTransformationsAfrica::getSupportedTransformations(),
314 413
                RegionMap::REGION_ANTARCTIC => CRSTransformationsAntarctic::getSupportedTransformations(),
315 413
                RegionMap::REGION_ARCTIC => CRSTransformationsArctic::getSupportedTransformations(),
316 413
                RegionMap::REGION_ASIA => CRSTransformationsAsia::getSupportedTransformations(),
317
                RegionMap::REGION_EUROPE => CRSTransformationsEurope::getSupportedTransformations(),
318 413
                RegionMap::REGION_NORTHAMERICA => CRSTransformationsNorthAmerica::getSupportedTransformations(),
319 413
                RegionMap::REGION_OCEANIA => CRSTransformationsOceania::getSupportedTransformations(),
320 413
                RegionMap::REGION_SOUTHAMERICA => CRSTransformationsSouthAmerica::getSupportedTransformations(),
321 413
                default => throw new UnknownConversionException('Unknown region: ' . $region),
322 413
            };
323
            $relevantRegionData = [...$relevantRegionData, ...$regionData];
324
        }
325
326 413
        $transformationsByCRS = [];
327
        foreach ($relevantRegionData as $transformation) {
328
            $operation = CoordinateOperations::getOperationData($transformation['operation']);
329
            $method = CoordinateOperationMethods::getMethodData($operation['method']);
330
331
            if (!isset($transformationsByCRS[$transformation['source_crs']][$transformation['target_crs']])) {
332
                $transformationsByCRS[$transformation['source_crs']][$transformation['target_crs']] = $transformation['target_crs'];
333
            }
334
            if ($method['reversible'] && !isset($transformationsByCRS[$transformation['target_crs']][$transformation['source_crs']])) {
335
                $transformationsByCRS[$transformation['target_crs']][$transformation['source_crs']] = $transformation['source_crs'];
336
            }
337
        }
338
339
        return $transformationsByCRS;
340
    }
341
342
    /**
343
     * @return array<string, array<array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>>
344
     */
345
    protected static function buildSupportedTransformationsByCRSPair(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target): array
346
    {
347
        $regions = array_unique([$source->getBoundingArea()->getRegion(), $target->getBoundingArea()->getRegion(), RegionMap::REGION_GLOBAL]);
348
        $relevantRegionData = [];
349
        foreach ($regions as $region) {
350
            $regionData = match ($region) {
351
                RegionMap::REGION_GLOBAL => [...CRSTransformationsGlobal::getSupportedTransformations(), ...CoordinateOperations::getCustomTransformations()],
352
                RegionMap::REGION_AFRICA => CRSTransformationsAfrica::getSupportedTransformations(),
353
                RegionMap::REGION_ANTARCTIC => CRSTransformationsAntarctic::getSupportedTransformations(),
354
                RegionMap::REGION_ARCTIC => CRSTransformationsArctic::getSupportedTransformations(),
355
                RegionMap::REGION_ASIA => CRSTransformationsAsia::getSupportedTransformations(),
356
                RegionMap::REGION_EUROPE => CRSTransformationsEurope::getSupportedTransformations(),
357
                RegionMap::REGION_NORTHAMERICA => CRSTransformationsNorthAmerica::getSupportedTransformations(),
358
                RegionMap::REGION_OCEANIA => CRSTransformationsOceania::getSupportedTransformations(),
359
                RegionMap::REGION_SOUTHAMERICA => CRSTransformationsSouthAmerica::getSupportedTransformations(),
360
                default => throw new UnknownConversionException('Unknown region: ' . $region),
361
            };
362
            $relevantRegionData = [...$relevantRegionData, ...$regionData];
363
        }
364
365
        $transformationsByCRSPair = [];
366
        foreach ($relevantRegionData as $key => $transformation) {
367
            $operation = CoordinateOperations::getOperationData($transformation['operation']);
368
            $method = CoordinateOperationMethods::getMethodData($operation['method']);
369
370
            $transformationsByCRSPair[$transformation['source_crs'] . '|' . $transformation['target_crs']][$key] = $transformation;
371
            $transformationsByCRSPair[$transformation['source_crs'] . '|' . $transformation['target_crs']][$key]['in_reverse'] = false;
372
            if ($method['reversible']) {
373
                $transformationsByCRSPair[$transformation['target_crs'] . '|' . $transformation['source_crs']][$key] = $transformation;
374
                $transformationsByCRSPair[$transformation['target_crs'] . '|' . $transformation['source_crs']][$key]['in_reverse'] = true;
375
            }
376
        }
377
378
        return $transformationsByCRSPair;
379
    }
380
381
    abstract public function getCRS(): CoordinateReferenceSystem;
382
383
    abstract public function getCoordinateEpoch(): ?DateTimeImmutable;
384
385
    abstract protected function performOperation(string $srid, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $to, bool $inReverse): Point;
386
}
387