Skip to content

Commit

Permalink
Merge branch 'conditional-restriction' into aachen
Browse files Browse the repository at this point in the history
  • Loading branch information
hbruch committed Jul 29, 2024
2 parents db41fc0 + 3650c93 commit 77a94c5
Show file tree
Hide file tree
Showing 11 changed files with 482 additions and 17 deletions.
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,11 @@
<artifactId>OpeningHoursParser</artifactId>
<version>0.28.2</version>
</dependency>
<dependency>
<groupId>io.leonard</groupId>
<artifactId>opening-hours-evaluator</artifactId>
<version>1.3.0</version>
</dependency>

<!-- create zip test files-->
<dependency>
Expand Down
241 changes: 241 additions & 0 deletions src/main/java/io/leonard/OpeningHoursEvaluator.java
Original file line number Diff line number Diff line change
@@ -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<RuleModifier.Modifier> CLOSED_MODIFIERS = Set.of(CLOSED, OFF);
private static final Set<RuleModifier.Modifier> OPEN_MODIFIERS = Set.of(OPEN, UNKNOWN);
private static final Map<WeekDay, DayOfWeek> 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<Rule> 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<LocalDateTime> isOpenUntil(LocalDateTime time, List<Rule> 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<LocalDateTime> wasLastOpen(LocalDateTime time, List<Rule> rules) {
return isOpenIterative(time, rules, false, MAX_SEARCH_DAYS);
}

public static Optional<LocalDateTime> wasLastOpen(
LocalDateTime time,
List<Rule> rules,
int searchDays
) {
return isOpenIterative(time, rules, false, searchDays);
}

public static Optional<LocalDateTime> isOpenNext(LocalDateTime time, List<Rule> rules) {
return isOpenIterative(time, rules, true, MAX_SEARCH_DAYS);
}

public static Optional<LocalDateTime> isOpenNext(
LocalDateTime time,
List<Rule> 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<LocalDateTime> isOpenIterative(
final LocalDateTime initialTime,
final List<Rule> 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<TimeRange> getTimeRangesOnThatDay(
LocalDateTime time,
Stream<Rule> 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<Rule> getOpenRules(List<Rule> rules) {
return rules
.stream()
.filter(r -> {
var modifier = r.getModifier();
return modifier == null || OPEN_MODIFIERS.contains(modifier.getModifier());
});
}

private static Stream<Rule> getClosedRules(List<Rule> 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<WeekDayRange> 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<DateRange> 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 <T> List<T> nullToEmptyList(List<T> list) {
if (list == null) return Collections.emptyList(); else return list;
}

private static List<TimeSpan> nullToEntireDay(List<TimeSpan> 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;
}
}
29 changes: 29 additions & 0 deletions src/main/java/io/leonard/TimeRange.java
Original file line number Diff line number Diff line change
@@ -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<TimeRange> startComparator = Comparator.comparing(timeRange ->
timeRange.start
);

public static Comparator<TimeRange> endComparator = Comparator.comparing(timeRange ->
timeRange.end
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -886,47 +887,50 @@ 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,
TurnRestrictionType.NO_TURN,
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,
TurnRestrictionType.ONLY_TURN,
Direction.STRAIGHT,
relation.getId()
);
} else if (relation.isTag("restriction", "only_right_turn")) {
} else if ("only_right_turn".equals(restriction)) {
tag =
new TurnRestrictionTag(
via,
TurnRestrictionType.ONLY_TURN,
Direction.RIGHT,
relation.getId()
);
} else if (relation.isTag("restriction", "only_left_turn")) {
} else if ("only_left_turn".equals(restriction)) {
tag =
new TurnRestrictionTag(
via,
TurnRestrictionType.ONLY_TURN,
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 {
Expand All @@ -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());
}
}
Expand Down
Loading

0 comments on commit 77a94c5

Please sign in to comment.