AutoConversion::buildTransformationPathsToCRS()   B
last analyzed

Complexity

Conditions 9
Paths 2

Size

Total Lines 45
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 9

Importance

Changes 8
Bugs 0 Features 0
Metric Value
cc 9
eloc 28
c 8
b 0
f 0
nc 2
nop 2
dl 0
loc 45
ccs 28
cts 28
cp 1
crap 9
rs 8.0555
1
<?php
2
3
/**
4
 * PHPCoord.
5
 *
6
 * @author Doug Wright
7
 */
8
declare(strict_types=1);
9
10
namespace PHPCoord\CoordinateOperation;
11
12
use DateTimeImmutable;
13
use PHPCoord\Point\CompoundPoint;
14
use PHPCoord\CoordinateReferenceSystem\Compound;
15
use PHPCoord\CoordinateReferenceSystem\CoordinateReferenceSystem;
16
use PHPCoord\CoordinateReferenceSystem\Geocentric;
17
use PHPCoord\CoordinateReferenceSystem\Geographic;
18
use PHPCoord\CoordinateReferenceSystem\Geographic2D;
19
use PHPCoord\CoordinateReferenceSystem\Geographic3D;
20
use PHPCoord\CoordinateReferenceSystem\Projected;
21
use PHPCoord\CoordinateReferenceSystem\Vertical;
22
use PHPCoord\Exception\UnknownConversionException;
23
use PHPCoord\Point\GeocentricPoint;
24
use PHPCoord\Point\GeographicPoint;
25
use PHPCoord\Geometry\BoundingArea;
26
use PHPCoord\Geometry\RegionMap;
27
use PHPCoord\Point\Point;
28
use PHPCoord\Point\ProjectedPoint;
29
use PHPCoord\UnitOfMeasure\Time\Time;
30
use PHPCoord\UnitOfMeasure\Time\Year;
31
use PHPCoord\Point\VerticalPoint;
32
33
use function abs;
34
use function array_column;
35
use function array_shift;
36
use function array_sum;
37
use function array_unique;
38
use function assert;
39
use function class_exists;
40
use function count;
41
use function in_array;
42
use function usort;
43
use function str_ends_with;
44
use function is_string;
45
46
/**
47
 * @internal
48
 */
49
trait AutoConversion
50
{
51
    private int $maxChainDepth = 6; // if traits could have constants...
52
53
    /**
54
     * @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}>>
55
     */
56
    private static array $completePathCache = [];
57
58
    /**
59
     * @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...
60 553
     */
61
    public function convert(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $to, bool $ignoreBoundaryRestrictions = false): Point
62 553
    {
63 279
        if ($this->getCRS() == $to) {
64
            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...
65
        }
66 328
67 328
        $point = $this;
68
        $path = $this->findOperationPath($this->getCRS(), $to, $ignoreBoundaryRestrictions);
69 292
70 292
        foreach ($path as $step) {
71 292
            $target = CoordinateReferenceSystem::fromSRID($step['in_reverse'] ? $step['source_crs'] : $step['target_crs']);
72
            $point = $point->performOperation($step['operation'], $target, $step['in_reverse']);
73
        }
74 292
75
        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...
76
    }
77
78
    /**
79
     * Calculates if the point as constructed actually lies within the bounding box of the CRS.
80 9
     */
81
    public function isWithinCRSBoundingArea(): bool
82 9
    {
83
        $pointAsWGS84 = $this->getPointForBoundaryCheck(); // some obscure CRSs might not be able to convert
84 9
85
        return !$pointAsWGS84 instanceof GeographicValue || $this->crs->getBoundingArea()->containsPoint($pointAsWGS84);
86
    }
87
88
    /**
89
     * @return array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>
90 328
     */
91
    protected function findOperationPath(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target, bool $ignoreBoundaryRestrictions): array
92 328
    {
93
        $boundaryCheckPoint = $ignoreBoundaryRestrictions ? null : $this->getPointForBoundaryCheck();
94
95 328
        // Iteratively calculate permutations of intermediate CRSs
96 328
        $candidatePaths = $this->buildTransformationPathsToCRS($source, $target);
97
        usort($candidatePaths, static fn (array $a, array $b) => $a['accuracy'] <=> $b['accuracy'] ?: count($a['path']) <=> count($b['path']));
98 328
99 310
        foreach ($candidatePaths as $candidatePath) {
100 292
            if ($this->validatePath($candidatePath['path'], $target, $boundaryCheckPoint)) {
101
                return $candidatePath['path'];
102
            }
103
        }
104 72
105
        throw new UnknownConversionException('Unable to perform conversion, please file a bug if you think this is incorrect');
106
    }
107
108
    /**
109
     * @param array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}> $candidatePath
110 310
     */
111
    protected function validatePath(array $candidatePath, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target, ?GeographicValue $boundaryCheckPoint): bool
112 310
    {
113 310
        foreach ($candidatePath as $pathStep) {
114
            $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...
115 310
116
            if ($boundaryCheckPoint) { // Filter out operations that only operate outside this point
117 265
                // First, eliminate based on bounding box if possible, that's quicker as only 4 corners
118 265
                if (!$operation['extent'] instanceof BoundingArea) {
119 265
                    $bbox = BoundingArea::createFromExtentCodes($operation['extent'], true);
120 81
                    if (!$bbox->containsPoint($boundaryCheckPoint)) {
121
                        return false;
122
                    }
123
                }
124
125 256
                // Then (or only) check the point is inside the full, complex shape
126 256
                $polygon = $operation['extent'] instanceof BoundingArea ? $operation['extent'] : BoundingArea::createFromExtentCodes($operation['extent']);
127
                if ((!isset($bbox) || $polygon !== $bbox) && !$polygon->containsPoint($boundaryCheckPoint)) {
128
                    return false;
129
                }
130
            }
131 301
132 301
            $operation = CoordinateOperations::getOperationData($pathStep['operation']);
133
            $methodParams = CoordinateOperationMethods::getMethodData($operation['method'])['paramData'];
134
135 301
            // filter out operations that use a 2D CRS as intermediate where this is a 3D point
136 301
            $currentCRS = $this->getCRS();
137 66
            if ($currentCRS instanceof Compound || count($currentCRS->getCoordinateSystem()->getAxes()) === 3) {
138 48
                if ($target instanceof Compound || count($target->getCoordinateSystem()->getAxes()) !== 2) {
139 48
                    $intermediateTarget = CoordinateReferenceSystem::fromSRID($pathStep['in_reverse'] ? $pathStep['source_crs'] : $pathStep['target_crs']);
140
                    if (!$intermediateTarget instanceof Compound && count($intermediateTarget->getCoordinateSystem()->getAxes()) === 2) {
141
                        return false;
142
                    }
143
                }
144
            }
145
146 301
            // filter out operations that require an epoch if we don't have one
147 18
            if (isset($methodParams['transformationReferenceEpoch']) && !$this->getCoordinateEpoch()) {
148
                return false;
149
            }
150 292
151
            $operationParams = CoordinateOperations::getParamData($pathStep['operation']);
152
153 292
            // filter out operations that require a specific epoch
154 9
            if (isset($methodParams['transformationReferenceEpoch']) && $this->getCoordinateEpoch()) {
155 9
                assert($operationParams['transformationReferenceEpoch'] instanceof Time);
156 9
                $pointEpoch = Year::fromDateTime($this->getCoordinateEpoch());
157
                if (!(abs($pointEpoch->subtract($operationParams['transformationReferenceEpoch'])->getValue()) <= 0.001)) {
158
                    return false;
159
                }
160
            }
161
162
            // filter out operations that require a grid file that we don't have, or where boundaries are not being
163 292
            // checked (a formula-based conversion will always return *a* result, outside a grid boundary does not...)
164 292
            foreach ($operationParams as $paramName => $paramValue) {
165 133
                if (str_ends_with($paramName, 'File') && is_string($paramValue) && (!$boundaryCheckPoint || !class_exists($paramValue))) {
166
                    return false;
167
                }
168
            }
169
        }
170 292
171
        return true;
172
    }
173
174
    /**
175
     * Build the set of possible paths that lead from the current CRS to the target CRS.
176
     * @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}>
177 328
     */
178
    protected function buildTransformationPathsToCRS(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target): array
179 328
    {
180 328
        $iterations = 0;
181 328
        $sourceSRID = $source->getSRID();
182 328
        $targetSRID = $target->getSRID();
183 328
        $previousSimplePaths = [[$sourceSRID]];
184
        $cacheKey = $sourceSRID . '|' . $targetSRID;
185 328
186 274
        if (!isset(self::$completePathCache[$cacheKey])) {
187 274
            $transformationsByCRS = self::buildSupportedTransformationsByCRS($source, $target);
188 274
            $transformationsByCRSPair = self::buildSupportedTransformationsByCRSPair($source, $target);
189
            self::$completePathCache[$cacheKey] = [];
190 274
191 274
            while ($iterations <= $this->maxChainDepth) {
192 274
                $completePaths = [];
193
                $simplePaths = [];
194 274
195 274
                foreach ($previousSimplePaths as $simplePath) {
196 274
                    $current = $simplePath[$iterations];
197 247
                    if ($current === $targetSRID) {
198 274
                        $completePaths[] = $simplePath;
199 265
                    } elseif (isset($transformationsByCRS[$current])) {
200 265
                        foreach ($transformationsByCRS[$current] as $next) {
201 265
                            if (!in_array($next, $simplePath, true)) {
202
                                $simplePaths[] = [...$simplePath, $next];
203
                            }
204
                        }
205
                    }
206
                }
207
208 274
                // Then expand each CRS->CRS permutation with the various ways of achieving that (can be lots :/)
209
                $fullPaths = $this->expandSimplePaths($transformationsByCRSPair, $completePaths, $sourceSRID, $targetSRID);
210 274
211 274
                $paths = [];
212 247
                foreach ($fullPaths as $fullPath) {
213
                    $paths[] = ['path' => $fullPath, 'accuracy' => array_sum(array_column($fullPath, 'accuracy'))];
214
                }
215 274
216 274
                $previousSimplePaths = $simplePaths;
217 274
                self::$completePathCache[$cacheKey] = [...self::$completePathCache[$cacheKey], ...$paths];
218
                ++$iterations;
219
            }
220
        }
221 328
222
        return self::$completePathCache[$cacheKey];
223
    }
224
225
    /**
226
     * @param  array<string, array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>> $transformationsByCRSPair
227
     * @param  array<int, array<int, string>>                                                                                                               $simplePaths
228
     * @return array<int, array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>>
229 274
     */
230
    protected function expandSimplePaths(array $transformationsByCRSPair, array $simplePaths, string $fromSRID, string $toSRID): array
231 274
    {
232 274
        $fullPaths = [];
233 247
        foreach ($simplePaths as $simplePath) {
234 247
            $transformationsToMakePath = [[]];
235 247
            $from = array_shift($simplePath);
236
            assert($from === $fromSRID);
237 247
            do {
238 247
                $to = array_shift($simplePath);
239 247
                $wipTransformationsInPath = [];
240 247
                foreach ($transformationsByCRSPair[$from . '|' . $to] ?? [] as $transformation) {
241 247
                    foreach ($transformationsToMakePath as $transformationToMakePath) {
242
                        $wipTransformationsInPath[] = [...$transformationToMakePath, $transformation];
243
                    }
244
                }
245 247
246 247
                $transformationsToMakePath = $wipTransformationsInPath;
247 247
                $from = $to;
248 247
            } while (count($simplePath) > 0);
249
            assert($to === $toSRID);
250 247
251 247
            foreach ($transformationsToMakePath as $transformationToMakePath) {
252
                $fullPaths[] = $transformationToMakePath;
253
            }
254
        }
255 274
256
        return $fullPaths;
257
    }
258
259
    /**
260
     * Boundary polygons are defined as WGS84, so theoretically all that needs to happen is
261
     * to conversion to WGS84 by calling ->convert(). However, that leads quickly to either circularity
262
     * when a conversion is possible, or an exception because not every CRS has a WGS84 transformation
263
     * available to it even when chaining.
264 310
     */
265
    protected function getPointForBoundaryCheck(): ?GeographicValue
266 310
    {
267 46
        if ($this instanceof CompoundPoint) {
268
            $point = $this->getHorizontalPoint();
269 273
        } else {
270
            $point = $this;
271
        }
272
273
        try {
274 310
            // try converting to WGS84 if possible...
275 9
            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\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

275
            return $point->convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->/** @scrutinizer ignore-call */ asGeographicValue();
Loading history...
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

275
            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
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

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