001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.validation.tests;
003
004import static org.openstreetmap.josm.tools.I18n.marktr;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.util.ArrayList;
008import java.util.Arrays;
009import java.util.Collection;
010import java.util.HashMap;
011import java.util.HashSet;
012import java.util.List;
013import java.util.Locale;
014import java.util.Map;
015import java.util.Map.Entry;
016import java.util.Objects;
017import java.util.Set;
018import java.util.stream.Collectors;
019import java.util.stream.Stream;
020
021import org.openstreetmap.josm.data.coor.EastNorth;
022import org.openstreetmap.josm.data.coor.LatLon;
023import org.openstreetmap.josm.data.osm.Node;
024import org.openstreetmap.josm.data.osm.OsmPrimitive;
025import org.openstreetmap.josm.data.osm.Relation;
026import org.openstreetmap.josm.data.osm.RelationMember;
027import org.openstreetmap.josm.data.osm.Way;
028import org.openstreetmap.josm.data.preferences.DoubleProperty;
029import org.openstreetmap.josm.data.validation.Severity;
030import org.openstreetmap.josm.data.validation.Test;
031import org.openstreetmap.josm.data.validation.TestError;
032import org.openstreetmap.josm.tools.Geometry;
033import org.openstreetmap.josm.tools.Logging;
034import org.openstreetmap.josm.tools.Pair;
035import org.openstreetmap.josm.tools.SubclassFilteredCollection;
036import org.openstreetmap.josm.tools.Utils;
037
038/**
039 * Performs validation tests on addresses (addr:housenumber) and associatedStreet relations.
040 * @since 5644
041 */
042public class Addresses extends Test {
043
044    protected static final int HOUSE_NUMBER_WITHOUT_STREET = 2601;
045    protected static final int DUPLICATE_HOUSE_NUMBER = 2602;
046    protected static final int MULTIPLE_STREET_NAMES = 2603;
047    protected static final int MULTIPLE_STREET_RELATIONS = 2604;
048    protected static final int HOUSE_NUMBER_TOO_FAR = 2605;
049
050    protected static final DoubleProperty MAX_DUPLICATE_DISTANCE = new DoubleProperty("validator.addresses.max_duplicate_distance", 200.0);
051    protected static final DoubleProperty MAX_STREET_DISTANCE = new DoubleProperty("validator.addresses.max_street_distance", 200.0);
052
053    // CHECKSTYLE.OFF: SingleSpaceSeparator
054    protected static final String ADDR_HOUSE_NUMBER  = "addr:housenumber";
055    protected static final String ADDR_INTERPOLATION = "addr:interpolation";
056    protected static final String ADDR_NEIGHBOURHOOD = "addr:neighbourhood";
057    protected static final String ADDR_PLACE         = "addr:place";
058    protected static final String ADDR_STREET        = "addr:street";
059    protected static final String ADDR_CITY          = "addr:city";
060    protected static final String ADDR_UNIT          = "addr:unit";
061    protected static final String ADDR_FLATS         = "addr:flats";
062    protected static final String ADDR_HOUSE_NAME    = "addr:housename";
063    protected static final String ADDR_POSTCODE      = "addr:postcode";
064    protected static final String ASSOCIATED_STREET  = "associatedStreet";
065    // CHECKSTYLE.ON: SingleSpaceSeparator
066
067    private Map<String, Collection<OsmPrimitive>> knownAddresses;
068    private Set<String> ignoredAddresses;
069
070    /**
071     * Constructor
072     */
073    public Addresses() {
074        super(tr("Addresses"), tr("Checks for errors in addresses and associatedStreet relations."));
075    }
076
077    protected List<Relation> getAndCheckAssociatedStreets(OsmPrimitive p) {
078        final List<Relation> list = p.referrers(Relation.class)
079                .filter(r -> r.hasTag("type", ASSOCIATED_STREET))
080                .collect(Collectors.toList());
081        if (list.size() > 1) {
082            Severity level;
083            // warning level only if several relations have different names, see #10945
084            final String name = list.get(0).get("name");
085            if (name == null || SubclassFilteredCollection.filter(list, r -> r.hasTag("name", name)).size() < list.size()) {
086                level = Severity.WARNING;
087            } else {
088                level = Severity.OTHER;
089            }
090            List<OsmPrimitive> errorList = new ArrayList<>(list);
091            errorList.add(0, p);
092            errors.add(TestError.builder(this, level, MULTIPLE_STREET_RELATIONS)
093                    .message(tr("Multiple associatedStreet relations"))
094                    .primitives(errorList)
095                    .build());
096        }
097        return list;
098    }
099
100    protected void checkHouseNumbersWithoutStreet(OsmPrimitive p) {
101        List<Relation> associatedStreets = getAndCheckAssociatedStreets(p);
102        // Find house number without proper location
103        // (neither addr:street, associatedStreet, addr:place, addr:neighbourhood or addr:interpolation)
104        if (p.hasKey(ADDR_HOUSE_NUMBER) && !p.hasKey(ADDR_STREET, ADDR_PLACE, ADDR_NEIGHBOURHOOD)) {
105            for (Relation r : associatedStreets) {
106                if (r.hasTag("type", ASSOCIATED_STREET)) {
107                    return;
108                }
109            }
110            if (p.referrers(Way.class).anyMatch(w -> w.hasKey(ADDR_INTERPOLATION) && w.hasKey(ADDR_STREET))) {
111                return;
112            }
113            // No street found
114            errors.add(TestError.builder(this, Severity.WARNING, HOUSE_NUMBER_WITHOUT_STREET)
115                    .message(tr("House number without street"))
116                    .primitives(p)
117                    .build());
118        }
119    }
120
121    static boolean isPOI(OsmPrimitive p) {
122        return p.hasKey("shop", "amenity", "tourism", "leisure", "emergency", "craft", "office", "name");
123    }
124
125    static boolean hasAddress(OsmPrimitive p) {
126        return p.hasKey(ADDR_HOUSE_NUMBER) && p.hasKey(ADDR_STREET, ADDR_PLACE);
127    }
128
129    /**
130     * adds the OsmPrimitive to the address map if it complies to the restrictions
131     * @param p OsmPrimitive that has an address
132     */
133    private void collectAddress(OsmPrimitive p) {
134        if (!isPOI(p)) {
135            String simplifiedAddress = getSimplifiedAddress(p);
136            if (!ignoredAddresses.contains(simplifiedAddress)) {
137                knownAddresses.computeIfAbsent(simplifiedAddress, x -> new ArrayList<>()).add(p);
138            }
139        }
140    }
141
142    protected void initAddressMap(OsmPrimitive primitive) {
143        knownAddresses = new HashMap<>();
144        ignoredAddresses = new HashSet<>();
145        for (OsmPrimitive p : primitive.getDataSet().allNonDeletedPrimitives()) {
146            if (p instanceof Node && p.hasKey(ADDR_UNIT, ADDR_FLATS)) {
147                for (OsmPrimitive r : p.getReferrers()) {
148                    if (hasAddress(r)) {
149                        // ignore addresses of buildings that are connected to addr:unit nodes
150                        // it's quite reasonable that there are more buildings with this address
151                        String simplifiedAddress = getSimplifiedAddress(r);
152                        if (!ignoredAddresses.contains(simplifiedAddress)) {
153                            ignoredAddresses.add(simplifiedAddress);
154                        } else if (knownAddresses.containsKey(simplifiedAddress)) {
155                            knownAddresses.remove(simplifiedAddress);
156                        }
157                    }
158                }
159            }
160            if (hasAddress(p)) {
161                collectAddress(p);
162            }
163        }
164    }
165
166    @Override
167    public void endTest() {
168        knownAddresses = null;
169        ignoredAddresses = null;
170        super.endTest();
171    }
172
173    protected void checkForDuplicate(OsmPrimitive p) {
174        if (knownAddresses == null) {
175            initAddressMap(p);
176        }
177        if (!isPOI(p) && hasAddress(p)) {
178            String simplifiedAddress = getSimplifiedAddress(p);
179            if (ignoredAddresses.contains(simplifiedAddress)) {
180                return;
181            }
182            if (knownAddresses.containsKey(simplifiedAddress)) {
183                double maxDistance = MAX_DUPLICATE_DISTANCE.get();
184                for (OsmPrimitive p2 : knownAddresses.get(simplifiedAddress)) {
185                    if (p == p2) {
186                        continue;
187                    }
188                    Severity severityLevel;
189                    String city1 = p.get(ADDR_CITY);
190                    String city2 = p2.get(ADDR_CITY);
191                    double distance = getDistance(p, p2);
192                    if (city1 != null && city2 != null) {
193                        if (city1.equals(city2)) {
194                            if (!p.hasKey(ADDR_POSTCODE) || !p2.hasKey(ADDR_POSTCODE) || p.get(ADDR_POSTCODE).equals(p2.get(ADDR_POSTCODE))) {
195                                severityLevel = Severity.WARNING;
196                            } else {
197                                // address including city identical but postcode differs
198                                // most likely perfectly fine
199                                severityLevel = Severity.OTHER;
200                            }
201                        } else {
202                            // address differs only by city - notify if very close, otherwise ignore
203                            if (distance < maxDistance) {
204                                severityLevel = Severity.OTHER;
205                            } else {
206                                continue;
207                            }
208                        }
209                    } else {
210                        // at least one address has no city specified
211                        if (p.hasKey(ADDR_POSTCODE) && p2.hasKey(ADDR_POSTCODE) && p.get(ADDR_POSTCODE).equals(p2.get(ADDR_POSTCODE))) {
212                            // address including postcode identical
213                            severityLevel = Severity.WARNING;
214                        } else {
215                            // city/postcode unclear - warn if very close, otherwise only notify
216                            // TODO: get city from surrounding boundaries?
217                            if (distance < maxDistance) {
218                                severityLevel = Severity.WARNING;
219                            } else {
220                                severityLevel = Severity.OTHER;
221                            }
222                        }
223                    }
224                    errors.add(TestError.builder(this, severityLevel, DUPLICATE_HOUSE_NUMBER)
225                            .message(tr("Duplicate house numbers"), marktr("''{0}'' ({1}m)"), simplifiedAddress, (int) distance)
226                            .primitives(Arrays.asList(p, p2)).build());
227                }
228                knownAddresses.get(simplifiedAddress).remove(p); // otherwise we would get every warning two times
229            }
230        }
231    }
232
233    static String getSimplifiedAddress(OsmPrimitive p) {
234        String simplifiedStreetName = p.hasKey(ADDR_STREET) ? p.get(ADDR_STREET) : p.get(ADDR_PLACE);
235        // ignore whitespaces and dashes in street name, so that "Mozart-Gasse", "Mozart Gasse" and "Mozartgasse" are all seen as equal
236        return Utils.strip(Stream.of(
237                simplifiedStreetName.replaceAll("[ -]", ""),
238                p.get(ADDR_HOUSE_NUMBER),
239                p.get(ADDR_HOUSE_NAME),
240                p.get(ADDR_UNIT),
241                p.get(ADDR_FLATS))
242            .filter(Objects::nonNull)
243            .collect(Collectors.joining(" ")))
244                .toUpperCase(Locale.ENGLISH);
245    }
246
247    @Override
248    public void visit(Node n) {
249        checkHouseNumbersWithoutStreet(n);
250        checkForDuplicate(n);
251    }
252
253    @Override
254    public void visit(Way w) {
255        checkHouseNumbersWithoutStreet(w);
256        checkForDuplicate(w);
257    }
258
259    @Override
260    public void visit(Relation r) {
261        checkHouseNumbersWithoutStreet(r);
262        checkForDuplicate(r);
263        if (r.hasTag("type", ASSOCIATED_STREET)) {
264            // Used to count occurrences of each house number in order to find duplicates
265            Map<String, List<OsmPrimitive>> map = new HashMap<>();
266            // Used to detect different street names
267            String relationName = r.get("name");
268            Set<OsmPrimitive> wrongStreetNames = new HashSet<>();
269            // Used to check distance
270            Set<OsmPrimitive> houses = new HashSet<>();
271            Set<Way> street = new HashSet<>();
272            for (RelationMember m : r.getMembers()) {
273                String role = m.getRole();
274                OsmPrimitive p = m.getMember();
275                if ("house".equals(role)) {
276                    houses.add(p);
277                    String number = p.get(ADDR_HOUSE_NUMBER);
278                    if (number != null) {
279                        number = number.trim().toUpperCase(Locale.ENGLISH);
280                        List<OsmPrimitive> list = map.get(number);
281                        if (list == null) {
282                            list = new ArrayList<>();
283                            map.put(number, list);
284                        }
285                        list.add(p);
286                    }
287                    if (relationName != null && p.hasKey(ADDR_STREET) && !relationName.equals(p.get(ADDR_STREET))) {
288                        if (wrongStreetNames.isEmpty()) {
289                            wrongStreetNames.add(r);
290                        }
291                        wrongStreetNames.add(p);
292                    }
293                } else if ("street".equals(role)) {
294                    if (p instanceof Way) {
295                        street.add((Way) p);
296                    }
297                    if (relationName != null && p.hasTagDifferent("name", relationName)) {
298                        if (wrongStreetNames.isEmpty()) {
299                            wrongStreetNames.add(r);
300                        }
301                        wrongStreetNames.add(p);
302                    }
303                }
304            }
305            // Report duplicate house numbers
306            for (Entry<String, List<OsmPrimitive>> entry : map.entrySet()) {
307                List<OsmPrimitive> list = entry.getValue();
308                if (list.size() > 1) {
309                    errors.add(TestError.builder(this, Severity.WARNING, DUPLICATE_HOUSE_NUMBER)
310                            .message(tr("Duplicate house numbers"), marktr("House number ''{0}'' duplicated"), entry.getKey())
311                            .primitives(list)
312                            .build());
313                }
314            }
315            // Report wrong street names
316            if (!wrongStreetNames.isEmpty()) {
317                errors.add(TestError.builder(this, Severity.WARNING, MULTIPLE_STREET_NAMES)
318                        .message(tr("Multiple street names in relation"))
319                        .primitives(wrongStreetNames)
320                        .build());
321            }
322            // Report addresses too far away
323            if (!street.isEmpty()) {
324                for (OsmPrimitive house : houses) {
325                    if (house.isUsable()) {
326                        checkDistance(house, street);
327                    }
328                }
329            }
330        }
331    }
332
333    /**
334     * returns rough distance between two OsmPrimitives
335     * @param a primitive a
336     * @param b primitive b
337     * @return distance of center of bounding boxes in meters
338     */
339    static double getDistance(OsmPrimitive a, OsmPrimitive b) {
340        LatLon centerA = a.getBBox().getCenter();
341        LatLon centerB = b.getBBox().getCenter();
342        return (centerA.greatCircleDistance(centerB));
343    }
344
345    protected void checkDistance(OsmPrimitive house, Collection<Way> street) {
346        EastNorth centroid;
347        if (house instanceof Node) {
348            centroid = ((Node) house).getEastNorth();
349        } else if (house instanceof Way) {
350            List<Node> nodes = ((Way) house).getNodes();
351            if (house.hasKey(ADDR_INTERPOLATION)) {
352                for (Node n : nodes) {
353                    if (n.hasKey(ADDR_HOUSE_NUMBER)) {
354                        checkDistance(n, street);
355                    }
356                }
357                return;
358            }
359            centroid = Geometry.getCentroid(nodes);
360        } else {
361            return; // TODO handle multipolygon houses ?
362        }
363        if (centroid == null) return; // fix #8305
364        double maxDistance = MAX_STREET_DISTANCE.get();
365        boolean hasIncompleteWays = false;
366        for (Way streetPart : street) {
367            for (Pair<Node, Node> chunk : streetPart.getNodePairs(false)) {
368                EastNorth p1 = chunk.a.getEastNorth();
369                EastNorth p2 = chunk.b.getEastNorth();
370                if (p1 != null && p2 != null) {
371                    EastNorth closest = Geometry.closestPointToSegment(p1, p2, centroid);
372                    if (closest.distance(centroid) <= maxDistance) {
373                        return;
374                    }
375                } else {
376                    Logging.warn("Addresses test skipped chunck "+chunk+" for street part "+streetPart+" because p1 or p2 is null");
377                }
378            }
379            if (!hasIncompleteWays && streetPart.isIncomplete()) {
380                hasIncompleteWays = true;
381            }
382        }
383        // No street segment found near this house, report error on if the relation does not contain incomplete street ways (fix #8314)
384        if (hasIncompleteWays) return;
385        List<OsmPrimitive> errorList = new ArrayList<>(street);
386        errorList.add(0, house);
387        errors.add(TestError.builder(this, Severity.WARNING, HOUSE_NUMBER_TOO_FAR)
388                .message(tr("House number too far from street"))
389                .primitives(errorList)
390                .build());
391    }
392}