Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #2402: See if we can make the aStar algorithm support spatial points #2589

Merged
merged 2 commits into from
Apr 21, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 79 additions & 6 deletions core/src/main/java/apoc/algo/PathFinding.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.neo4j.procedure.Description;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.Procedure;
import org.neo4j.values.storable.PointValue;

import java.util.Collections;
import java.util.Map;
Expand All @@ -19,12 +20,78 @@

public class PathFinding {

public static class GeoEstimateEvaluatorPointCustom implements EstimateEvaluator<Double> {

// -- from org.neo4j.graphalgo.impl.util.GeoEstimateEvaluator
private static final double EARTH_RADIUS = 6371 * 1000; // Meters
private Node cachedGoal;
private final String pointPropertyKey;
private double[] cachedGoalCoordinates;

public GeoEstimateEvaluatorPointCustom(String pointPropertyKey) {
this.pointPropertyKey = pointPropertyKey;
}

@Override
public Double getCost( Node node, Node goal) {
double[] nodeCoordinates = getCoordinates(node);
if ( cachedGoal == null || !cachedGoal.equals( goal ) )
{
cachedGoalCoordinates = getCoordinates(goal);
cachedGoal = goal;
}
return distance(nodeCoordinates[0], nodeCoordinates[1],
cachedGoalCoordinates[0], cachedGoalCoordinates[1] );
}

private static double distance( double latitude1, double longitude1,
double latitude2, double longitude2 ) {
latitude1 = Math.toRadians( latitude1 );
longitude1 = Math.toRadians( longitude1 );
latitude2 = Math.toRadians( latitude2 );
longitude2 = Math.toRadians( longitude2 );
double cLa1 = Math.cos( latitude1 );
double xA = EARTH_RADIUS * cLa1 * Math.cos( longitude1 );
double yA = EARTH_RADIUS * cLa1 * Math.sin( longitude1 );
double zA = EARTH_RADIUS * Math.sin( latitude1 );
double cLa2 = Math.cos( latitude2 );
double xB = EARTH_RADIUS * cLa2 * Math.cos( longitude2 );
double yB = EARTH_RADIUS * cLa2 * Math.sin( longitude2 );
double zB = EARTH_RADIUS * Math.sin( latitude2 );
return Math.sqrt( ( xA - xB ) * ( xA - xB ) + ( yA - yB )
* ( yA - yB ) + ( zA - zB ) * ( zA - zB ) );
}
// -- end from org.neo4j.graphalgo.impl.util.GeoEstimateEvaluator

private double[] getCoordinates(Node node) {
return ((PointValue) node.getProperty(pointPropertyKey)).coordinate();
}
}

@Context
public GraphDatabaseService db;

@Context
public Transaction tx;

@Procedure
@Description("apoc.algo.aStarWithPoint(startNode, endNode, 'relTypesAndDirs', 'distance','pointProp') - " +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have started to be stricter about what will be added into APOC core. Would it be possible to add this into APOC full instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes sure, I just moved it in full

"equivalent to apoc.algo.aStar but accept a Point type as a pointProperty instead of Number types as latitude and longitude properties")
public Stream<WeightedPathResult> aStarWithPoint(
@Name("startNode") Node startNode,
@Name("endNode") Node endNode,
@Name("relationshipTypesAndDirections") String relTypesAndDirs,
@Name("weightPropertyName") String weightPropertyName,
@Name("pointPropertyName") String pointPropertyName) {

PathFinder<WeightedPath> algo = GraphAlgoFactory.aStar(
new BasicEvaluationContext(tx, db),
buildPathExpander(relTypesAndDirs),
CommonEvaluators.doubleCostEvaluator(weightPropertyName),
new GeoEstimateEvaluatorPointCustom(pointPropertyName));
return WeightedPathResult.streamWeightedPathResult(startNode, endNode, algo);
}

@Procedure
@Description("apoc.algo.aStar(startNode, endNode, 'KNOWS|<WORKS_WITH|IS_MANAGER_OF>', 'distance','lat','lon') " +
"YIELD path, weight - run A* with relationship property name as cost function")
Expand All @@ -45,8 +112,8 @@ public Stream<WeightedPathResult> aStar(
}

@Procedure
@Description("apoc.algo.aStar(startNode, endNode, 'KNOWS|<WORKS_WITH|IS_MANAGER_OF>', {weight:'dist',default:10," +
"x:'lon',y:'lat'}) YIELD path, weight - run A* with relationship property name as cost function")
@Description("apoc.algo.aStarConfig(startNode, endNode, 'KNOWS|<WORKS_WITH|IS_MANAGER_OF>', {weight:'dist',default:10," +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch that this description was wrong 👍

"x:'lon',y:'lat', pointPropName:'point'}) YIELD path, weight - run A* with relationship property name as cost function")
public Stream<WeightedPathResult> aStarConfig(
@Name("startNode") Node startNode,
@Name("endNode") Node endNode,
Expand All @@ -56,14 +123,20 @@ public Stream<WeightedPathResult> aStarConfig(
config = config == null ? Collections.emptyMap() : config;
String relationshipCostPropertyKey = config.getOrDefault("weight", "distance").toString();
double defaultCost = ((Number) config.getOrDefault("default", Double.MAX_VALUE)).doubleValue();
String latPropertyName = config.getOrDefault("y", "latitude").toString();
String lonPropertyName = config.getOrDefault("x", "longitude").toString();

String pointPropertyName = (String) config.get("pointPropName");
final EstimateEvaluator<Double> estimateEvaluator;
if (pointPropertyName != null) {
estimateEvaluator = new GeoEstimateEvaluatorPointCustom(pointPropertyName);
} else {
String latPropertyName = config.getOrDefault("y", "latitude").toString();
String lonPropertyName = config.getOrDefault("x", "longitude").toString();
estimateEvaluator = CommonEvaluators.geoEstimateEvaluator(latPropertyName, lonPropertyName);
}
PathFinder<WeightedPath> algo = GraphAlgoFactory.aStar(
new BasicEvaluationContext(tx, db),
buildPathExpander(relTypesAndDirs),
CommonEvaluators.doubleCostEvaluator(relationshipCostPropertyKey, defaultCost),
CommonEvaluators.geoEstimateEvaluator(latPropertyName, lonPropertyName));
estimateEvaluator);
return WeightedPathResult.streamWeightedPathResult(startNode, endNode, algo);
}

Expand Down
30 changes: 26 additions & 4 deletions core/src/test/java/apoc/algo/PathFindingTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ public class PathFindingTest {
"(b)-[:ROAD {d:20}]->(c), " +
"(c)-[:ROAD {d:30}]->(d), " +
"(a)-[:ROAD {d:20}]->(c) ";
private static final String SETUP_GEO = "CREATE (b:City {name:'Berlin',lat:52.52464,lon:13.40514})\n" +
"CREATE (m:City {name:'München',lat:48.1374,lon:11.5755})\n" +
"CREATE (f:City {name:'Frankfurt',lat:50.1167,lon:8.68333})\n" +
"CREATE (h:City {name:'Hamburg',lat:53.554423,lon:9.994583})\n" +
private static final String SETUP_GEO = "CREATE (b:City {name:'Berlin', coords: point({latitude:52.52464,longitude:13.40514}), lat:52.52464,lon:13.40514})\n" +
"CREATE (m:City {name:'München', coords: point({latitude:48.1374,longitude:11.5755, height: 1}), lat:48.1374,lon:11.5755})\n" +
"CREATE (f:City {name:'Frankfurt',coords: point({latitude:50.1167,longitude:8.68333, height: 1}), lat:50.1167,lon:8.68333})\n" +
"CREATE (h:City {name:'Hamburg', coords: point({latitude:53.554423,longitude:9.994583, height: 1}), lat:53.554423,lon:9.994583})\n" +
"CREATE (b)-[:DIRECT {dist:255.64*1000}]->(h)\n" +
"CREATE (b)-[:DIRECT {dist:504.47*1000}]->(m)\n" +
"CREATE (b)-[:DIRECT {dist:424.12*1000}]->(f)\n" +
Expand All @@ -76,6 +76,17 @@ public void testAStar() {
);
}

@Test
public void testAStarWithPoint() {
db.executeTransactionally(SETUP_GEO);
testResult(db,
"MATCH (from:City {name:'München'}), (to:City {name:'Hamburg'}) " +
"CALL apoc.algo.aStarWithPoint(from, to, 'DIRECT', 'dist', 'coords') yield path, weight " +
"RETURN path, weight" ,
this::assertAStarResult
);
}

@Test
public void testAStarConfig() {
db.executeTransactionally(SETUP_GEO);
Expand All @@ -87,6 +98,17 @@ public void testAStarConfig() {
);
}

@Test
public void testAStarConfigWithPoint() {
db.executeTransactionally(SETUP_GEO);
testResult(db,
"MATCH (from:City {name:'München'}), (to:City {name:'Hamburg'}) " +
"CALL apoc.algo.aStarConfig(from, to, 'DIRECT', {pointPropName:'coords', weight:'dist', default:100}) yield path, weight " +
"RETURN path, weight" ,
this::assertAStarResult
);
}

private void assertAStarResult(Result r) {
assertEquals(true, r.hasNext());
Map<String, Object> row = r.next();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ APOC exposes some built in path-finding functions that Neo4j brings along.
| apoc.algo.dijkstra(startNode, endNode, 'KNOWS\|<WORKS_WITH\|IS_MANAGER_OF>', 'distance') YIELD path, weight | run dijkstra with relationship property name as cost function
| apoc.algo.dijkstraWithDefaultWeight(startNode, endNode, 'KNOWS\|<WORKS_WITH\|IS_MANAGER_OF>', 'distance', 10) YIELD path, weight | run dijkstra with relationship property name as cost function and a default weight if the property does not exist
| apoc.algo.aStar(startNode, endNode, 'KNOWS\|<WORKS_WITH\|IS_MANAGER_OF>', 'distance','lat','lon') YIELD path, weight | run A* with relationship property name as cost function
| apoc.algo.aStar(startNode, endNode, 'KNOWS\|<WORKS_WITH\|IS_MANAGER_OF>', {weight:'dist',default:10, x:'lon',y:'lat'}) YIELD path, weight | run A* with relationship property name as cost function
| apoc.algo.aStarWithPoint(startNode, endNode, 'relTypesAndDirs', 'weightPropertyName','pointPropertyName') - equivalent to apoc.algo.aStar but accept a Point type as a pointProperty instead of Number types as latitude and longitude properties
| apoc.algo.aStarConfig(startNode, endNode, 'KNOWS|<WORKS_WITH|IS_MANAGER_OF>', {weight:'dist',default:10, x:'lon',y:'lat',pointPropName:'point'}) YIELD path, weight - run A* with relationship property name as cost function
| apoc.algo.allSimplePaths(startNode, endNode, 'KNOWS\|<WORKS_WITH\|IS_MANAGER_OF>', 5) YIELD path, weight | run allSimplePaths with relationships given and maxNodes
| apoc.stats.degrees(relTypesDirections) yield type, direction, total, min, max, mean, p50, p75, p90, p95, p99, p999 | compute degree distribution in parallel
|===
Expand All @@ -23,4 +24,152 @@ Example: find the weighted shortest path based on relationship property `d` from
MATCH (from:Loc{name:'A'}), (to:Loc{name:'D'})
CALL apoc.algo.dijkstra(from, to, 'ROAD', 'd') yield path as path, weight as weight
RETURN path, weight
----

==== apoc.algo.aStarConfig

Given this dataset:

[source,cypher]
----
CREATE (b:City {name:'Berlin', coords: point({latitude:52.52464,longitude:13.40514}), lat:52.52464,lon:13.40514})
CREATE (m:City {name:'München', coords: point({latitude:48.1374,longitude:11.5755}), lat:48.1374,lon:11.5755})
CREATE (f:City {name:'Frankfurt',coords: point({latitude:50.1167,longitude:8.68333}), lat:50.1167,lon:8.68333})
CREATE (h:City {name:'Hamburg', coords: point({latitude:53.554423,longitude:9.994583}), lat:53.554423,lon:9.994583})
CREATE (b)-[:DIRECT {dist:255.64*1000}]->(h)
CREATE (b)-[:DIRECT {dist:504.47*1000}]->(m)
CREATE (b)-[:DIRECT {dist:424.12*1000}]->(f)
CREATE (f)-[:DIRECT {dist:304.28*1000}]->(m)
CREATE (f)-[:DIRECT {dist:393.15*1000}]->(h)
----

we can execute (leveraging on 'lat' and 'lon' node properties, which are Numbers,
on 'dist' relationship property and with default cost 100):

[source,cypher]
----
MATCH (from:City {name:'München'}), (to:City {name:'Hamburg'})
CALL apoc.algo.aStarConfig(from, to, 'DIRECT', {weight:'dist',y:'lat', x:'lon',default:100})
YIELD weight, path
RETURN weight, path
----

.Results
[opts="header"]
|===
| weight | path
| 697430.0 |
[source,json]
----
{
"start": {
"identity": 1520006,
"labels": [
"City"
],
"properties": {
"name": "München",
"lon": 11.5755,
"lat": 48.1374,
"coords": point({srid:4326, x:11.5755, y:48.1374})
}
},
"end": {
"identity": 1520008,
"labels": [
"City"
],
"properties": {
"name": "Hamburg",
"lon": 9.994583,
"lat": 53.554423,
"coords": point({srid:4326, x:9.994583, y:53.554423})
}
},
"segments": [
{
"start": {
"identity": 1520006,
"labels": [
"City"
],
"properties": {
"name": "München",
"lon": 11.5755,
"lat": 48.1374,
"coords": point({srid:4326, x:11.5755, y:48.1374})
}
},
"relationship": {
"identity": 3,
"start": 1520007,
"end": 1520006,
"type": "DIRECT",
"properties": {
"dist": 304280.0
}
},
"end": {
"identity": 1520007,
"labels": [
"City"
],
"properties": {
"name": "Frankfurt",
"lon": 8.68333,
"lat": 50.1167,
"coords": point({srid:4326, x:8.68333, y:50.1167})
}
}
},
{
"start": {
"identity": 1520007,
"labels": [
"City"
],
"properties": {
"name": "Frankfurt",
"lon": 8.68333,
"lat": 50.1167,
"coords": point({srid:4326, x:8.68333, y:50.1167})
}
},
"relationship": {
"identity": 4,
"start": 1520007,
"end": 1520008,
"type": "DIRECT",
"properties": {
"dist": 393150.0
}
},
"end": {
"identity": 1520008,
"labels": [
"City"
],
"properties": {
"name": "Hamburg",
"lon": 9.994583,
"lat": 53.554423,
"coords": point({srid:4326, x:9.994583, y:53.554423})
}
}
}
],
"length": 2.0
}
----
|===

or equivalently, with the same result, leveraging on 'coords' node property, which is a Point, with the same other configs.
Note that in case of a 3d-coordinate, the procedure will pick only the x and y or the longitude and latitude values.

[source,cypher]
----
MATCH (from:City {name:'München'}), (to:City {name:'Hamburg'})
CALL apoc.algo.aStarConfig(from, to, 'DIRECT', {pointPropName:'coords', weight:'dist', default:100})
YIELD weight, path
RETURN weight, path
----