Passed
Push — master ( 8522d6...6acfa8 )
by Doug
60:39
created

AutoConversion::validatePath()   F

Complexity

Conditions 26
Paths 129

Size

Total Lines 61
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 26.0171

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 33
cts 34
cp 0.9706
crap 26.0171
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
     * @return array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>
79
     */
80
    protected function findOperationPath(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target, bool $ignoreBoundaryRestrictions): array
81 2002
    {
82
        $boundaryCheckPoint = $ignoreBoundaryRestrictions ? null : $this->getPointForBoundaryCheck();
83
84 2195
        // Iteratively calculate permutations of intermediate CRSs
85
        $candidatePaths = $this->buildTransformationPathsToCRS($source, $target);
86 2195
        usort($candidatePaths, static fn (array $a, array $b) => $a['accuracy'] <=> $b['accuracy'] ?: count($a['path']) <=> count($b['path']));
87 2195
88 2195
        foreach ($candidatePaths as $candidatePath) {
89
            if ($this->validatePath($candidatePath['path'], $target, $boundaryCheckPoint)) {
90 2159
                return $candidatePath['path'];
91 2159
            }
92 110
        }
93
94
        throw new UnknownConversionException('Unable to perform conversion, please file a bug if you think this is incorrect');
95
    }
96 2186
97 2186
    /**
98
     * @param array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}> $candidatePath
99
     */
100 2186
    protected function validatePath(array $candidatePath, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target, ?GeographicValue $boundaryCheckPoint): bool
101 2186
    {
102 61
        foreach ($candidatePath as $pathStep) {
103 43
            $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...
104 43
105 2
            if ($boundaryCheckPoint) { // Filter out operations that only operate outside this point
106
                // First, eliminate based on bounding box if possible, that's quicker as only 4 corners
107
                if (!$operation['extent'] instanceof BoundingArea) {
108
                    $bbox = BoundingArea::createFromExtentCodes($operation['extent'], true);
109
                    if (!$bbox->containsPoint($boundaryCheckPoint)) {
110
                        return false;
111 2186
                    }
112 98
                }
113
114
                // Then (or only) check the point is inside the full, complex shape
115 2177
                $polygon = $operation['extent'] instanceof BoundingArea ? $operation['extent'] : BoundingArea::createFromExtentCodes($operation['extent']);
116
                if ((!isset($bbox) || $polygon !== $bbox) && !$polygon->containsPoint($boundaryCheckPoint)) {
117
                    return false;
118 2177
                }
119 9
            }
120 9
121
            $operation = CoordinateOperations::getOperationData($pathStep['operation']);
122
            $methodParams = CoordinateOperationMethods::getMethodData($operation['method'])['paramData'];
123
124
            // filter out operations that use a 2D CRS as intermediate where this is a 3D point
125
            $currentCRS = $this->getCRS();
126
            if ($currentCRS instanceof Compound || count($currentCRS->getCoordinateSystem()->getAxes()) === 3) {
127 2177
                if ($target instanceof Compound || count($target->getCoordinateSystem()->getAxes()) !== 2) {
128 2177
                    $intermediateTarget = CoordinateReferenceSystem::fromSRID($pathStep['in_reverse'] ? $pathStep['source_crs'] : $pathStep['target_crs']);
129 166
                    if (!$intermediateTarget instanceof Compound && count($intermediateTarget->getCoordinateSystem()->getAxes()) === 2) {
130
                        return false;
131
                    }
132
                }
133
            }
134 2177
135
            // filter out operations that require an epoch if we don't have one
136
            if (isset($methodParams['transformationReferenceEpoch']) && !$this->getCoordinateEpoch()) {
137
                return false;
138
            }
139
140 2213
            $operationParams = CoordinateOperations::getParamData($pathStep['operation']);
141
142 2213
            // filter out operations that require a specific epoch
143 2213
            if (isset($methodParams['transformationReferenceEpoch']) && $this->getCoordinateEpoch()) {
144 2213
                assert($operationParams['transformationReferenceEpoch'] instanceof Time);
145 2213
                $pointEpoch = Year::fromDateTime($this->getCoordinateEpoch());
146 2213
                if (!(abs($pointEpoch->subtract($operationParams['transformationReferenceEpoch'])->getValue()) <= 0.001)) {
147
                    return false;
148 2213
                }
149 413
            }
150 413
151 413
            // filter out operations that require a grid file that we don't have, or where boundaries are not being
152
            // checked (a formula-based conversion will always return *a* result, outside a grid boundary does not...)
153 413
            foreach ($operationParams as $paramName => $paramValue) {
154 413
                if (str_ends_with($paramName, 'File') && is_string($paramValue) && (!$boundaryCheckPoint || !class_exists($paramValue))) {
155 413
                    return false;
156
                }
157 413
            }
158 413
        }
159 413
160 386
        return true;
161 413
    }
162 404
163 404
    /**
164 404
     * Build the set of possible paths that lead from the current CRS to the target CRS.
165
     * @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}>
166
     */
167
    protected function buildTransformationPathsToCRS(Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $source, Compound|Geocentric|Geographic2D|Geographic3D|Projected|Vertical $target): array
168
    {
169
        $iterations = 0;
170
        $sourceSRID = $source->getSRID();
171 413
        $targetSRID = $target->getSRID();
172
        $previousSimplePaths = [[$sourceSRID]];
173 413
        $cacheKey = $sourceSRID . '|' . $targetSRID;
174 413
175 386
        if (!isset(self::$completePathCache[$cacheKey])) {
176
            $transformationsByCRS = self::buildSupportedTransformationsByCRS($source, $target);
177
            $transformationsByCRSPair = self::buildSupportedTransformationsByCRSPair($source, $target);
178 413
            self::$completePathCache[$cacheKey] = [];
179 413
180 413
            while ($iterations <= $this->maxChainDepth) {
181
                $completePaths = [];
182
                $simplePaths = [];
183
184 2213
                foreach ($previousSimplePaths as $simplePath) {
185
                    $current = $simplePath[$iterations];
186
                    if ($current === $targetSRID) {
187 413
                        $completePaths[] = $simplePath;
188
                    } elseif (isset($transformationsByCRS[$current])) {
189 413
                        foreach ($transformationsByCRS[$current] as $next) {
190 413
                            if (!in_array($next, $simplePath, true)) {
191 386
                                $simplePaths[] = [...$simplePath, $next];
192 386
                            }
193 386
                        }
194
                    }
195 386
                }
196 386
197 386
                // Then expand each CRS->CRS permutation with the various ways of achieving that (can be lots :/)
198 386
                $fullPaths = $this->expandSimplePaths($transformationsByCRSPair, $completePaths, $sourceSRID, $targetSRID);
199 386
200
                $paths = [];
201
                foreach ($fullPaths as $fullPath) {
202
                    $paths[] = ['path' => $fullPath, 'accuracy' => array_sum(array_column($fullPath, 'accuracy'))];
203 386
                }
204 386
205 386
                $previousSimplePaths = $simplePaths;
206 386
                self::$completePathCache[$cacheKey] = [...self::$completePathCache[$cacheKey], ...$paths];
207
                ++$iterations;
208 386
            }
209 386
        }
210
211
        return self::$completePathCache[$cacheKey];
212
    }
213 413
214
    /**
215
     * @param  array<string, array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>> $transformationsByCRSPair
216
     * @param  array<int, array<int, string>>                                                                                                               $simplePaths
217
     * @return array<int, array<int, array{operation: string, name: string, source_crs: string, target_crs: string, accuracy: float, in_reverse: bool}>>
218
     */
219
    protected function expandSimplePaths(array $transformationsByCRSPair, array $simplePaths, string $fromSRID, string $toSRID): array
220
    {
221
        $fullPaths = [];
222 2195
        foreach ($simplePaths as $simplePath) {
223
            $transformationsToMakePath = [[]];
224 2195
            $from = array_shift($simplePath);
225 50
            assert($from === $fromSRID);
226
            do {
227 2158
                $to = array_shift($simplePath);
228
                $wipTransformationsInPath = [];
229
                foreach ($transformationsByCRSPair[$from . '|' . $to] ?? [] as $transformation) {
230
                    foreach ($transformationsToMakePath as $transformationToMakePath) {
231
                        $wipTransformationsInPath[] = [...$transformationToMakePath, $transformation];
232 2195
                    }
233 1935
                }
234
235
                $transformationsToMakePath = $wipTransformationsInPath;
236
                $from = $to;
237
            } while (count($simplePath) > 0);
238 1935
            assert($to === $toSRID);
239
240
            foreach ($transformationsToMakePath as $transformationToMakePath) {
241
                $fullPaths[] = $transformationToMakePath;
242
            }
243
        }
244
245
        return $fullPaths;
246
    }
247 1935
248 1926
    /**
249
     * Boundary polygons are defined as WGS84, so theoretically all that needs to happen is
250
     * to conversion to WGS84 by calling ->convert(). However, that leads quickly to either circularity
251 9
     * when a conversion is possible, or an exception because not every CRS has a WGS84 transformation
252 9
     * available to it even when chaining.
253
     */
254 9
    protected function getPointForBoundaryCheck(): ?GeographicValue
255
    {
256
        if ($this instanceof CompoundPoint) {
257
            $point = $this->getHorizontalPoint();
258
        } else {
259 413
            $point = $this;
260
        }
261 413
262 413
        try {
263 413
            // try converting to WGS84 if possible...
264 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\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

264
            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

264
            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

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