diff --git a/pom.xml b/pom.xml index 2f007cbb214..941807c63e8 100644 --- a/pom.xml +++ b/pom.xml @@ -912,6 +912,11 @@ OpeningHoursParser 0.28.2 + + io.leonard + opening-hours-evaluator + 1.3.0 + diff --git a/src/main/java/io/leonard/OpeningHoursEvaluator.java b/src/main/java/io/leonard/OpeningHoursEvaluator.java new file mode 100644 index 00000000000..bf9c3a62d93 --- /dev/null +++ b/src/main/java/io/leonard/OpeningHoursEvaluator.java @@ -0,0 +1,241 @@ +package io.leonard; + +import static ch.poole.openinghoursparser.RuleModifier.Modifier.*; + +import ch.poole.openinghoursparser.*; +import java.time.DayOfWeek; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.stream.Stream; + +public class OpeningHoursEvaluator { + + private static final Set CLOSED_MODIFIERS = Set.of(CLOSED, OFF); + private static final Set OPEN_MODIFIERS = Set.of(OPEN, UNKNOWN); + private static final Map weekDayToDayOfWeek = Map.of( + WeekDay.MO, + DayOfWeek.MONDAY, + WeekDay.TU, + DayOfWeek.TUESDAY, + WeekDay.WE, + DayOfWeek.WEDNESDAY, + WeekDay.TH, + DayOfWeek.THURSDAY, + WeekDay.FR, + DayOfWeek.FRIDAY, + WeekDay.SA, + DayOfWeek.SATURDAY, + WeekDay.SU, + DayOfWeek.SUNDAY + ); + + // when calculating the next time the hours are open, how many days should you go into the future + // this protects against stack overflows when the place is never going to open again + private static final int MAX_SEARCH_DAYS = 365 * 10; + + public static boolean isOpenAt(LocalDateTime time, List rules) { + var closed = getClosedRules(rules); + var open = getOpenRules(rules); + return ( + closed.noneMatch(rule -> timeMatchesRule(time, rule)) && + open.anyMatch(rule -> rule.isTwentyfourseven() || timeMatchesRule(time, rule)) + ); + } + + /** + * @return LocalDateTime in Optional, representing next closing time ; or empty Optional if place + * is either closed at time or never closed at all. + */ + public static Optional isOpenUntil(LocalDateTime time, List rules) { + var closed = getClosedRules(rules); + var open = getOpenRules(rules); + if (closed.anyMatch(rule -> timeMatchesRule(time, rule))) return Optional.empty(); + return getTimeRangesOnThatDay(time, open) + .filter(r -> r.surrounds(time.toLocalTime())) + .findFirst() + .map(r -> time.toLocalDate().atTime(r.end)); + } + + public static Optional wasLastOpen(LocalDateTime time, List rules) { + return isOpenIterative(time, rules, false, MAX_SEARCH_DAYS); + } + + public static Optional wasLastOpen( + LocalDateTime time, + List rules, + int searchDays + ) { + return isOpenIterative(time, rules, false, searchDays); + } + + public static Optional isOpenNext(LocalDateTime time, List rules) { + return isOpenIterative(time, rules, true, MAX_SEARCH_DAYS); + } + + public static Optional isOpenNext( + LocalDateTime time, + List rules, + int searchDays + ) { + return isOpenIterative(time, rules, true, searchDays); + } + + /** + * This is private function, this doc-string means only help onboard new devs. + * + * @param initialTime Starting point in time to search from. + * @param rules From parser + * @param forward Whether to search in future (true)? or in the past(false)? + * @param searchDays Limit search scope in days. + * @return an Optional LocalDateTime + */ + private static Optional isOpenIterative( + final LocalDateTime initialTime, + final List rules, + boolean forward, + final int searchDays + ) { + var nextTime = initialTime; + for (var iterations = 0; iterations <= searchDays; ++iterations) { + var open = getOpenRules(rules); + var closed = getClosedRules(rules); + + var time = nextTime; + if (isOpenAt(time, rules)) return Optional.of(time); else { + var openRangesOnThatDay = getTimeRangesOnThatDay(time, open); + var closedRangesThatDay = getTimeRangesOnThatDay(time, closed); + + var endOfExclusion = closedRangesThatDay + .filter(r -> r.surrounds(time.toLocalTime())) + .findFirst() + .map(r -> time.toLocalDate().atTime(forward ? r.end : r.start)); + + var startOfNextOpening = forward + ? openRangesOnThatDay + .filter(range -> range.start.isAfter(time.toLocalTime())) + .min(TimeRange.startComparator) + .map(timeRange -> time.toLocalDate().atTime(timeRange.start)) + : openRangesOnThatDay + .filter(range -> range.end.isBefore(time.toLocalTime())) + .max(TimeRange.endComparator) + .map(timeRange -> time.toLocalDate().atTime(timeRange.end)); + + var opensNextThatDay = endOfExclusion.or(() -> startOfNextOpening); + if (opensNextThatDay.isPresent()) { + return opensNextThatDay; + } + + // if we cannot find time on the same day when the POI is open, we skip forward to the start + // of the following day and try again + nextTime = + forward + ? time.toLocalDate().plusDays(1).atStartOfDay() + : time.toLocalDate().minusDays(1).atTime(LocalTime.MAX); + } + } + + return Optional.empty(); + } + + private static Stream getTimeRangesOnThatDay( + LocalDateTime time, + Stream ruleStream + ) { + return ruleStream + .filter(rule -> timeMatchesDayRanges(time, rule.getDays())) + .filter(r -> !Objects.isNull(r.getTimes())) + .flatMap(r -> r.getTimes().stream().map(TimeRange::new)); + } + + private static Stream getOpenRules(List rules) { + return rules + .stream() + .filter(r -> { + var modifier = r.getModifier(); + return modifier == null || OPEN_MODIFIERS.contains(modifier.getModifier()); + }); + } + + private static Stream getClosedRules(List rules) { + return rules + .stream() + .filter(r -> { + var modifier = r.getModifier(); + return modifier != null && CLOSED_MODIFIERS.contains(modifier.getModifier()); + }); + } + + private static boolean timeMatchesRule(LocalDateTime time, Rule rule) { + return ( + ( + timeMatchesDayRanges(time, rule.getDays()) || + rule.getDays() == null && + dateMatchesDateRanges(time, rule.getDates()) + ) && + nullToEntireDay(rule.getTimes()) + .stream() + .anyMatch(timeSpan -> timeMatchesHours(time, timeSpan)) + ); + } + + private static boolean timeMatchesDayRanges(LocalDateTime time, List ranges) { + return nullToEmptyList(ranges).stream().anyMatch(dayRange -> timeMatchesDay(time, dayRange)); + } + + private static boolean timeMatchesDay(LocalDateTime time, WeekDayRange range) { + // if the end day is null it means that it's just a single day like in "Th + // 10:00-18:00" + if (range.getEndDay() == null) { + return time.getDayOfWeek().equals(weekDayToDayOfWeek.getOrDefault(range.getStartDay(), null)); + } + int ordinal = time.getDayOfWeek().ordinal(); + return range.getStartDay().ordinal() <= ordinal && range.getEndDay().ordinal() >= ordinal; + } + + private static boolean dateMatchesDateRanges(LocalDateTime time, List ranges) { + return nullToEmptyList(ranges) + .stream() + .anyMatch(dateRange -> dateMatchesDateRange(time, dateRange)); + } + + private static boolean dateMatchesDateRange(LocalDateTime time, DateRange range) { + // if the end date is null it means that it's just a single date like in "2020 Aug 11" + DateWithOffset startDate = range.getStartDate(); + boolean afterStartDate = + time.getYear() >= startDate.getYear() && + time.getMonth().ordinal() >= startDate.getMonth().ordinal() && + time.getDayOfMonth() >= startDate.getDay(); + if (range.getEndDate() == null) { + return afterStartDate; + } + DateWithOffset endDate = range.getEndDate(); + boolean beforeEndDate = + time.getYear() <= endDate.getYear() && + time.getMonth().ordinal() <= endDate.getMonth().ordinal() && + time.getDayOfMonth() <= endDate.getDay(); + return afterStartDate && beforeEndDate; + } + + private static boolean timeMatchesHours(LocalDateTime time, TimeSpan timeSpan) { + var minutesAfterMidnight = minutesAfterMidnight(time.toLocalTime()); + return timeSpan.getStart() <= minutesAfterMidnight && timeSpan.getEnd() >= minutesAfterMidnight; + } + + private static int minutesAfterMidnight(LocalTime time) { + return time.getHour() * 60 + time.getMinute(); + } + + private static List nullToEmptyList(List list) { + if (list == null) return Collections.emptyList(); else return list; + } + + private static List nullToEntireDay(List span) { + if (span == null) { + var allDay = new TimeSpan(); + allDay.setStart(TimeSpan.MIN_TIME); + allDay.setEnd(TimeSpan.MAX_TIME); + return List.of(allDay); + } else return span; + } +} diff --git a/src/main/java/io/leonard/TimeRange.java b/src/main/java/io/leonard/TimeRange.java new file mode 100644 index 00000000000..61141c2ad12 --- /dev/null +++ b/src/main/java/io/leonard/TimeRange.java @@ -0,0 +1,29 @@ +package io.leonard; + +import ch.poole.openinghoursparser.TimeSpan; +import java.time.LocalTime; +import java.util.Comparator; + +public class TimeRange { + + public final LocalTime start; + public final LocalTime end; + + public TimeRange(TimeSpan span) { + this.start = LocalTime.ofSecondOfDay(span.getStart() * 60L); + this.end = + LocalTime.ofSecondOfDay(Math.min(span.getEnd() * 60L, LocalTime.MAX.toSecondOfDay())); + } + + public boolean surrounds(LocalTime time) { + return time.isAfter(start) && time.isBefore(end); + } + + public static Comparator startComparator = Comparator.comparing(timeRange -> + timeRange.start + ); + + public static Comparator endComparator = Comparator.comparing(timeRange -> + timeRange.end + ); +} diff --git a/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmDatabase.java b/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmDatabase.java index df40e2cb647..5ee6d3cd3db 100644 --- a/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmDatabase.java +++ b/src/main/java/org/opentripplanner/graph_builder/module/osm/OsmDatabase.java @@ -1,5 +1,6 @@ package org.opentripplanner.graph_builder.module.osm; +import ch.poole.openinghoursparser.OpeningHoursParseException; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.HashMultimap; import com.google.common.collect.Iterables; @@ -45,7 +46,7 @@ import org.opentripplanner.openstreetmap.model.OSMTag; import org.opentripplanner.openstreetmap.model.OSMWay; import org.opentripplanner.openstreetmap.model.OSMWithTags; -import org.opentripplanner.street.model.RepeatingTimePeriod; +import org.opentripplanner.street.model.OHRulesRestriction; import org.opentripplanner.street.model.StreetTraversalPermission; import org.opentripplanner.street.model.TurnRestrictionType; import org.opentripplanner.street.search.TraverseMode; @@ -886,13 +887,16 @@ private void processRestriction(OSMRelation relation) { } TurnRestrictionTag tag; - if (relation.isTag("restriction", "no_right_turn")) { + String restriction = relation.hasTag("restriction") + ? relation.getTag("restriction") + : relation.getConditionalTag("restriction:conditional"); + if ("no_right_turn".equals(restriction)) { tag = new TurnRestrictionTag(via, TurnRestrictionType.NO_TURN, Direction.RIGHT, relation.getId()); - } else if (relation.isTag("restriction", "no_left_turn")) { + } else if ("no_left_turn".equals(restriction)) { tag = new TurnRestrictionTag(via, TurnRestrictionType.NO_TURN, Direction.LEFT, relation.getId()); - } else if (relation.isTag("restriction", "no_straight_on")) { + } else if ("no_straight_on".equals(restriction)) { tag = new TurnRestrictionTag( via, @@ -900,9 +904,9 @@ private void processRestriction(OSMRelation relation) { Direction.STRAIGHT, relation.getId() ); - } else if (relation.isTag("restriction", "no_u_turn")) { + } else if ("no_u_turn".equals(restriction)) { tag = new TurnRestrictionTag(via, TurnRestrictionType.NO_TURN, Direction.U, relation.getId()); - } else if (relation.isTag("restriction", "only_straight_on")) { + } else if ("only_straight_on".equals(restriction)) { tag = new TurnRestrictionTag( via, @@ -910,7 +914,7 @@ private void processRestriction(OSMRelation relation) { Direction.STRAIGHT, relation.getId() ); - } else if (relation.isTag("restriction", "only_right_turn")) { + } else if ("only_right_turn".equals(restriction)) { tag = new TurnRestrictionTag( via, @@ -918,7 +922,7 @@ private void processRestriction(OSMRelation relation) { Direction.RIGHT, relation.getId() ); - } else if (relation.isTag("restriction", "only_left_turn")) { + } else if ("only_left_turn".equals(restriction)) { tag = new TurnRestrictionTag( via, @@ -926,7 +930,7 @@ private void processRestriction(OSMRelation relation) { Direction.LEFT, relation.getId() ); - } else if (relation.isTag("restriction", "only_u_turn")) { + } else if ("only_u_turn".equals(restriction)) { tag = new TurnRestrictionTag(via, TurnRestrictionType.ONLY_TURN, Direction.U, relation.getId()); } else { @@ -936,22 +940,35 @@ private void processRestriction(OSMRelation relation) { tag.modes = modes.clone(); // set the time periods for this restriction, if applicable - if ( + if (relation.hasTag("restriction:conditional")) { + String tagWithCondition = relation.getTag("restriction:conditional"); + try { + tag.time = + OHRulesRestriction.parseFromCondition( + tagWithCondition, + relation.getOsmProvider()::getZoneId + ); + } catch (OpeningHoursParseException e) { + LOG.info("Unparseable conditional turn restriction: {}", relation.getId()); + } + } else if ( relation.hasTag("day_on") && relation.hasTag("day_off") && relation.hasTag("hour_on") && relation.hasTag("hour_off") ) { + // TODO tagging schemes day_on/day_off(hour_on/hour_off is deprecated and should be converted + // to restriction:conditional try { tag.time = - RepeatingTimePeriod.parseFromOsmTurnRestriction( + OHRulesRestriction.parseFromOsmTurnRestriction( relation.getTag("day_on"), relation.getTag("day_off"), relation.getTag("hour_on"), relation.getTag("hour_off"), relation.getOsmProvider()::getZoneId ); - } catch (NumberFormatException e) { + } catch (OpeningHoursParseException e) { LOG.info("Unparseable turn restriction: {}", relation.getId()); } } diff --git a/src/main/java/org/opentripplanner/graph_builder/module/osm/TurnRestrictionTag.java b/src/main/java/org/opentripplanner/graph_builder/module/osm/TurnRestrictionTag.java index 8bd1464667c..82e5e736367 100644 --- a/src/main/java/org/opentripplanner/graph_builder/module/osm/TurnRestrictionTag.java +++ b/src/main/java/org/opentripplanner/graph_builder/module/osm/TurnRestrictionTag.java @@ -2,7 +2,7 @@ import java.util.ArrayList; import java.util.List; -import org.opentripplanner.street.model.RepeatingTimePeriod; +import org.opentripplanner.street.model.TimeRestriction; import org.opentripplanner.street.model.TurnRestrictionType; import org.opentripplanner.street.model.edge.StreetEdge; import org.opentripplanner.street.search.TraverseModeSet; @@ -18,7 +18,7 @@ class TurnRestrictionTag { long relationOSMID; TurnRestrictionType type; Direction direction; - RepeatingTimePeriod time; + TimeRestriction time; public List possibleFrom = new ArrayList<>(); public List possibleTo = new ArrayList<>(); public TraverseModeSet modes; diff --git a/src/main/java/org/opentripplanner/openstreetmap/model/OSMWithTags.java b/src/main/java/org/opentripplanner/openstreetmap/model/OSMWithTags.java index b53739bf6a1..ab96f011b82 100644 --- a/src/main/java/org/opentripplanner/openstreetmap/model/OSMWithTags.java +++ b/src/main/java/org/opentripplanner/openstreetmap/model/OSMWithTags.java @@ -195,6 +195,20 @@ public String getTag(String tag) { return null; } + /** @return a conditional tag's (tag with ':conditional' appended) restriction value + * (value before the first @, without condition). + * Note: a conditional tag might have multiple conditions, which is not yet supported. */ + @Nullable + public String getConditionalTag(String tag) { + tag = tag.toLowerCase(); + if (tags != null && tags.containsKey(tag)) { + String conditionalValue = tags.get(tag); + int indexOfAt = conditionalValue.indexOf('@'); + return indexOfAt > 0 ? conditionalValue.substring(0, indexOfAt).trim() : conditionalValue; + } + return null; + } + /** * * @return A tags value converted to lower case. An empty Optional if tags is not present. diff --git a/src/main/java/org/opentripplanner/street/model/OHRulesRestriction.java b/src/main/java/org/opentripplanner/street/model/OHRulesRestriction.java new file mode 100644 index 00000000000..64bbd656c30 --- /dev/null +++ b/src/main/java/org/opentripplanner/street/model/OHRulesRestriction.java @@ -0,0 +1,95 @@ +package org.opentripplanner.street.model; + +import ch.poole.openinghoursparser.OpeningHoursParseException; +import ch.poole.openinghoursparser.OpeningHoursParser; +import ch.poole.openinghoursparser.Rule; +import io.leonard.OpeningHoursEvaluator; +import java.io.ByteArrayInputStream; +import java.io.Serializable; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.function.Supplier; +import javax.annotation.Nullable; + +/** + * OHRulesRestriction represents timezone aware OSM opening hours rules + * to represent temporal restrictions. + */ +public class OHRulesRestriction implements Serializable, TimeRestriction { + + /** + * The timezone this is represented in. + */ + private final ZoneId timeZone; + /** + * The opening hours rules as parsed by OpeningHoursParser. + */ + private final List rules; + /** + * Should the time restriction be reversed (in case the condition's restriction value is "none") + */ + private final boolean inverse; + + private OHRulesRestriction(ZoneId timeZone, List rules, boolean inverse) { + this.timeZone = timeZone; + this.rules = rules; + this.inverse = inverse; + } + + /** + * Parse the time specification from an OSM turn restriction + */ + public static TimeRestriction parseFromOsmTurnRestriction( + String day_on, + String day_off, + String hour_on, + String hour_off, + Supplier timeZoneSupplier + ) throws OpeningHoursParseException { + return parseFromCondition( + day_on.substring(0, 3) + " - " + day_off.substring(0, 3) + " " + hour_on + "-" + hour_off, + timeZoneSupplier + ); + } + + /** + * + * @param tagValueWithCondition + * @param timeZoneSupplier + * @return + * @throws OpeningHoursParseException + */ + public static TimeRestriction parseFromCondition( + String tagValueWithCondition, + Supplier timeZoneSupplier + ) throws OpeningHoursParseException { + ZoneId timeZone = timeZoneSupplier.get(); + if (timeZone == null) { + return null; + } + int indexOfAt = tagValueWithCondition.indexOf('@'); + String temporalCondition = indexOfAt > 0 + ? tagValueWithCondition.substring(indexOfAt + 1).trim() + : tagValueWithCondition; + OpeningHoursParser openingHoursParser = new OpeningHoursParser( + new ByteArrayInputStream(temporalCondition.getBytes()) + ); + boolean inverse = + indexOfAt > 0 && "none".equals(tagValueWithCondition.substring(0, indexOfAt).trim()); + + return new OHRulesRestriction(timeZone, openingHoursParser.rules(false), inverse); + } + + @Override + public boolean active(long time) { + return ( + OpeningHoursEvaluator.isOpenAt( + LocalDateTime.ofInstant(Instant.ofEpochSecond(time), timeZone), + rules + ) ^ + inverse + ); + } +} diff --git a/src/main/java/org/opentripplanner/street/model/RepeatingTimePeriod.java b/src/main/java/org/opentripplanner/street/model/RepeatingTimePeriod.java index d16c0ed7993..055664446de 100644 --- a/src/main/java/org/opentripplanner/street/model/RepeatingTimePeriod.java +++ b/src/main/java/org/opentripplanner/street/model/RepeatingTimePeriod.java @@ -14,7 +14,7 @@ * * @author mattwigway */ -public class RepeatingTimePeriod implements Serializable { +public class RepeatingTimePeriod implements Serializable, TimeRestriction { /** * The timezone this is represented in. @@ -107,6 +107,7 @@ public static RepeatingTimePeriod parseFromOsmTurnRestriction( return ret; } + @Override public boolean active(long time) { ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochSecond(time), timeZone); DayOfWeek dayOfWeek = zonedDateTime.getDayOfWeek(); diff --git a/src/main/java/org/opentripplanner/street/model/TimeRestriction.java b/src/main/java/org/opentripplanner/street/model/TimeRestriction.java new file mode 100644 index 00000000000..5f128b7e817 --- /dev/null +++ b/src/main/java/org/opentripplanner/street/model/TimeRestriction.java @@ -0,0 +1,10 @@ +package org.opentripplanner.street.model; + +/** + * Represents a time restriction, used for opening hours, time dependant turn restrictions etc. + * + * @author hbruch + */ +public interface TimeRestriction { + boolean active(long time); +} diff --git a/src/main/java/org/opentripplanner/street/model/TurnRestriction.java b/src/main/java/org/opentripplanner/street/model/TurnRestriction.java index 395a200e754..655a41c5713 100644 --- a/src/main/java/org/opentripplanner/street/model/TurnRestriction.java +++ b/src/main/java/org/opentripplanner/street/model/TurnRestriction.java @@ -9,7 +9,7 @@ public class TurnRestriction implements Serializable { public final TurnRestrictionType type; public final StreetEdge from; public final StreetEdge to; - public final RepeatingTimePeriod time; + public final TimeRestriction time; public final TraverseModeSet modes; public TurnRestriction( @@ -17,7 +17,7 @@ public TurnRestriction( StreetEdge to, TurnRestrictionType type, TraverseModeSet modes, - RepeatingTimePeriod time + TimeRestriction time ) { this.from = from; this.to = to; diff --git a/src/test/java/org/opentripplanner/street/model/OHRulesRestrictionTest.java b/src/test/java/org/opentripplanner/street/model/OHRulesRestrictionTest.java new file mode 100644 index 00000000000..294abcb2acb --- /dev/null +++ b/src/test/java/org/opentripplanner/street/model/OHRulesRestrictionTest.java @@ -0,0 +1,53 @@ +package org.opentripplanner.street.model; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.poole.openinghoursparser.OpeningHoursParseException; +import java.time.Instant; +import java.time.ZoneId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.opentripplanner._support.time.ZoneIds; + +public class OHRulesRestrictionTest { + + private final ZoneId zoneId = ZoneIds.PARIS; + + @Test + public void testRepeatingTimePeriod() throws OpeningHoursParseException { + // Note: day_on/day_off is currently required, relations like e.g. + // https://www.openstreetmap.org/relation/54249 would not work, as day_on/day_off is missing + TimeRestriction timePeriod = OHRulesRestriction.parseFromOsmTurnRestriction( + "monday", + "sunday", + "06", + "23", + () -> { + return zoneId; + } + ); + Instant dateTime = Instant.parse("2024-07-26T10:30:00Z"); + long time = dateTime.getEpochSecond(); + assertTrue(timePeriod.active(time)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "mo-su 06:00-23:00", "fri", "none @ sat", "2024 Jul 26", "2024 Jul 25 - 2024 Jul 27", + } + ) // six numbers + // Note: hours only restrictions (e.g. "06:00-23:00") are not yet supported + public void testOHCalendarRestrictionOpen(String condition) throws OpeningHoursParseException { + TimeRestriction tr = OHRulesRestriction.parseFromCondition( + condition, + () -> { + return zoneId; + } + ); + Instant dateTime = Instant.parse("2024-07-26T10:30:00Z"); + long time = dateTime.getEpochSecond(); + assertTrue(tr.active(time)); + } +}