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

AutoConversion::validatePath()   F

Complexity

Conditions 26
Paths 129

Size

Total Lines 61
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 26.0204

Importance

Changes 6
Bugs 0 Features 0
Metric Value
cc 26
eloc 30
c 6
b 0
f 0
nc 129
nop 3
dl 0
loc 61
ccs 31
cts 32
cp 0.9688
crap 26.0204
rs 3.925

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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