Tramwaje warszawskie

Dzisiaj zajmiemy się ruchem tramwajów w Warszawie. Jak zmienia się ruch w ciągu dnia? Jakie i ile tramwajów jeździ po warszawskich torach? Inspiracją do tego wpisu jest filmik obrazujący podobne dane z Nowego Jorku.

Ekstra, co nie? Zrobimy to samo. Bo możemy, bo chcemy, bo potrafimy.

Źródło i pobranie danych

Skorzystamy z otwartych danych, jakie dostępne są na stronie Otwarte dane po warszawsku. Na początek potrzebna będzie rejestracja na tej stronie i uzyskanie klucza API, który pozwoli na dostęp do różnych ciekawych danych jakie udostępnia Miasto Stołeczne Warszawa. Po uzyskaniu klucza API koniecznie będzie wpisanie jego wartości w miejsce NUMER_APIKEY w poniższym kodzie.

library(jsonlite)

# tutaj wstaw swój API Key
apikkey <- "&apikey=NUMER_APIKEY"

api_url <- "https://api.um.warszawa.pl/api/action/wsstore_get?id=c7238cfe-8b1f-4c38-bb4a-de386db7e776"

tramwaje <- data.frame()
i <- 1

# nieskończona pętla pobierająca i zapisująca lokalnie dane
while(TRUE) {
    dane <- fromJSON(paste0(api_url, apikkey))
    dane_df <- dane$result
    dane_df$frame <- i
    
    # sprawdzamy czy coś pobraliśmy i jeśli tak - dodajemy do wcześniejszych danych
    if(!is.null(nrow(dane$result))) {
       tramwaje <- rbind(tramwaje, dane_df)
       i <- i + 1
       saveRDS(tramwaje, file="tramwaje.RDS")
    }

    # czekamy minutę
    Sys.sleep(60)
}

Ten krótki kod sięgnie do API i pobierze plik JSON (dokładny opis struktury znajdziecie w dokumencie pdf na stronie z danymi), dane przetransformuje w ramkę i doda do niej kolejny numer frame. Numer ten pozwoli nam później na wygenerowanie kolejnych “klatek” animacji. Później czekamy minutę i operację powtarzamy, a nowe dane doklejamy do wcześniej pobranych. Pętla jest nieskończona - po zebraniu interesującej nas liczby danych (np. doba) trzeba go przerwać. Aby nie utracić pobranych danych - po każdym pobraniu nowej partii zapisujemy całość lokalnie do pliku tramwaje.RDS. Ten proces jest najdłuższy - ponieważ nie mamy gotowych danych, musimy je pobrać. A że potrzebujemy całej doby to trwa to całą dobę… Żebyście nie musieli tyle czekać - dane z całej doby znajdziecie tutaj. Czytając dokumentację widzimy, że znajdziemy położenie (długość i szerokość) geograficzne danego tramwaju, numer linii jaką obsługuje i numer składu (brygady) dla którego podane jest położenie oraz określenie czy zadanie realizuje tramwaj niskopodłogowy. Skorzystamy z tych danych i wybierzemy jedną brygadę (ja wybrałem trójkę) aby w animacji zaznaczyć poruszający się tramwaj oraz - zobaczymy jaki udział mają składy niskopodłogowe.

Edycja danych (czyszczenie)

Już po 15-30 minutach mamy trochę danych i jeśli jesteście niecierpliwi możecie się im przyjrzeć od razu. Warto jednak trochę poczekać. Tak czy inaczej trzeba dane wyczyścić z wartości błędnych (zdarzają się takie).

library(dplyr)
library(ggplot2)
library(lubridate)
library(gridExtra)

tramwaje <- tramwaje %>%
   # granice Warszawy - doświadczalnie wybrane :)
   filter(Lon >= 20.8, Lat >= 52.1) %>%
   filter(Lon <= 21.2, Lat <= 52.5) %>%
   # format daty na bardziej przyjazny + godzina i minuty osobno
   mutate(Time = ymd_hms(Time)) %>%
   mutate(Date = date(Time), Hour = hour(Time), Minute = minute(Time)) %>%
   filter(Hour!=0, Minute!=0)

tramwaje$Brigade <- trimws(tramwaje$Brigade)
tramwaje$FirstLine <- as.numeric(tramwaje$FirstLine)

theme_set(theme_minimal())

Przegląd i pierwsze analizy

Z oczyszczonych danych możemy narysować z położeniem wszystkich pobranych składów. Im więcej danych tym bardziej dokładna mapka. Ja mam dane z całej doby (wirtualny komputer popracował trochę), więc widać wszystko bardzo ładnie:

ggplot(tramwaje) + geom_point(aes(Lon, Lat)) + coord_map()

Moje dziecko młodsze powiedziało “czarny pająk!” :D Widać jeszcze pojedyncze śmieci, ale nie warto się nimi przejmować. Dla mieszkańców Warszawy rozpoznawalne są poszczególne końcówki tras (na przykład Gocławek w prawym dolnym rogu; Nowodwory najbardziej na północnym wschodzie; na południu z jednej strony Okęcie, a bliżej Wisły - Służewiec i Wyścigi). Ale żeby było łatwiej - nałóżmy tą siatkę na mapę.

# mapa do tła
library(ggmap)
map_waw <- get_map("Warszawa, Polska", zoom=11)

ggmap(map_waw) + geom_point(data=tramwaje, aes(Lon, Lat))

Przy tej okazji można zauważyć ciekawostki - kiedy każdą z linii narysujemy na oddzielnej mapce (to co wyżej uzupełnione o +facet_wrap(~FirstLine)) otrzymamy trasy, którymi dana linia przemieszczała się przez całą dobę. Ktoś myślał, że tramwaje jeżdżą zawsze tą samą trasą? Otóż nie! Sprawdźcie sami. Należy z danych wybrać określoną linię (filter() z odpowiednim FirstLine) i na przykład na siatce narysować jej trasę przy rozdzieleniu na godziny i składy (facet_grid() z Hour i Brigade). Dlaczego tak jest? Nie mam pojęcia - pewnie ZTM Warszawa ma w tym jakiś zamysł. Może być też tak, że są to “przejazdy techniczne” lub inne “zjazdy do zajezdni”. Zobaczmy jak wyglądała liczba tramwajów w ruchu dla poszczególnych pobranych danych (“ramek” animacji) - czyli w czasie, chociaż nie będziemy pokazywać tutaj godzin a numer pomiaru. Przy okazji podzielmy dane na rodzaj składu (niskopodłogowy i nie).

tramwaje %>%
   # tylko tramwaje w ruchu
   filter(Status=="RUNNING") %>%
   count(frame, LowFloor) %>%
   ungroup() %>%
   ggplot() +
   geom_line(aes(frame, n, color=LowFloor)) +
   labs(x="Numer próbki danych", y="Liczba tramwajów w ruchu")

Widać zmiany razem z porą dnia, widać też jakieś pojedyncze piki (te szpilki w dół) wynikające z błędnych danych w API. Można pokusić się o ich wygładzenie, ale szczerze powiedziawszy nie mają one większego znaczenia - interesuje nas trend. Poza tym - uważam, że w takim zadaniu nie warto. Zadaniem analityka jest dostarczyć użyteczne dane szybko. Szybkość informacji to o wiele większa wartość w biznesie niż 100% precyzja. Błąd na poziomie pewnie w okolicy 3% (ile jest tych szpilek? a danych mamy 1133 pomiarów…) jest w tym przypadku pomijalny. Można robić analizę tydzień i dość do błędu na poziomie promila, ale czy jest ona lepsza (w sensie przydatności biznesowej) od tej zrobionej w pół dnia z błędem 3% (lub mniejszym)? Możemy się spierać. Swoją drogą - w API powinny być też tramwaje stojące, ale jak widać:

Statusn
RUNNING282381

nie ma takowych :) Do dalszej zabawy potrzebujemy wyskalowania osi czasu. Na naszej animacji będziemy rysować wykres zgodnie z upływem czasu - kolejne punkty będą pojawiać się na wykresie, więc zakres na osi czasu był ciągle taki sam. Znajdźmy chwilę początkową i końcową w naszych danych:

t_min <- min(tramwaje$Time)
t_max <- max(tramwaje$Time)

# miejsce na informacje zagregowane w kolejnych punktach czasu
n_tramwaje <- data.frame()

# numer brygady - o tym niżej
brygada <- "3"

Dla kolejnych ramek przygotujemy pakiet wykresów i mapkę. Te elementy umieścimy na jednym obrazku, który zapiszemy lokalnie. Później (już poza R) całą armię obrazków połączymy w animację - klatka po klatce.

Przygotowanie animacji - składowe elementy

W pętli na początku wybieramy dane z i-tej ramki:

for(i in 1:max(tramwaje$frame)) {
   # tramwaje w minutowym okienku
   tmp_tramwaje <- filter(tramwaje, frame==i)

   # ile wszystkich tramwajów w ruchu?
   n_tramwaje <- rbind(n_tramwaje,
                       data.frame(t=mean(tmp_tramwaje$Time),
                                  n=nrow(tmp_tramwaje),
                                  l=nrow(tmp_tramwaje[tmp_tramwaje$LowFloor==TRUE,])))

Teraz przygotowujemy mapkę z położeniem tramwajów. Żeby było ładnie wszystkie tramwaje zaznaczymy na czerwono, a te, które prowadzi jedna brygada (na każdej linii jednocześnie jest kilka składów w trasie - to właśnie poszczególne brygady, numery brygad powtarzają się globalnie, ale w danym momencie dla linii numery brygad są unikalne) - na biało i nieco większe. Tylko która brygada powinna być wyróżniona? Wybrałem brygadę numer trzy, bo jak widać takich składów jest najwięcej (globalnie, ze wszystkich danych).

   # mapa tramwajów
   p1 <- ggmap(map_waw, darken = 0.7) +
      geom_point(data=tmp_tramwaje,
              aes(Lon, Lat), color="red", size=1) +
      geom_point(data=filter(tmp_tramwaje, Brigade==brygada),
              aes(Lon, Lat), color="white", size=2) +
      theme_void() +
      theme(legend.position = "none") +
      labs(title="Położenie tramwajów",
           subtitle=substr(min(tmp_tramwaje$Time), 1, 16))

Zobaczmy jak taka mapa będzie wyglądać (dla losowo wybranej ramki, losowość w tym przypadku to tak naprawdę mediana z frame): Dodajmy do tego podział na rodzaje składów na danej linii - ile niskopodłogowych dziesiątek jedzie w danym momencie? To zobaczymy na odpowiednim słupku.

   # ile tramwajów niskopodłogowych na danej linii?
   p2 <- tmp_tramwaje %>%
      count(FirstLine, LowFloor) %>%
      ungroup() %>%
      group_by(FirstLine) %>%
      mutate(p=100*n/sum(n)) %>%
      ggplot() +
         geom_bar(aes(FirstLine, p, fill=LowFloor), stat="identity") +
         labs(title="Niskopodłogowe na liniach", x="", y="") +
         xlim(0, 50) +
         theme(legend.position = "none") +
         geom_hline(yintercept = 50, color="black")

Efektem tego kodu będzie taki wykres (znowu - tutaj na losową chwilę; na złożonej w całość animacji osie nie będą opisane): Widzimy przy okazji, że nie ma tramwajów o numerach 5, 8, 12, 16 i tak dalej - tam gdzie nie ma pasków. Teraz dla bieżącej (w ramach pętli) ramki policzmy ile mamy wszystkich składów i ile z nich jest niskopodłogowych. Te dane gromadzimy w tabeli n_tramwaje i jednocześnie rysujemy całą tabelę od początku na dwóch wykresach - ile tramwajów i jaki procent z nich to niskopodłogowe. Zobaczymy też, czy udział tramwajów niskopodłogowych jest ciągle taki sam, czy też im więcej tramwajów w trasie tym mniej niskopodłogowych (bo wielkość taboru jest stała, a nie wszystkie pociągi są niskopodłogowe). Tym razem bez obrazka :)

   # wykres ilości tramwajów
   p3 <- ggplot(n_tramwaje) +
      geom_line(aes(t, n)) +
      scale_x_datetime(lim=c(as_datetime(t_min), as_datetime(t_max))) +
      ylim(0,400) +
      labs(title="Liczba tramwajów w ruchu", x="", y="")

   # ile niskopodłogowych?
   p4 <- ggplot(n_tramwaje) +
      geom_line(aes(t, 100*l/n)) +
      scale_x_datetime(lim=c(as_datetime(t_min), as_datetime(t_max))) +
      ylim(25,75) +
      labs(title="% niskopodłogowych w ruchu", x="", y="")

   # % niskopodłogowych vs liczba tramwajów
   p5 <- ggplot(n_tramwaje) +
      geom_point(aes(n, 100*l/n)) +
      xlim(0,400) +
      ylim(25,75) +
      labs(x="liczba tramwajów", y="% niskopodłogowych")

Na koniec składamy w jeden obrazek wszystkie przygotowane wykresy i zapisujemy je do pliku z kolejnym numerem. I kończymy pętlę.

   # łączymy wykresy w jeden obrazek
   plot <- arrangeGrob(p1, p2, p3, p4, p5,
                       layout_matrix=rbind(c(1,1,3),
                                           c(1,1,4),
                                           c(1,1,5),
                                           c(1,1,2)))
   
   # zapisujemy obrazek na dysku
   ggsave(paste0("pics/", sprintf("%04d", i), ".png"), plot,
          width = 16, height = 9, dpi=120)
}

Wykonanie całej pętli na moim komputerze (kilkuletni Vaio) zajęła jakieś dwie-trzy godziny. Najbardziej pracochłonne jest generowanie obrazków; bez tej części pętla przechodzi dość szybko.

Wnioski

W katalogu pics/ mamy całą masę plików wyglądających mniej więcej tak:

To jest ostatnia klatka - moment, kiedy mamy już wszystkie dane na bocznych wykresach, ale za to na mapie pusto. Pusto, bo wszystkie tramwaje już śpią w swoich tramwajowych domkach, na swoich tramwajowych poduszeczkach, przykryte tramwajowymi kołderkami z wyhaftowanymi autobusikami… taki żart, kiepski, wiem. Widać (drugi i trzeci wykres od góry w prawej kolumnie) między innymi, że udział pociągów niskopodłogowych nie jest stały. Zapewne takich pociągów jest określona liczba i jeśli na trasach jest więcej tramwajów (na przykład w godzinach szczytu co widać z pierwszego wykresu w prawej kolumnie) to ich po prostu brakuje. Dlaczego dla 250 jeżdżących tramwajów raz niskopodłogowych jest jakieś 55%, a innym razem około 65%? Ciekawa to zmiana, będzie ją fajnie widać na animacji - zaobserwujcie wtedy gdzie przybywają kolejne punkty na trzecim od góry wykresie. Odpowiedź kryje się na drugim wykresie - w dzień niskopodłogowce są bardziej potrzebne (w dużym uproszczeniu).

Wygenerowanie animacji ze składowych (timelapse)

Możemy zamknąć R i przejść do sklejenia animacji poklatkowej. Metod jest wiele, moje “top of mind” mówi:

  • VirtualDub - używam do składania timelapsów
  • ImageMagick - korzysta z niego pakiet gganimate
  • FFmpeg którego używałem do rozdzielenia filmów na pojedyncze klatki
  • Photoshop - zapewne się da, w Photoshopie da się wszystko

Ja wybieram VirtualDub, bo wygenerowanie poklatkowej animacji sprowadza się do kilku kroków:

  • uruchamiamy VirtualDub
  • wybieramy z menu opcję Otwórz File/Open video file…
  • wskazujemy pierwszy obrazek z całej serii, ważne - kolejne klatki muszą mieć kolejne numery, ale o to zadbaliśmy na poziomie skryptu R
  • VirtualDub sam zaczyta kolejne pliki jako kolejne klatki
  • wybieramy kodek, który wygenerujemy wideo (Video/Compression…); ja użyłem Xvid MPEG-4 Codec
  • ewentualnie konfigurujemy ustawienia kodeka (Configure w oknie, w którym wybieramy kodek)
  • ustawiamy ilość klatek na sekundę (Video/Frame rate…) - ja zmieniłem z domyślnych 25fps na 12 (Change frame rate to (fps))
  • zapisujemy film (uruchamiając tym samym konwersję) w wybranym pliku (File/Save as AVI…)
  • kiedy VirtualDub zrobi swoje będziemy mieć gotowy filmik

Tak samo (w najprostszej wersji) robi się timelapse’y i jest to o tyle banalne, że pliki z aparatu są od razu ponumerowane. VirtualDub poradził sobie z przygotowaniem animacji w kilkanaście minut. Samo napisanie całego kodu z weryfikacją czy wszystko wygląda i działa jak należy to może 2-3 godziny, z czego 75% czasu to babranie się z wyglądem wykresów i mapy (kolory, przeźroczystość itp.). Podsumowując - cały proces zajął dwa dni (i noce maszynie zbierającej dane), samej pracy twórczej było z tego ze 2 godziny (2/48 daje 4%). Tak właśnie wygląda praca analityka danych - 96% nudnej roboty z przygotowaniem danych, 4% przyjemności. I wszystko to dla półtorej minuty wideo.

Gotowa animacja

Wynikowa animacja wygląda następująco (polecam full screen i full HD): Czad. Można dopracować kolory, układ, cokolwiek. Można przyczepić się do wielu elementów. Nie ma bunkrów, a i tak jest zajebiście.