diff --git a/prog/communication.py b/prog/communication.py index ea79b6f95fb1d3bd39c226305dc234d9d16a91cc..d67e30f66e6fc26672a5c0c196fb09fb5d5ea1a4 100644 --- a/prog/communication.py +++ b/prog/communication.py @@ -212,9 +212,6 @@ def server_exec(): return l return decorator -dt_pinf = datetime.datetime(2100, 1, 1, tzinfo=local_timezone) -dt_minf = datetime.datetime(2000, 1, 1, tzinfo=local_timezone) - def path_to_dt(path): path = path.split('.')[0] path = path.replace('/', '-') diff --git a/prog/data_utils.py b/prog/data_utils.py index c600d49c892bd6bbbf0cdc05e9d3d17c9d1ca4a2..2ffa9deed15f08fb43d923cf76f855611321087c 100644 --- a/prog/data_utils.py +++ b/prog/data_utils.py @@ -203,6 +203,7 @@ class TripHistory: def add_preprocessed_data(self, data): for x in data["history"]: self.history.append(HistoryPointFromPreprocessed(x)) + self.history.sort(key=lambda hp: hp.first_captured) diff --git a/prog/main.py b/prog/main.py index 696283a621021266a6f32a9eccd56ad6532746d7..7554ea46703febc0cab9655cc4ad1a465b03f03b 100755 --- a/prog/main.py +++ b/prog/main.py @@ -26,6 +26,8 @@ import sys, os import pprint import gtfs +import math + window_title_prefix = "PID realtime" @@ -37,46 +39,56 @@ crs_wgs=QgsCoordinateReferenceSystem(4326) crs_wgs_mercator=QgsCoordinateReferenceSystem(3857) crs_transform = QgsCoordinateTransform(crs_wgs, crs_wgs_mercator, QgsProject.instance()) -live_subcribes = set() +live_subscribers = set() live_data = None +live_data_by_trip_id = None live_dt = None +def error(s): + eprint("ERROR:", s) + fetch_live_task: asyncio.Task = None async def fetch_live(): - global live_data, live_dt + global live_data, live_dt, live_data_by_trip_id eprint("Hi from fetch_live") c = await get_communication() r = await c.get_last_data() live_data = await unzip_parse(r[1]) + live_data_by_trip_id = {x["properties"]["trip"]["gtfs"]["trip_id"]: x for x in live_data["features"]} live_dt = r[0] - for f in live_subcribes: - asyncio.create_task(f(live_dt, live_data)) + for f in live_subscribers: + asyncio.create_task(f(live_dt, live_data, live_data_by_trip_id)) while True: eprint("Start wait next") r = await c.wait_next_data(live_dt) eprint("Done wait next") live_data = await unzip_parse(r[1]) + live_data_by_trip_id = {x["properties"]["trip"]["gtfs"]["trip_id"]: x for x in live_data["features"]} live_dt = r[0] - for f in live_subcribes: - asyncio.create_task(f(live_dt, live_data)) + for f in live_subscribers: + asyncio.create_task(f(live_dt, live_data, live_data_by_trip_id)) def spawn_or_remove_fetch_live_task(): global live_data, live_dt, fetch_live_task eprint("spawn_or_remove_fetch_live_task") - if fetch_live_task and len(live_subcribes) == 0: + if fetch_live_task and len(live_subscribers) == 0: fetch_live_task.cancel() fetch_live_task = None live_data = None live_dt = None - if not fetch_live_task and len(live_subcribes): + if not fetch_live_task and len(live_subscribers): fetch_live_task = asyncio.create_task(catch_all(fetch_live())) class ClickableLabel(QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setStyleSheet ("color: blue; text-decoration: underline;"); + clicked = pyqtSignal() def mouseReleaseEvent(self, event): super(ClickableLabel, self).mouseReleaseEvent(event) @@ -108,6 +120,7 @@ async def gtfs_default_data_getter(date, filename): gtfs.default_data_getter = gtfs_default_data_getter data_utils.get_communication = get_communication +graph.get_communication = get_communication def layer_updated(layer): prov = layer.dataProvider() @@ -157,7 +170,7 @@ class TimeControllToolBar(QToolBar): wnd.shown_data_changed_signal.connect(self.time_changed) def time_changed(self): - self._time_label.setText(self.wnd.current_data_capture_time.strftime("%Y-%m-%d %H:%M:%S") if self.wnd.current_data_capture_time else "") + self._time_label.setText(format_dt_to_user(self.wnd.current_data_capture_time) if self.wnd.current_data_capture_time else "") class FilterWidget(QWidget): @@ -255,15 +268,15 @@ class MainWind(QMainWindow): self._tmp2 = QPushButton() self._tmp2.clicked.connect(self.tmp2) - dockWidget = QDockWidget("Dock Widget") - dockWidget.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) - dockWidget.setWidget(self._tmp) - self.addDockWidget(Qt.LeftDockWidgetArea, dockWidget) + # dockWidget = QDockWidget("Dock Widget") + # dockWidget.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) + # dockWidget.setWidget(self._tmp) + # self.addDockWidget(Qt.LeftDockWidgetArea, dockWidget) - dockWidget = QDockWidget("Dock Widget") - dockWidget.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) - dockWidget.setWidget(self._tmp2) - self.addDockWidget(Qt.LeftDockWidgetArea, dockWidget) + # dockWidget = QDockWidget("Dock Widget") + # dockWidget.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) + # dockWidget.setWidget(self._tmp2) + # self.addDockWidget(Qt.LeftDockWidgetArea, dockWidget) self._time_controll = TimeControllToolBar(self) toolbar = self.addToolBar(self._time_controll) @@ -284,13 +297,15 @@ class MainWind(QMainWindow): @asyncSlot() async def tmp2(self): - dt = datetime.datetime(2024, 7, 4, 8, 0, 0) + dt = datetime.datetime(2024, 7, 16, 8, 0, 0) g = gtfs.for_date(dt) await g.load_trips() - await g.load_stops_for_all_trips() + #await g.load_stops_for_all_trips() trips = [Trip(t.id, dt.date()) for t in g.trips_by_routes["L22"] if t.direction == 1] ths = {t.trip_id: TripHistory(t) for t in trips} + main_th = ths['22_28258_240701'] + c = await get_communication() #dts = await c.list_realtime_data(datetime.datetime.combine(dt.date(), datetime.time(8,0,0)), datetime.datetime.combine(dt.date(), datetime.time(9,0,0))) #for dt in dts: @@ -306,7 +321,7 @@ class MainWind(QMainWindow): # ths[trip_id].add_history_point(dt, dato) # break - data = await c.get_preprocessed_data(dt, "22") + data, source_timestamps = await c.get_preprocessed_data(dt, "22") print("P1") data = await unzip_parse(data) print("P3") @@ -319,16 +334,15 @@ class MainWind(QMainWindow): - for th in ths.values(): - await th.load_stops() + await main_th.load_stops(await c.gtfs_get_stop_times(datetime.datetime.combine(main_th.trip.date, datetime.time()), main_th.trip.trip_id)) # HACK: Date is not CBOR supported type + #for th in ths.values(): + #await th.load_stops() print("P5") - main_th = list(ths.values())[51] - history_graph = graph.HistoryGraph(main_th.stops) - print("P6") - for th in ths.values(): - history_graph.add_trip(th) + history_graph = graph.HistoryGraph(main_th.stops, "22", dt-datetime.timedelta(hours=2), dt) + + history_graph.add_trip(main_th) print("P7") history_graph.show() tmp_windows[history_graph]=history_graph @@ -367,9 +381,10 @@ class MainWind(QMainWindow): c = await get_communication() r = await c.get_next_data(self.current_data_capture_time) if r is None: - ... + error("Couldn't move to next data, you are on the last data.") else: data = await unzip_parse(r[1]) + await gtfs.for_date(r[0]).load_stops() self.show_data(data, r[0]) @asyncSlot() @@ -378,9 +393,10 @@ class MainWind(QMainWindow): c = await get_communication() r = await c.get_prev_data(self.current_data_capture_time) if r is None: - ... + error("Couldn't move to previous data, you are on the first data.") else: data = await unzip_parse(r[1]) + await gtfs.for_date(r[0]).load_stops() self.show_data(data, r[0]) @asyncSlot() @@ -399,27 +415,39 @@ class MainWind(QMainWindow): async def start_live(self): if live_data is not None: + await gtfs.for_date(live_dt).load_stops() self.show_data(live_data, live_dt) - live_subcribes.add(self.live_data_arrived) + live_subscribers.add(self.live_data_arrived) spawn_or_remove_fetch_live_task() async def stop_live(self): - live_subcribes.remove(self.live_data_arrived) + live_subscribers.remove(self.live_data_arrived) spawn_or_remove_fetch_live_task() - async def live_data_arrived(self, dt, data): + async def live_data_arrived(self, dt, data, _): if self.showing_live: + await gtfs.for_date(dt).load_stops() self.show_data(data, dt) else: pprint("live data arrived but we are not in live mode") + async def load_data_near(self, dt): + c = await get_communication() + dts = await c.list_realtime_data(dt-datetime.timedelta(seconds=20), dt+datetime.timedelta(seconds=20)) + if not len(dts): + error("No data near {format_dt_to_user(dt)}") + else: + near_dt = min(dts, key=lambda x: abs((x - dt).total_seconds())) + await self.load_data(near_dt) + async def load_data(self, dt): await self.set_live(False) c = await get_communication() data = await unzip_parse(await c.get_data(dt)) + await gtfs.for_date(dt).load_stops() self.show_data(data, dt) def show_data(self, data, capture_time): @@ -439,9 +467,12 @@ class MainWind(QMainWindow): self.show_parsed_data() def show_parsed_data(self): - feats = [] + g = gtfs.for_date_cache.get(self.current_data_capture_time.date(), None) + stops = g.stops or {} if g else {} current_filtered_data = [tp for tp in self.current_data if self._filter.filter(tp)] + feats = [] + self.current_filtered_data = current_filtered_data layer_fields = self.data_layer.fields() @@ -460,7 +491,13 @@ class MainWind(QMainWindow): else: feat["vehicle_type"] = str(dato_vehicle_type["description_cs"]) feat["oldness"] = (self.current_data_capture_time - tp.origin_timestamp).total_seconds() - # feat["rotation"] + last_stop = stops.get(tp.json["properties"]["last_position"]["last_stop"]["id"], None) + next_stop = stops.get(tp.json["properties"]["last_position"]["next_stop"]["id"], None) + if last_stop and next_stop: + lat = next_stop.lat - last_stop.lat + lon = next_stop.lon - last_stop.lon + atan = math.atan2(lat, lon*data_utils.lon_muntiplicator)/math.pi*180 + feat["rotation"] = (360 - atan) % 360 feats.append(feat) self.data_prov.truncate() @@ -708,17 +745,126 @@ class TripPointSelector(QWidget): tmp_windows = {} +class BoldLabel(QLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + f = self.font() + f.setBold(True) + self.setFont(f) + +class TimeLabel(ClickableLabel): + def __init__(self, mwnd, dt=None): + super().__init__() + self.set_dt(dt) + self.clicked.connect(self.on_click) + self.mwnd = mwnd + + def set_dt(self, dt): + if dt: + self.setText(format_dt_to_user(dt)) + self.dt = dt + + + @asyncSlot() + async def on_click(self): + if self.dt: + await self.mwnd.load_data_near(self.dt) + + + class TripPointWindow(QWidget): def __init__(self, tp, mwnd): super().__init__() self.mwnd = mwnd self._layout = QVBoxLayout() + + self._info_layout = QGridLayout() + self._layout.addLayout(self._info_layout) + i = 0 + + def add_info_widgets(title, val=None): + nonlocal i + self._info_layout.addWidget(title, i, 0) + if val: + self._info_layout.addWidget(val, i, 1) + i += 1 + + self._info_capture_time = TimeLabel(self.mwnd) + add_info_widgets(QLabel("Capture time"), self._info_capture_time) + self._info_first_capture_time = TimeLabel(self.mwnd) + add_info_widgets(QLabel("First capture time"), self._info_first_capture_time) + self._info_last_capture_time = TimeLabel(self.mwnd) + add_info_widgets(QLabel("Last capture time"), self._info_last_capture_time) + self._info_origin_time = TimeLabel(self.mwnd) + add_info_widgets(QLabel("Origin time"), self._info_origin_time) + + add_info_widgets(BoldLabel("Trip")) + self._info_headsig = QLabel() + add_info_widgets(QLabel("Headsign"), self._info_headsig) + self._info_trip_id = QLabel() + add_info_widgets(QLabel("Trip ID"), self._info_trip_id) + self._info_trip_name = QLabel() + add_info_widgets(QLabel("Trip name"), self._info_trip_name) + self._info_route = QLabel() + add_info_widgets(QLabel("Route"), self._info_route) + self._info_agency = QLabel() + add_info_widgets(QLabel("Agency"), self._info_agency) + self._info_sequence = QLabel() + add_info_widgets(QLabel("Sequence"), self._info_sequence) + self._info_vehicle_type = QLabel() + add_info_widgets(QLabel("Vehicle"), self._info_vehicle_type) + self._info_start_timestamp = TimeLabel(self.mwnd) + add_info_widgets(QLabel("Start timestamp"), self._info_start_timestamp) + self._info_start_timestamp = TimeLabel(self.mwnd) + add_info_widgets(QLabel("Start "), self._info_start_timestamp) + + def add_stop_info(): + class StopInfo: + ... + obj = StopInfo() + obj.name = QLabel() + add_info_widgets(QLabel("Name"), obj.name) + obj.id = QLabel() + add_info_widgets(QLabel("Id"), obj.id) + obj.arrival = TimeLabel(self.mwnd) + add_info_widgets(QLabel("Arrival"), obj.arrival) + obj.departure = TimeLabel(self.mwnd) + add_info_widgets(QLabel("Departure"), obj.departure) + return obj + + + add_info_widgets(BoldLabel("OpenAPI Last stop")) + self._info_last_stop = add_stop_info() + add_info_widgets(BoldLabel("OPpenAPI Next stop")) + self._info_next_stop = add_stop_info() + + add_info_widgets(BoldLabel("OpenAPI delay")) + self._info_openapi_delay_arrival = QLabel() + add_info_widgets(QLabel("Last stop arrival"), self._info_openapi_delay_arrival) + self._info_openapi_delay_departure = QLabel() + add_info_widgets(QLabel("Last stop departure"), self._info_openapi_delay_departure) + self._info_openapi_delay = QLabel() + add_info_widgets(QLabel("Now"), self._info_openapi_delay) + + add_info_widgets(BoldLabel("Shape & position")) + self._info_state = QLabel() + add_info_widgets(QLabel("State"), self._info_state) + self._info_shape_dist = QLabel() + add_info_widgets(QLabel("Shape dist"), self._info_shape_dist) + self._info_openapi_shape_dist = QLabel() + add_info_widgets(QLabel("OpenAPI shape dist"), self._info_openapi_shape_dist) + + + + #self._info_rotation = QLabel() + #add_info_widgets(QLabel("Rotation"), self._info_rotation) + + del i + self.setLayout(self._layout) self.tp = tp - self._tmp = SmallTripPointLabel(tp) - self._layout.addWidget(self._tmp) self.setLayout(self._layout) - self.setWindowTitle(f"{window_title_prefix} - trip point TODO") + self.setWindowTitle(f"{window_title_prefix} - trip point") self.show() tmp_windows[self] = self @@ -726,16 +872,93 @@ class TripPointWindow(QWidget): self._layout.addWidget(self._plot_history_button) self._plot_history_button.clicked.connect(self.plot_history) - self._json = QLabel(pprint.pformat(tp.json)) - self._layout.addWidget(self._json) + self._show_json = QPushButton("Show json") + self._layout.addWidget(self._show_json) + self._show_json.clicked.connect(self.show_json) + + + self.fill_data() + + def show_json(self): + w = QTextEdit() + w.setAcceptRichText(False) + w.setText(pprint.pformat(self.tp.json)) + w.resize(600,700); + w.setWindowTitle(f"{window_title_prefix} - raw json") + tmp_windows[w] = w + w.show() + + + @asyncSlot() + async def fill_data(self): + tp = self.tp + + self._info_origin_time.set_dt(tp.origin_timestamp) + self._info_capture_time.set_dt(tp.capture_time) + #self._info_origin_time.setText(self._info_origin_time.text() + (tp.capture_time - tp.origin_timestamp).total_seconds()} s before capture") + + self._info_openapi_delay.setText(format_delay_s(tp.openapi_delay["actual"])) + self._info_openapi_delay_arrival.setText(format_delay_s(tp.openapi_delay["last_stop_arrival"])) + self._info_openapi_delay_departure.setText(format_delay_s(tp.openapi_delay["last_stop_departure"])) + + self._info_start_timestamp.set_dt(tp.start_timestamp) + + trip_json = tp.json["properties"]["trip"] + self._info_trip_id.setText(tp.trip.trip_id) + self._info_trip_name.setText(trip_json["gtfs"]["trip_short_name"] or "") + self._info_headsig.setText(trip_json["gtfs"]["trip_headsign"]) + self._info_route.setText(trip_json["origin_route_name"] or trip_json["gtfs"]["route_short_name"]) + self._info_vehicle_type.setText((trip_json["vehicle_type"]["description_en"] if trip_json["vehicle_type"] else "undef") + + (" ❄️" if trip_json["air_conditioned"] else "") + + (" 🔌" if trip_json["usb_chargers"] else "") + + (" ♿" if trip_json["wheelchair_accessible"] else "") + ) + self._info_sequence.setText(str(trip_json["sequence_id"])) + + self._info_openapi_shape_dist.setText(str(tp.openapi_shape_dist_traveled)) + + self._info_agency.setText(str(trip_json['agency_name']['scheduled']) + " / " + str(trip_json['agency_name']['real'])) + + self._info_state.setText(tp.state_position) + + + + + + g = gtfs.for_date(tp.origin_timestamp) + await g.load_stops() + + def fill_stop(json, obj): + if json is not None: + stop = g.stops.get(json["id"], None) + obj.id.setText(json["id"]) + obj.name.setText(stop.name if stop else "") + obj.arrival.set_dt((datetime.datetime.fromisoformat(json["arrival_time"]))) + obj.departure.set_dt(datetime.datetime.fromisoformat(json["departure_time"])) + else: + obj.name.setText("") + obj.arrival.set_dt(None) + obj.departure.set_dt(None) + + fill_stop(tp.json["properties"]["last_position"]["last_stop"], self._info_last_stop) + fill_stop(tp.json["properties"]["last_position"]["next_stop"], self._info_next_stop) + + #stops = g.stops + #last_stop = stops.get(tp.json["properties"]["last_position"]["last_stop"]["id"], None) + #next_stop = stops.get(tp.json["properties"]["last_position"]["next_stop"]["id"], None) + #if last_stop and next_stop: + #lat = next_stop.lat - last_stop.lat + #lon = next_stop.lon - last_stop.lon + #atan = math.atan2(lat, lon*data_utils.lon_muntiplicator)/math.pi*180 + #rotation = - atan + #self._info_rotation.setText(f"{lat} {lon} -> {atan} -> {rotation}") @asyncSlot() async def plot_history(self): history_widget = self.mwnd.add_trip_history_widget(self.tp.trip) - await history_widget.load(self.tp.capture_time-datetime.timedelta(hours=0.4), self.tp.capture_time) await history_widget.load_gtfs_shape() await history_widget.load_stops() - history_widget.redraw_all() + await history_widget.load(self.tp.capture_time-datetime.timedelta(hours=0.1), self.tp.capture_time) #await self.mwnd.plot_gtfs_shape(self.tp) #await self.mwnd.plot_history(self.tp, self.tp.capture_time-datetime.timedelta(minutes=30), self.tp.capture_time) @@ -752,6 +975,120 @@ class LayerWidget(QWidget): self._layout.addWidget(self._title) +class LoadRangeWidget(QWidget): + def __init__(self, mwnd, route_id): + super().__init__() + self.route_id = route_id + self.cb_clear = lambda: None + self.cb_add_preprocessed_data = lambda data, dt_from, dt_to: None + self.cb_data_changed = lambda: None + self.cb_add_live_data = lambda: error("Live not supported") + + self._layout = QVBoxLayout() + self.setLayout(self._layout) + self._times_layout = QGridLayout() + self._layout.addLayout(self._times_layout) + + self.dt_from = self.dt_to = None + + self.live = False + self._dt_from = TimeLabel(mwnd) + self._dt_to = TimeLabel(mwnd) + self._times_layout.addWidget(QLabel("Loaded from:"), 0, 0) + self._times_layout.addWidget(self._dt_from, 0, 1) + self._times_layout.addWidget(QLabel("Loaded to:"), 1, 0) + self._times_layout.addWidget(self._dt_to, 1, 1) + + self._buttons = QToolBar() + self._layout.addWidget(self._buttons) + self._load_previous = QAction(QIcon(":images/themes/default/temporal_navigation/rewindToStart.svg"), "Load previous") + self._load_previous.triggered.connect(self.load_prev) + self._buttons.addAction(self._load_previous) + self._reload = QAction(QIcon(":images/themes/default/mActionReload.svg"), "Reload all") + self._buttons.addAction(self._reload) + self._reload.triggered.connect(self.select_and_reload) + self._load_next = QAction(QIcon(":images/themes/default/temporal_navigation/skipToEnd.svg"), "Load next") + self._buttons.addAction(self._load_next) + self._load_next.triggered.connect(self.load_next) + self._start_live = QAction(QIcon(":images/themes/default/temporal_navigation/forward.svg"), "Load to now and start live updates") + self._buttons.addAction(self._start_live) + self._start_live.triggered.connect(self.start_live) + self._stop_live = QAction(QIcon(":images/themes/default/temporal_navigation/pause.svg"), "Stop live updates") + self._buttons.addAction(self._stop_live) + self._stop_live.triggered.connect(self.stop_live) + + def update_dt_labels(self): + self._dt_from.set_dt(self.dt_from) + self._dt_to.set_dt(self.dt_to) + + async def clear(self, dt_from, dt_to): + self.dt_from = self.dt_to = None + self.update_dt_labels() + await self.cb_clear() + + @asyncSlot() + async def load_prev(self): + await self.load(self.dt_from-datetime.timedelta(minutes=15), self.dt_from-datetime.timedelta(seconds=20)) + + @asyncSlot() + async def load_next(self): + await self.load(self.dt_to+datetime.timedelta(seconds=10), self.dt_to+datetime.timedelta(minutes=15)) + + def select_and_reload(self): + ... + + @asyncSlot() + async def start_live(self): + if not self.live: + if (datetime.datetime.now(local_timezone) - self.dt_to).total_seconds() > 60*60: + error("Last data is too old to start live") + return + await self.load(self.dt_to, datetime.datetime.now(local_timezone), send_changed=False) + if (datetime.datetime.now(local_timezone) - self.dt_to).total_seconds() > 30: + error("Couldn't fetch data just before now, couldn't continue with live.") + return + self.live = True + live_subscribers.add(self.live_data_arrived) + spawn_or_remove_fetch_live_task() + if live_data: + await self.cb_add_live_data(live_dt, live_data, live_data_by_trip_id) + await self.cb_data_changed() + + def stop_live(self): + if self.live: + self.live = False + live_subscribers.remove(self.live_data_arrived) + spawn_or_remove_fetch_live_task() + + async def live_data_arrived(self, dt, data, data_by_trip_id): + if self.live: + await self.cb_add_live_data(dt, data, data_by_trip_id) + self.dt_to = dt + self.update_dt_labels() + else: + pprint("live data arrived but we are not in live mode") + + async def load(self, dt_from, dt_to, strict=False, send_changed=True): + c = await get_communication() + dt = dt_from.replace(second=0, microsecond=0, minute=0) + while dt < dt_to: + data, source_timestamps = await c.get_preprocessed_data(dt, self.route_id) + data = await unzip_parse(data) + await self.cb_add_preprocessed_data(data, dt_minf, dt_pinf) + if len(source_timestamps): + print(self.dt_from, self.dt_to) + print(source_timestamps[0], source_timestamps[-1]) + self.dt_from = min(source_timestamps[0], self.dt_from) if self.dt_from else source_timestamps[0] + self.dt_to = max(source_timestamps[-1], self.dt_to) if self.dt_to else source_timestamps[-1] + print(self.dt_from, self.dt_to) + self.update_dt_labels() + dt += datetime.timedelta(hours=1) + if send_changed: + await self.cb_data_changed() + + + + class TripHistoryWidget(QWidget): def __init__(self, trip, mwnd): super().__init__() @@ -787,6 +1124,13 @@ class TripHistoryWidget(QWidget): self._stops_layer = LayerWidget([self.stops_layer], "Stops layer") self._layout.addWidget(self._stops_layer) + self._load_range = LoadRangeWidget(self.mwnd, self.trip.trip_id.split("_")[0]) + self._load_range.cb_add_preprocessed_data = self.add_preprocessed_data + self._load_range.cb_data_changed = self.redraw_all_async + self._load_range.cb_add_live_data = self.add_live_data + self._layout.addWidget(self._load_range) + + self._show_graph = QPushButton("Show graph") self._show_graph.clicked.connect(self.show_graph) self._layout.addWidget(self._show_graph) @@ -801,8 +1145,18 @@ class TripHistoryWidget(QWidget): self.mwnd.map.removeLayer(self.history_points_layer) + async def add_preprocessed_data(self, data, dt_from, dt_to): + if self.trip.trip_id in data: + self.th.add_preprocessed_data(data[self.trip.trip_id]) + + async def add_live_data(self, dt, data, data_by_trip_id): + self.th.add_history_point(dt, data_by_trip_id[self.trip.trip_id]) + self.redraw_all() + async def load(self, dt_from, dt_to): + await self._load_range.load(dt_from, dt_to) + return c = await get_communication() dt = dt_from.replace(second=0, microsecond=0, minute=0) while dt < dt_to: @@ -810,10 +1164,9 @@ class TripHistoryWidget(QWidget): print("P1") data = await unzip_parse(data) print("P3") - if self.trip.trip_id in data: - self.th.add_preprocessed_data(data[self.trip.trip_id]) dt += datetime.timedelta(hours=1) + async def load_stops(self): print("LOAD STOPS") c = await get_communication() @@ -923,6 +1276,9 @@ class TripHistoryWidget(QWidget): self.redraw_history_layers() self.redraw_history_shape_mapping_layer() + async def redraw_all_async(self): + self.redraw_all() + async def load_gtfs_shape(self): await self.th.load_gtfs_shape() self.redraw_gtfs_shape_layer() @@ -931,7 +1287,7 @@ class TripHistoryWidget(QWidget): def show_graph(self): if self.graph is None: print(self.th.stops) - self.graph = graph.HistoryGraph(self.th.stops) + self.graph = graph.HistoryGraph(self.th.stops, self.th.trip.trip_id.split("_")[0]) self.graph.closed.connect(self.clear_graph) self.graph.add_trip(self.th) self.graph.show() diff --git a/prog/style.qml b/prog/style.qml index 5bedb4004c41ec670bffc8b5f32f7a4d6107f5bf..a797f365828db1f211c55d4ce3ee67b4dbb7ee53 100644 --- a/prog/style.qml +++ b/prog/style.qml @@ -112,9 +112,9 @@ <Option name="transformer" type="Map"> <Option name="d" type="Map"> <Option name="exponent" value="0.57" type="double"/> - <Option name="maxSize" value="2.4" type="double"/> + <Option name="maxSize" value="3.5" type="double"/> <Option name="maxValue" value="360" type="double"/> - <Option name="minSize" value="2.4" type="double"/> + <Option name="minSize" value="3.5" type="double"/> <Option name="minValue" value="0" type="double"/> <Option name="nullSize" value="0" type="double"/> <Option name="scaleType" value="2" type="int"/> @@ -232,9 +232,9 @@ <Option name="transformer" type="Map"> <Option name="d" type="Map"> <Option name="exponent" value="0.57" type="double"/> - <Option name="maxSize" value="2.4" type="double"/> + <Option name="maxSize" value="3.5" type="double"/> <Option name="maxValue" value="360" type="double"/> - <Option name="minSize" value="2.4" type="double"/> + <Option name="minSize" value="3.5" type="double"/> <Option name="minValue" value="0" type="double"/> <Option name="nullSize" value="0" type="double"/> <Option name="scaleType" value="2" type="int"/> @@ -352,9 +352,9 @@ <Option name="transformer" type="Map"> <Option name="d" type="Map"> <Option name="exponent" value="0.57" type="double"/> - <Option name="maxSize" value="2.4" type="double"/> + <Option name="maxSize" value="3.5" type="double"/> <Option name="maxValue" value="360" type="double"/> - <Option name="minSize" value="2.4" type="double"/> + <Option name="minSize" value="3.5" type="double"/> <Option name="minValue" value="0" type="double"/> <Option name="nullSize" value="0" type="double"/> <Option name="scaleType" value="2" type="int"/> @@ -472,9 +472,9 @@ <Option name="transformer" type="Map"> <Option name="d" type="Map"> <Option name="exponent" value="0.57" type="double"/> - <Option name="maxSize" value="2.4" type="double"/> + <Option name="maxSize" value="3.5" type="double"/> <Option name="maxValue" value="360" type="double"/> - <Option name="minSize" value="2.4" type="double"/> + <Option name="minSize" value="3.5" type="double"/> <Option name="minValue" value="0" type="double"/> <Option name="nullSize" value="0" type="double"/> <Option name="scaleType" value="2" type="int"/> @@ -592,9 +592,9 @@ <Option name="transformer" type="Map"> <Option name="d" type="Map"> <Option name="exponent" value="0.57" type="double"/> - <Option name="maxSize" value="2.4" type="double"/> + <Option name="maxSize" value="3.5" type="double"/> <Option name="maxValue" value="360" type="double"/> - <Option name="minSize" value="2.4" type="double"/> + <Option name="minSize" value="3.5" type="double"/> <Option name="minValue" value="0" type="double"/> <Option name="nullSize" value="0" type="double"/> <Option name="scaleType" value="2" type="int"/> @@ -714,9 +714,9 @@ <Option name="transformer" type="Map"> <Option name="d" type="Map"> <Option name="exponent" value="0.57" type="double"/> - <Option name="maxSize" value="2.4" type="double"/> + <Option name="maxSize" value="3.5" type="double"/> <Option name="maxValue" value="360" type="double"/> - <Option name="minSize" value="2.4" type="double"/> + <Option name="minSize" value="3.5" type="double"/> <Option name="minValue" value="0" type="double"/> <Option name="nullSize" value="0" type="double"/> <Option name="scaleType" value="2" type="int"/> @@ -805,8 +805,8 @@ </selectionSymbol> </selection> <labeling type="rule-based"> - <rules key="{7d199803-2f52-406f-9b11-3e7f0023912b}"> - <rule key="{47a4e9cb-3d7e-4298-95f4-ef970c2c129c}" filter=""vehicle_type" = 'tramvaj'" scalemindenom="1" scalemaxdenom="30000"> + <rules key="{8a882773-ce69-43f3-aa19-73bb6eaf119f}"> + <rule key="{1b1868c3-1b86-4642-a895-26a85c93e179}" filter=""vehicle_type" = 'tramvaj'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="255,1,1,255,rgb:1,0.00392156862745098,0.00392156862745098,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -930,7 +930,7 @@ </callout> </settings> </rule> - <rule key="{54ca7404-395f-4156-91a2-79e39ff03c8f}" filter=""vehicle_type" = 'trolejbus'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{efb88fc7-1970-429c-ad0a-9573696d53c6}" filter=""vehicle_type" = 'trolejbus'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="5,255,1,255,rgb:0.0196078431372549,1,0.00392156862745098,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1054,7 +1054,7 @@ </callout> </settings> </rule> - <rule key="{07420885-74ef-48a8-9b1e-749885a10eba}" filter=""vehicle_type" = 'autobus'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{db20f50a-c885-4dee-bf61-c93c9092adad}" filter=""vehicle_type" = 'autobus'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="1,26,255,255,rgb:0.00392156862745098,0.10196078431372549,1,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1178,7 +1178,7 @@ </callout> </settings> </rule> - <rule key="{a2374761-8c03-4533-940e-a532a74d7da1}" filter=""vehicle_type" = 'smluvní spoj'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{69bd12b9-5d68-41f7-a45e-76454bf5282b}" filter=""vehicle_type" = 'smluvní spoj'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="255,179,1,255,rgb:1,0.70196078431372544,0.00392156862745098,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1302,7 +1302,7 @@ </callout> </settings> </rule> - <rule key="{f26909f7-710d-4388-8734-e5629dcc0b23}" filter=""vehicle_type" = 'loď'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{a34cedcb-0bf4-467f-b8b2-bae99c2a83b3}" filter=""vehicle_type" = 'loď'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="0,0,0,255,rgb:0,0,0,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1426,7 +1426,7 @@ </callout> </settings> </rule> - <rule key="{6908df92-09fa-4869-a43f-4a290ea4f5b9}" filter=""vehicle_type" = 'náhradní doprava'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{b0df926b-a2ef-4d50-bddf-819ba4f3c7b6}" filter=""vehicle_type" = 'náhradní doprava'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="1,26,255,255,rgb:0.00392156862745098,0.10196078431372549,1,1" textOpacity="1" fontKerning="1" fontUnderline="1" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1550,7 +1550,7 @@ </callout> </settings> </rule> - <rule key="{5e30f856-b07d-4ec0-9567-b0efc34fe009}" filter=""vehicle_type" = 'regionální autobus'" scalemindenom="1" scalemaxdenom="40000"> + <rule key="{2ad63a67-05e4-4ff1-964f-3d9aaacbf576}" filter=""vehicle_type" = 'regionální autobus'" scalemindenom="1" scalemaxdenom="40000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="183,1,255,255,rgb:0.71764705882352942,0.00392156862745098,1,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1674,7 +1674,7 @@ </callout> </settings> </rule> - <rule key="{ef8b24d3-8d11-459a-bae3-fd3bf8cf86fa}" filter=""vehicle_type" = 'metro' AND "route_short_name" = 'A'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{3de8a380-771c-4ca8-88a7-2078b9d3fd8d}" filter=""vehicle_type" = 'metro' AND "route_short_name" = 'A'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="255,255,255,255,rgb:1,1,1,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1798,7 +1798,7 @@ </callout> </settings> </rule> - <rule key="{fdf413ee-fc2a-44e3-8e0a-9c67b4a7486c}" filter=""vehicle_type" = 'metro' AND "route_short_name" = 'B'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{2b31ecbd-a799-4e93-8352-72f66d169643}" filter=""vehicle_type" = 'metro' AND "route_short_name" = 'B'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="255,255,255,255,rgb:1,1,1,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -1922,7 +1922,7 @@ </callout> </settings> </rule> - <rule key="{ee7358f7-0041-4be9-93f0-5076b999a471}" filter=""vehicle_type" = 'metro' AND "route_short_name" = 'C'" scalemindenom="1" scalemaxdenom="30000"> + <rule key="{13ca571b-83ea-429f-80e5-8fbbe0850b6d}" filter=""vehicle_type" = 'metro' AND "route_short_name" = 'C'" scalemindenom="1" scalemaxdenom="30000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="255,255,255,255,rgb:1,1,1,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -2046,7 +2046,7 @@ </callout> </settings> </rule> - <rule key="{db037816-c2dc-4673-ad91-3ce901e828d4}" filter=""vehicle_type" = 'vlak'" scalemindenom="1" scalemaxdenom="60000"> + <rule key="{4cc61794-bc84-4634-9052-06a4909a108e}" filter=""vehicle_type" = 'vlak'" scalemindenom="1" scalemaxdenom="60000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="255,255,255,255,rgb:1,1,1,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> @@ -2170,7 +2170,7 @@ </callout> </settings> </rule> - <rule key="{c107065a-c6a3-4a0c-abd7-8094885f9e0e}" filter=""vehicle_type" = 'vlak' AND "route_short_name" LIKE 'R%'" scalemindenom="1" scalemaxdenom="100000"> + <rule key="{b1eb89fc-e57a-4699-ac6e-74476060c412}" filter=""vehicle_type" = 'vlak' AND "route_short_name" LIKE 'R%'" scalemindenom="1" scalemaxdenom="100000"> <settings calloutType="simple"> <text-style tabStopDistanceUnit="Point" forcedItalic="0" fontFamily="Open Sans" fontWordSpacing="0" fontItalic="0" fontSize="10" fontSizeMapUnitScale="3x:0,0,0,0,0,0" isExpression="0" fontStrikeout="0" forcedBold="0" capitalization="0" blendMode="0" textColor="255,37,52,255,rgb:1,0.14509803921568629,0.20392156862745098,1" textOpacity="1" fontKerning="1" fontUnderline="0" multilineHeight="1" fontWeight="50" fontLetterSpacing="0" fontSizeUnit="Point" stretchFactor="100" namedStyle="Regular" allowHtml="0" fieldName="route_short_name" legendString="Aa" textOrientation="horizontal" previewBkgrdColor="255,255,255,255,rgb:1,1,1,1" tabStopDistance="80" useSubstitutions="0" multilineHeightUnit="Percentage" tabStopDistanceMapUnitScale="3x:0,0,0,0,0,0"> <families/> diff --git a/prog/utils.py b/prog/utils.py index 9e9f953a89d9661f5ab77e051532484ccb4caad0..5c520d435f22f9025cd7e9aa6e1e7b36e2b3effb 100644 --- a/prog/utils.py +++ b/prog/utils.py @@ -1,6 +1,7 @@ import sys, os import traceback import datetime +from typing import Optional def eprint(*args): print(*args, file=sys.stderr, flush=True) @@ -15,3 +16,22 @@ async def catch_all(corutine, exitcode=None): exit(exitcode) local_timezone = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo +dt_pinf = datetime.datetime(2100, 1, 1, tzinfo=local_timezone) +dt_minf = datetime.datetime(2000, 1, 1, tzinfo=local_timezone) + + +def format_dt_to_user(dt: datetime.datetime) -> str: + return dt.strftime("%Y-%m-%d %H:%M:%S") + +def parse_dt_from_user(val: str) -> Optional[datetime.datetime]: + return datetime.datetime.strptime(val, "%Y-%m-%d %H:%M:%S").replace(tzinfo=local_timezone) + +def format_delay_s(s): + if s is None: + return "–" + if s < 0: return "- " + format_delay_s(-s) + if s == 0: return "on time" + if s < 60: return f"{s} s" + if s < 10*60: return f"{s//60} min {s%60} s" + if s < 60*60: return f"{s//60} min" + if s < 60*60: return f"{s//60//60} h {s//60} min"