Passed
Push — master ( 3e5121...8c57bf )
by Doug
27:00
created

AutoConversion::findOperationPath()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 5

Importance

Changes 6
Bugs 0 Features 0
Metric Value
cc 5
eloc 10
c 6
b 0
f 0
nc 8
nop 3
dl 0
loc 20
ccs 12
cts 12
cp 1
crap 5
rs 9.6111
1
<?php
2
/**
3
 * PHPCoord.
4
 *
5
 * @author Doug Wright
6
 */
7
declare(strict_types=1);
8
9
namespace PHPCoord\CoordinateOperation;
10
11
use function abs;
12
use function array_column;
13
use function array_merge;
14
use function array_shift;
15
use function array_sum;
16
use function assert;
17
use function class_exists;
18
use function count;
19
use DateTimeImmutable;
20
use function end;
21
use Generator;
22
use function in_array;
23
use PHPCoord\CompoundPoint;
24
use PHPCoord\CoordinateReferenceSystem\CoordinateReferenceSystem;
25
use PHPCoord\CoordinateReferenceSystem\Geographic2D;
26
use PHPCoord\Exception\UnknownConversionException;
27
use PHPCoord\GeocentricPoint;
28
use PHPCoord\GeographicPoint;
29
use PHPCoord\Geometry\BoundingArea;
30
use PHPCoord\Point;
31
use PHPCoord\ProjectedPoint;
32
use PHPCoord\UnitOfMeasure\Time\Year;
33
use function strpos;
34
use function usort;
35
36
/**
37
 * @internal
38
 */
39
trait AutoConversion
40
{
41
    private int $maxChainDepth = 7; // if traits could have constants...
42
43
    protected static array $methodsThatRequireCoordinateEpoch = [ // if traits could have constants...
44
        CoordinateOperationMethods::EPSG_TIME_DEPENDENT_COORDINATE_FRAME_ROTATION_GEOCEN => CoordinateOperationMethods::EPSG_TIME_DEPENDENT_COORDINATE_FRAME_ROTATION_GEOCEN,
45
        CoordinateOperationMethods::EPSG_TIME_DEPENDENT_COORDINATE_FRAME_ROTATION_GEOG2D => CoordinateOperationMethods::EPSG_TIME_DEPENDENT_COORDINATE_FRAME_ROTATION_GEOG2D,
46
        CoordinateOperationMethods::EPSG_TIME_DEPENDENT_COORDINATE_FRAME_ROTATION_GEOG3D => CoordinateOperationMethods::EPSG_TIME_DEPENDENT_COORDINATE_FRAME_ROTATION_GEOG3D,
47
        CoordinateOperationMethods::EPSG_TIME_DEPENDENT_POSITION_VECTOR_TFM_GEOCENTRIC => CoordinateOperationMethods::EPSG_TIME_DEPENDENT_POSITION_VECTOR_TFM_GEOCENTRIC,
48
        CoordinateOperationMethods::EPSG_TIME_DEPENDENT_POSITION_VECTOR_TFM_GEOG2D => CoordinateOperationMethods::EPSG_TIME_DEPENDENT_POSITION_VECTOR_TFM_GEOG2D,
49
        CoordinateOperationMethods::EPSG_TIME_DEPENDENT_POSITION_VECTOR_TFM_GEOG3D => CoordinateOperationMethods::EPSG_TIME_DEPENDENT_POSITION_VECTOR_TFM_GEOG3D,
50
        CoordinateOperationMethods::EPSG_TIME_SPECIFIC_COORDINATE_FRAME_ROTATION_GEOCEN => CoordinateOperationMethods::EPSG_TIME_SPECIFIC_COORDINATE_FRAME_ROTATION_GEOCEN,
51
        CoordinateOperationMethods::EPSG_TIME_SPECIFIC_POSITION_VECTOR_TRANSFORM_GEOCEN => CoordinateOperationMethods::EPSG_TIME_SPECIFIC_POSITION_VECTOR_TRANSFORM_GEOCEN,
52
    ];
53
54
    protected static array $methodsThatRequireASpecificEpoch = [ // if traits could have constants...
55
        CoordinateOperationMethods::EPSG_TIME_SPECIFIC_COORDINATE_FRAME_ROTATION_GEOCEN => CoordinateOperationMethods::EPSG_TIME_SPECIFIC_COORDINATE_FRAME_ROTATION_GEOCEN,
56
        CoordinateOperationMethods::EPSG_TIME_SPECIFIC_POSITION_VECTOR_TRANSFORM_GEOCEN => CoordinateOperationMethods::EPSG_TIME_SPECIFIC_POSITION_VECTOR_TRANSFORM_GEOCEN,
57
    ];
58
59
    private static array $completePathCache = [];
60
61
    private static array $incompletePathCache = [];
62
63
    private static array $transformationsByCRS = [];
64
65
    private static array $transformationsByCRSPair = [];
66
67 452
    public function convert(CoordinateReferenceSystem $to, bool $ignoreBoundaryRestrictions = false): Point
68
    {
69 452
        if ($this->getCRS() == $to) {
70 216
            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.
Loading history...
71
        }
72
73 272
        if (strpos($this->getCRS()->getSRID(), CoordinateReferenceSystem::CRS_SRID_PREFIX_EPSG) !== 0 || strpos($to->getSRID(), CoordinateReferenceSystem::CRS_SRID_PREFIX_EPSG) !== 0) {
74 18
            throw new UnknownConversionException('Automatic conversions are only supported for EPSG CRSs');
75
        }
76
77 263
        $point = $this;
78 263
        $path = $this->findOperationPath($this->getCRS(), $to, $ignoreBoundaryRestrictions);
79
80 254
        foreach ($path as $step) {
81 254
            $target = CoordinateReferenceSystem::fromSRID($step['in_reverse'] ? $step['source_crs'] : $step['target_crs']);
82 254
            $point = $point->performOperation($step['operation'], $target, $step['in_reverse']);
83
        }
84
85 254
        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. Consider adding an additional type-check to rule them out.
Loading history...
86
    }
87
88 263
    protected function findOperationPath(CoordinateReferenceSystem $source, CoordinateReferenceSystem $target, bool $ignoreBoundaryRestrictions): array
89
    {
90 263
        self::buildSupportedTransformationsByCRS();
91 263
        self::buildSupportedTransformationsByCRSPair();
92 263
        $boundaryCheckPoint = $ignoreBoundaryRestrictions ? null : $this->getPointForBoundaryCheck();
93
94
        // Iteratively calculate permutations of intermediate CRSs
95 263
        foreach ($this->buildTransformationPathsToCRS($source, $target) as $candidatePaths) {
96 263
            usort($candidatePaths, static function (array $a, array $b) {
97 179
                return $a['accuracy'] <=> $b['accuracy'];
98 263
            });
99
100 263
            foreach ($candidatePaths as $candidatePath) {
101 263
                if ($this->validatePath($candidatePath['path'], $boundaryCheckPoint)) {
102 254
                    return $candidatePath['path'];
103
                }
104
            }
105
        }
106
107 63
        throw new UnknownConversionException('Unable to perform conversion, please file a bug if you think this is incorrect');
108
    }
109
110 263
    protected function validatePath(array $candidatePath, ?GeographicValue $boundaryCheckPoint): bool
111
    {
112 263
        foreach ($candidatePath as $pathStep) {
113 263
            $operation = CoordinateOperations::getOperationData($pathStep['operation']);
114 263
            if ($boundaryCheckPoint) {
115
                //filter out operations that only operate outside this point
116 227
                $polygon = BoundingArea::createFromExtentCodes($operation['extent_code']);
117 227
                if (!$polygon->containsPoint($boundaryCheckPoint)) {
118 97
                    return false;
119
                }
120
            }
121
122 254
            $operation = CoordinateOperations::getOperationData($pathStep['operation']);
123
124
            //filter out operations that require an epoch if we don't have one
125 254
            if (isset(self::$methodsThatRequireCoordinateEpoch[$operation['method']]) && !$this->getCoordinateEpoch()) {
126 18
                return false;
127
            }
128
129 254
            $params = CoordinateOperationParams::getParamData($pathStep['operation']);
0 ignored issues
show
Bug introduced by
The type PHPCoord\CoordinateOpera...ordinateOperationParams 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...
130
131
            //filter out operations that require a specific epoch
132 254
            if (isset(self::$methodsThatRequireASpecificEpoch[$operation['method']]) && $this->getCoordinateEpoch()) {
133 9
                $pointEpoch = Year::fromDateTime($this->getCoordinateEpoch());
134 9
                if (!(abs($pointEpoch->getValue() - $params['transformationReferenceEpoch']['value']) <= 0.001)) {
135
                    return false;
136
                }
137
            }
138
139
            //filter out operations that require a grid file that we don't have, or where boundaries are not being
140
            //checked (a formula-based conversion will always return *a* result, outside a grid boundary does not...
141 254
            foreach ($params as $param) {
142 254
                if (isset($param['fileProvider']) && (!$boundaryCheckPoint || !class_exists($param['fileProvider']))) {
143 77
                    return false;
144
                }
145
            }
146
        }
147
148 254
        return true;
149
    }
150
151
    /**
152
     * Build the set of possible paths that lead from the current CRS to the target CRS.
153
     */
154 263
    protected function buildTransformationPathsToCRS(CoordinateReferenceSystem $source, CoordinateReferenceSystem $target): Generator
155
    {
156 263
        $iterations = 1;
157 263
        $sourceSRID = $source->getSRID();
158 263
        $targetSRID = $target->getSRID();
159 263
        self::$incompletePathCache[$sourceSRID . '|' . $targetSRID . '|0'] = [[$sourceSRID]];
160
161 263
        while ($iterations < $this->maxChainDepth) {
162 263
            $cacheKey = $sourceSRID . '|' . $targetSRID . '|' . $iterations;
163 263
            $cacheKeyMinus1 = $sourceSRID . '|' . $targetSRID . '|' . ($iterations - 1);
164
165 263
            if (!isset(self::$completePathCache[$cacheKey])) {
166 227
                $completePaths = [];
167
168 227
                $simplePaths = self::$incompletePathCache[$cacheKeyMinus1];
169
170 227
                foreach ($simplePaths as $key => $simplePath) {
171 227
                    $current = end($simplePath);
172 227
                    if ($current === $targetSRID) {
173 218
                        $completePaths[] = $simplePath;
174 227
                    } elseif (isset(static::$transformationsByCRS[$current])) {
0 ignored issues
show
Bug introduced by
Since $transformationsByCRS is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $transformationsByCRS to at least protected.
Loading history...
175 227
                        foreach (static::$transformationsByCRS[$current] as $next) {
176 227
                            if (!in_array($next, $simplePath, true)) {
177 227
                                $simplePaths[] = array_merge($simplePath, [$next]);
178
                            }
179
                        }
180
                    }
181 227
                    unset($simplePaths[$key]);
182
                }
183
184
                // Then expand each CRS->CRS permutation with the various ways of achieving that (can be lots :/)
185 227
                $fullPaths = $this->expandSimplePaths($completePaths, $sourceSRID, $targetSRID);
186
187 227
                $paths = [];
188 227
                foreach ($fullPaths as $fullPath) {
189 218
                    $paths[] = ['path' => $fullPath, 'accuracy' => array_sum(array_column($fullPath, 'accuracy'))];
190
                }
191
192 227
                self::$incompletePathCache[$cacheKey] = $simplePaths;
193 227
                self::$completePathCache[$cacheKey] = $paths;
194
            }
195
196 263
            ++$iterations;
197 263
            yield self::$completePathCache[$cacheKey];
198
        }
199 63
    }
200
201 227
    protected function expandSimplePaths(array $simplePaths, string $fromSRID, string $toSRID): array
202
    {
203 227
        $fullPaths = [];
204 227
        foreach ($simplePaths as $simplePath) {
205 218
            $transformationsToMakePath = [[]];
206 218
            $from = array_shift($simplePath);
207 218
            assert($from === $fromSRID);
208
            do {
209 218
                $to = array_shift($simplePath);
210 218
                $wipTransformationsInPath = [];
211 218
                foreach (static::$transformationsByCRSPair[$from . '|' . $to] ?? [] as $transformation) {
0 ignored issues
show
Bug introduced by
Since $transformationsByCRSPair is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $transformationsByCRSPair to at least protected.
Loading history...
212 218
                    foreach ($transformationsToMakePath as $transformationToMakePath) {
213 218
                        $wipTransformationsInPath[] = array_merge($transformationToMakePath, [$transformation]);
214
                    }
215
                }
216
217 218
                $transformationsToMakePath = $wipTransformationsInPath;
218 218
                $from = $to;
219 218
            } while (count($simplePath) > 0);
220 218
            assert($to === $toSRID);
221
222 218
            foreach ($transformationsToMakePath as $transformationToMakePath) {
223 218
                $fullPaths[] = $transformationToMakePath;
224
            }
225
        }
226
227 227
        return $fullPaths;
228
    }
229
230
    /**
231
     * Boundary polygons are defined as WGS84, so theoretically all that needs to happen is
232
     * to conversion to WGS84 by calling ->convert(). However, that leads quickly to either circularity
233
     * when a conversion is possible, or an exception because not every CRS has a WGS84 transformation
234
     * available to it even when chaining.
235
     */
236 245
    protected function getPointForBoundaryCheck(): ?GeographicValue
237
    {
238 245
        if ($this instanceof CompoundPoint) {
239 37
            $point = $this->getHorizontalPoint();
240
        } else {
241 208
            $point = $this;
242
        }
243
244
        try {
245
            // try converting to WGS84 if possible...
246 245
            return $point->convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->asGeographicValue();
0 ignored issues
show
Bug introduced by
The method convert() does not exist on PHPCoord\Point. It seems like you code against a sub-type of said class. However, the method does not exist in PHPCoord\VerticalPoint. Are you sure you never get one of those? ( Ignorable by Annotation )

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

246
            return $point->/** @scrutinizer ignore-call */ convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->asGeographicValue();
Loading history...
Bug introduced by
The method asGeographicValue() does not exist on PHPCoord\Point. It seems like you code against a sub-type of PHPCoord\Point such as PHPCoord\GeographicPoint or PHPCoord\GeocentricPoint. ( Ignorable by Annotation )

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

246
            return $point->convert(Geographic2D::fromSRID(Geographic2D::EPSG_WGS_84), true)->/** @scrutinizer ignore-call */ asGeographicValue();
Loading history...
247
        } catch (UnknownConversionException $e) {
248
            /*
249
             * If Projected then either the point is inside the boundary by definition
250
             * or the user is deliberately exceeding the safe zone so safe to make a no-op either way.
251
             */
252
            if ($point instanceof ProjectedPoint) {
253
                return null;
254
            }
255
256
            /*
257
             * Otherwise, compensate for non-Greenwich Prime Meridian, but otherwise assume that coordinates
258
             * are interchangeable between the actual CRS and WGS84. Boundaries are only defined to the nearest
259
             * ≈1km so the error bound should be acceptable within the area of interest
260
             */
261
            if ($point instanceof GeographicPoint) {
262
                return new GeographicValue($point->getLatitude(), $point->getLongitude()->subtract($point->getCRS()->getDatum()->getPrimeMeridian()->getGreenwichLongitude()), null, $point->getCRS()->getDatum());
263
            }
264
265
            if ($point instanceof GeocentricPoint) {
266
                $asGeographic = $point->asGeographicValue();
267
268
                return new GeographicValue($asGeographic->getLatitude(), $asGeographic->getLongitude()->subtract($asGeographic->getDatum()->getPrimeMeridian()->getGreenwichLongitude()), null, $asGeographic->getDatum());
269
            }
270
        }
271
    }
272
273 263
    protected static function buildSupportedTransformationsByCRS(): void
274
    {
275 263
        if (!static::$transformationsByCRS) {
0 ignored issues
show
Bug Best Practice introduced by
The expression static::transformationsByCRS of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
Bug introduced by
Since $transformationsByCRS is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $transformationsByCRS to at least protected.
Loading history...
276 27
            foreach (CRSTransformations::getSupportedTransformations() as $transformation) {
0 ignored issues
show
Bug introduced by
The type PHPCoord\CoordinateOperation\CRSTransformations 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...
277 27
                if (!isset(static::$transformationsByCRS[$transformation['source_crs']][$transformation['target_crs']])) {
278 27
                    static::$transformationsByCRS[$transformation['source_crs']][$transformation['target_crs']] = $transformation['target_crs'];
279
                }
280 27
                if ($transformation['reversible'] && !isset(static::$transformationsByCRS[$transformation['target_crs']][$transformation['source_crs']])) {
281 27
                    static::$transformationsByCRS[$transformation['target_crs']][$transformation['source_crs']] = $transformation['source_crs'];
282
                }
283
            }
284
        }
285 263
    }
286
287 263
    protected static function buildSupportedTransformationsByCRSPair(): void
288
    {
289 263
        if (!static::$transformationsByCRSPair) {
0 ignored issues
show
Bug introduced by
Since $transformationsByCRSPair is declared private, accessing it with static will lead to errors in possible sub-classes; you can either use self, or increase the visibility of $transformationsByCRSPair to at least protected.
Loading history...
Bug Best Practice introduced by
The expression static::transformationsByCRSPair of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
290 27
            foreach (CRSTransformations::getSupportedTransformations() as $key => $transformation) {
291 27
                if (!isset(static::$transformationsByCRSPair[$transformation['source_crs'] . '|' . $transformation['target_crs']][$key])) {
292 27
                    static::$transformationsByCRSPair[$transformation['source_crs'] . '|' . $transformation['target_crs']][$key] = $transformation;
293 27
                    static::$transformationsByCRSPair[$transformation['source_crs'] . '|' . $transformation['target_crs']][$key]['in_reverse'] = false;
294
                }
295 27
                if ($transformation['reversible'] && !isset(static::$transformationsByCRSPair[$transformation['target_crs'] . '|' . $transformation['source_crs']][$key])) {
296 27
                    static::$transformationsByCRSPair[$transformation['target_crs'] . '|' . $transformation['source_crs']][$key] = $transformation;
297 27
                    static::$transformationsByCRSPair[$transformation['target_crs'] . '|' . $transformation['source_crs']][$key]['in_reverse'] = true;
298
                }
299
            }
300
        }
301 263
    }
302
303
    abstract public function getCRS(): CoordinateReferenceSystem;
304
305
    abstract public function getCoordinateEpoch(): ?DateTimeImmutable;
306
307
    abstract protected function performOperation(string $srid, CoordinateReferenceSystem $to, bool $inReverse): Point;
308
}
309