Niemand will in einem Nebelloch wohnen, oder in einer Gegend, in der der Himmel dauernd grau in grau ist. Blauer Himmel und Sonnenschein, das ist Lebensqualität! Auch Makler-Listings betonen oft "helle" Wohnungen, aber ob die Lage überhaupt viel Sonne hergibt, darüber finden Interessenten oft nichts. Ja, es soll sogar schon vorgekommen sein, dass manche Käufer Immobilien ungesehen, nur aufgrund von Internet-Listings erwarben! Das ist regelrecht frevelhaft, denn eine kurze Anfrage an den Wetter-Server des Weather-Underground-Netzwerks fördert historische Wetterdaten zutage, und stellt kompromisslos fest, ob die Immobilie in einem Nebelloch oder einem sonnigen Stadtteil liegt.
Der Weather-Underground ([3]) aggregiert laut seiner Homepage Live-Wetterdaten von mehr als 250.000 privaten Wetterstationen (Personal Weather Station, PWS). Wie meine eigene PWS auf unserem Hausdach in San Francisco montiert ist, habe ich in der Mai-Ausgabe des Snapshots [2] erläutert. Ein Wetterfühler vom Typ EcoWitt misst die aktuellen Wetterdaten und leitet sie stetig durchs Internet an den Wetterserver weiter. Der archiviert sie, und stellt aktuelle und historische Daten für jedermann ins Netz.
Nun hat der Weather Underground eine interessante Firmengeschichte hinter sich: Gegründet als Uni-Projekt im Jahr 1993, 1995 als kommerzielles Projekt ausgekoppelt, verkauft im Jahr 2012 an die Firma The Weather Channel, 2015 gekauft von IBM, bevor schließlich die Private-Equity-Firma Francisco Partners 2023 die Firma ausschlachtete und nur die Wetter-Bereiche übernahm.
|
| Abbildung 1: Aktuelle Messdaten von Mike Schillis Wetterstation in San Francisco |
Auch heute noch antwortet wunderground.com (allerdings leicht schleppend) auf Web-Anfragen zu aktuellen Wetterdaten angeschlossener Stationen (Abbildung 1). Historische Daten von Stationen rund um den Globus gibt es hingegen vom kostenpflichtigen Server auf api.weather.com per API-Zugriff. Einen kostenlosen API-Key für Entwickler stellt dabei weather.com zeitbegrenzt für 30 Tage (Abbildung 2), und wer selbst eine Wetterstation im Weather-Underground-Netzwerk betreibt, bekommt auf wunderground.com ebenfalls einen API-Key, der für 100 Requests pro Minute und 50.000 pro Tag gut ist, und, das Beste, zeitlich nicht begrenzt ist. Für einen Enterprise-Key ruft weather.com absurde $500/Monat ab, was sich für private Zwecke verbietet.
|
| Abbildung 2: Unterschiedliche Abos für die Wetterdaten |
Die über 250.000 Wetterstationen im Verbund pumpen nun kontinuierlich ihre Messdaten durchs Internet an den Server auf weather.com und der speichert sie für die Ewigkeit. Historische Wetterdaten lassen sich später tagesgenau für alle Stationen abrufen, und wer wissen will, aus welcher Richtung der Wind am 17. März 2026 in San Franciscos Viertel Noe Valley gepfiffen hat, ob es an dem Tag regnete oder die Sonne prall vom Himmel schien, der fördert die Daten mit einer simplen API-Abfrage an den Server zutage.
Die API bietet eine Vielzahl von Endpoints. Für die aktuelle Ausgabe des Programmier-Snapshots sehen wir uns zwei an: /near liefert die nächsten 10 Wetterstationen um eine Geolocation aus geografischer Länge und Breite, und /daily gibt zu einer bestimmten Wetterstation die Tagesmessdaten innerhalb eines vorgegebenen Zeitraums zurück.
Abhängig von geografischen Besonderheiten herrscht in manchen Städten nicht nur ein einziges Klima, sondern eine Vielzahl sogenannter Micro-Climates. San Francisco ist so eine Stadt, mit dem Mini-Gebirge Twin Peaks in der Mitte, das den Nebel vom Pazifischen Ozean abblockt, sodass die östlichen Stadtteile durchgehend mehr Sonne und damit angenehmere Temperaturen bekommen.
Da kommt es gerade recht, dass in solchen Ballungsgebieten typischerweise eine Vielzahl von Wetterstationen quer über die Stadt verteilt stehen, die ihre ganz spezifischen Stadtteilmessdaten auf dem Wetterserver ablegen. Wie lässt sich nun aus den Wetterdaten ableiten, ob eine Gegend ein Nebelloch oder eine sonnenverwöhnte Wellness-Oase ist? Als Beispielanwendung holt die heute vorgestellte App die Wetterdaten des Monats November ein, und fischt sich aus allen verfügbaren Messwerten (Kasten 1) die höchste gemessene Sonneneinstrahlung (solarRadiationHigh) heraus. Ein Wert über 500 bedeutet zumeist, dass am Tag der Messung ordentlich die Sonne schien. Ausgelesene Werte während der 30 Tage des Monats November werden anschließend gemittelt und das Ergebnis als Score interpretiert, zur Ermittlung eines Scores der Wetter-Bonität des Stadtteils.
Aber nicht jede Wetterstation liefert alle Werte korrekt. Bei der Sonneneinstrahlung kann es sein, dass der Fühler im Schatten steht, oder dass die Hardware keine UV-Strahlung messen kann. Kommt aus diesem Grund ein Null-Wert zurück, sollte die App die Station schlicht ignorieren und nur die mit sinnvollen Werten zur Score-Ermittlung heranziehen. Im Kasten 1 stehen noch eine Vielzahl weiterer vom API-Server eingeholter historischer Messwerte, aber um den Code kurz zu halten, nutzt die vorgestellte App nur den solarRadiationHigh-Wert. Am Ende dieser Ausgabe stehen noch ein paar Ideen zur genaueren Bestimmung.
solarRadiationHigh - Höchste gemessene Solarstrahlung
uvHigh - Höchster gemessener UV-Index
winddirAvg - Durchschnittliche Windrichtung in Grad.
humidityHigh - Höchste relative Luftfeuchte
humidityLow - Niedrigste relative Luftfeuchte
humidityAvg - Durchschnittliche relative Luftfeuchte
tempHigh - Höchste Temperatur
tempLow - Niedrigste Temperatur
tempAvg - Durchschnittliche Temperatur
windspeedHigh - Höchste Windgeschwindigkeit
windspeedLow - Niedrigste Windgeschwindigkeit
windspeedAvg - Durchschnittliche Windgeschwindigkeit
windgustHigh - Stärkste Windbö
windgustLow - Schwächste Windbö
windgustAvg - Durchschnittliche Windbö
dewptHigh - Höchster Taupunkt
dewptLow - Niedrigster Taupunkt
dewptAvg - Durchschnittlicher Taupunkt
windchillHigh - Höchste gefühlte Kälte
windchillLow - Niedrigste gefühlte Kälte
windchillAvg - Durchschnittliche gefühlte Kälte
heatindexHigh - Höchster Hitzeindex
heatindexLow - Niedrigster Hitzeindex
heatindexAvg - Durchschnittlicher Hitzeindex
pressureMax - Höchster Luftdruck
pressureMin - Niedrigster Luftdruck
pressureTrend - Trend des Luftdrucks im Tagesverlauf
precipRate - Niederschlagsrate des Tages
precipTotal - Gesamtniederschlag des Tages
Das erste vorgestellte Go-Programm aus Listing 1 landet im Binary near. Es nimmt auf der Kommandozeile einen Punkt auf der Erdkugel als geografische Länge und Breite entgegen, sowie ein Startdatum, ab dem der Code die historischen Wetterdaten des darauf anschließenden Monats abholt. Der Aufruf in Abbildung 3 holt zum Beispiel die Wetterdaten der in Berlin Mitte ansässigen Wetterstationen für den schon etwas zurückliegenden Juni 2026 ein.
Die Ergebnisse schreibt das Programm nach Stdout, im Spaltenformat mit der ID des Messfühlers, dessen geografische Länge und Breite, sowie dem Messwert für die mittlere Sonneneinstrahlung während des angeforderten Monats (Abbildung 3). Die Ausgabe meldet durchgehend sonnige Tage und blauen Himmel mit Werten um die 800. Zum Vergleich zeigt Abbildung 4 die gemittelten Messdaten sieben Monate früher, im Monat November. Drastisch niedrigere Mittelwerte um die 100 zeigen an, dass der Schlagersong "Wenn es dunkel und kalt wird in Berlin" der bekannten Combo "Element Of Crime" ([5]) durchaus seine Berechtigung hat.
|
| Abbildung 3: Mittlere Sonneneinstrahlung im Juni 2026 für Stationen in Berlin Mitte |
|
| Abbildung 4: Im November wird es kalt und dunkel in Berlin |
Erstmal liest dazu die Hauptfunktion main() ab Zeile 20 in Listing 1 die Kommandozeilenoptionen -lat, -lon und -date mit dem Standardpaket flag ein. Den geheimen API-Key sucht und findet das Murmur-Paket in der Datei ~/.murmur unter dem Schlüssel wu-api. So muss die Applikation die sensiblen Daten nicht in den öffentlich verfügbaren Code einzimmern.
001 package main
002 import (
003 "flag"
004 "fmt"
005 "time"
006 "github.com/mschilli/go-murmur"
007 "github.com/tidwall/gjson"
008 "github.com/valyala/fasthttp"
009 )
010 const (
011 BaseUrl = "https://api.weather.com/"
012 NearEP = BaseUrl + "v3/location/near"
013 DailyEP = BaseUrl + "v2/pws/history/daily"
014 )
015 type station struct {
016 stationID string
017 lat float64
018 lon float64
019 }
020 func main() {
021 latFloat := flag.Float64("lat", 0, "latitude")
022 lonFloat := flag.Float64("lon", 0, "longitude")
023 dateArg := flag.String("date", "", "start date")
024 flag.Parse()
025 m := murmur.NewMurmur()
026 apiKey, err := m.Lookup("wu-api")
027 if err != nil {
028 panic("no api key")
029 }
030 stations, err := near(*latFloat, *lonFloat, apiKey)
031 if err != nil {
032 panic(err)
033 }
034 startDate, endDate, err := dateRange(*dateArg)
035 if err != nil {
036 panic(err)
037 }
038 for _, station := range stations {
039 avg, err := sun(station.stationID, startDate, endDate, apiKey)
040 if err != nil {
041 panic(err)
042 }
043 if avg == 0 {
044 continue
045 }
046 fmt.Printf("%s\t%.6f\t%.6f\t%.2f\n", station.stationID, station.lat, station.lon, avg)
047 }
048 }
049 func dateRange(start string) (time.Time, time.Time, error) {
050 if start == "" {
051 endDate := time.Now().AddDate(0, 0, -1)
052 return endDate.AddDate(0, 0, -29), endDate, nil
053 }
054 startDate, err := time.ParseInLocation("20060102", start, time.Local)
055 if err != nil {
056 return time.Time{}, time.Time{}, fmt.Errorf("parse --date as yyyymmdd: %w", err)
057 }
058 return startDate, startDate.AddDate(0, 0, 29), nil
059 }
060 func near(lat, lon float64, apiKey string) ([]station, error) {
061 body, err := apiGet(NearEP, map[string]string{
062 "geocode": fmt.Sprintf("%.6f,%.6f", lat, lon),
063 "product": "pws", "format": "json", "apiKey": apiKey,
064 })
065 if err != nil {
066 return nil, err
067 }
068 loc := gjson.GetBytes(body, "location")
069 ids, lats, lons := loc.Get("stationId").Array(), loc.Get("latitude").Array(), loc.Get("longitude").Array()
070 stations := make([]station, 0, len(ids))
071 for i, id := range ids {
072 if i < len(lats) && i < len(lons) {
073 stations = append(stations, station{id.String(), lats[i].Float(), lons[i].Float()})
074 }
075 }
076 return stations, nil
077 }
078 func sun(stationID string, startDate, endDate time.Time, apiKey string) (float64, error) {
079 body, err := apiGet(DailyEP, map[string]string{
080 "stationId": stationID, "format": "json", "units": "m",
081 "startDate": startDate.Format("20060102"), "endDate": endDate.Format("20060102"),
082 "numericPrecision": "decimal", "apiKey": apiKey,
083 })
084 if err != nil {
085 return 0, err
086 }
087 var total float64
088 count := 0
089 for _, summary := range gjson.GetBytes(body, "observations").Array() {
090 if solar := summary.Get("solarRadiationHigh"); solar.Exists() {
091 total += solar.Float()
092 count++
093 }
094 }
095 if count == 0 {
096 return 0, nil
097 }
098 return total / float64(count), nil
099 }
100 func apiGet(ep string, params map[string]string) ([]byte, error) {
101 req := fasthttp.AcquireRequest()
102 defer fasthttp.ReleaseRequest(req)
103 req.SetRequestURI(ep)
104 for key, value := range params {
105 req.URI().QueryArgs().Set(key, value)
106 }
107 status, body, err := fasthttp.GetTimeout(nil, req.URI().String(), 15*time.Second)
108 if err != nil {
109 return nil, err
110 }
111 if status < 200 || status > 299 {
112 return nil, fmt.Errorf("HTTP %d: %s", status, body)
113 }
114 return body, nil
115 }
Der Aufruf der Funktion near() in Zeile 30 verzweigt zur Implementierung in Zeile 60, wo ein API-Call an den /near-Endpoint auf weather.com zehn Wetterstationen in der näheren Umgebung als JSON zurückliefert. Das Hauptprogramm fieselt später mit dem Paket gjson von Github deren Station-IDs heraus. Es handelt sich dabei um kurze Strings zur Identifizierung einzelner Wetterstationen im Wunderground-Netzwerk. Die Werte der ersten Spalten der Ausgabe in Abbildung 3 zeigen einige Beispiele.
Für jede dieser Wetterfühler-IDs ruft das Hauptprogramm dann in Zeile 39 die Funktion sun() auf, deren Körper ab Zeile 78 in Listing 1 steht. Jede Anfrage an den /daily-Endpoint der API mit der Station-ID und einem Zeitrahmen aus Start- und Enddatum liefert einen Wust von Wetterdaten im JSON-Format zurück. Die Namen der Felder und ihre Bedeutung illustriert der Kasten 1. Auch hier hilft gjson, interessierende Werte ohne Umschweife zu extrahieren.
Nun sind Zahlenreihen ja gut und schön, aber nur die wenigsten Menschen können sich ausmalen, in welchem Stadtviertel sich eine Wetterstation einer vorgegebenen geografischen Länge und Breite befindet. Zu Hilfe kommt hier Listing 2, das die Zahlenreihen aufschnappt und in eine Landkarte des OpenStreetMap-Projekts einzeichnet.
Dazu ruft der User die beiden aus Listing 1 und Listing 2 mittels go mod init/tidy generierten Binaries in einer Pipe auf:
$ ./near | ./heatmap
Heraus kommt eine Bilddatei im .png-Format, mit Markern, die den gemittelten Messwert der Sonneneinstrahlung am Ort der Wetterstation anzeigen. So illustriert zum Beispiel Abbildung 5 im November glorreiches Wetter in meiner Wahlheimat San Francisco. In unserem Stadtteil Noe Valley, etwa in der geografischen Mitte der Halbinsel der Stadt wurden letzten November Werte von etwa 600 für die Sonneneinstrahlung gemessen. Abbildung 6 zeigt im selben Zeitraum für Berlin Mitte das erwartete Grau-in-Grau mit Messwerten um 300. Ähnlich verhält es sich mit London, das ebenfalls nicht für sonniges Novemberwetter bekannt ist. Eine Stadt mit extrem hoher Sonnenqualität ist nach Abbildung 8 Honolulu auf der Hauptinsel Oahu des Hawaii-Archipels: Messwerte um 950 im November spiegeln das perfekte Klima der Insel wider, und aus eigener Erfahrung kann ich sagen, dass dies ganzjährig der Fall ist.
|
| Abbildung 5: Blauer Himmel im November in San Francisco |
|
| Abbildung 6: Wenn es dunkel und kalt wird in Berlin |
|
| Abbildung 7: Nebelloch London: Bedeckter Himmel im November |
|
| Abbildung 8: In Honolulu auf Hawaii ist es durchgehend sonnig. |
Listing 2 nimmt in readPoints() ab Zeile 46 die Messdaten einzelner Stationen zeilenweise entgegen und fieselt die Einzelwerte mit Sscanf() aus der Standardbibliothek heraus. Da diese Daten vorher eigenhändig von Listing 1 produziert wurden (und um Platz zu sparen), validiert readPoints() die Eingabe nicht, andernfalls wäre zu prüfen, ob Messwerte im erwarteten Bereich ankommen oder auch die geografische Breite und Länge einzelner Messstationen in einem sinnvollen Rahmen liegen.
001 package main
002 import (
003 "bufio"
004 "bytes"
005 "flag"
006 "fmt"
007 "image"
008 "image/color"
009 "image/png"
010 "io"
011 "log"
012 "math"
013 "os"
014 "strconv"
015 sm "github.com/flopp/go-staticmaps"
016 "github.com/golang/geo/s2"
017 "github.com/mazznoer/csscolorparser"
018 )
019 type point struct {
020 Name string
021 Lat float64
022 Lon float64
023 Value float64
024 }
025 func main() {
026 out := flag.String("out", "heatmap.png", "PNG path")
027 width := flag.Int("width", 1000, "map width")
028 height := flag.Int("height", 800, "map height")
029 flag.Parse()
030 points, err := readPoints(os.Stdin)
031 if err != nil {
032 log.Fatal(err)
033 }
034 if len(points) == 0 {
035 log.Fatal("no input rows")
036 }
037 rendered, err := renderMap(points, *width, *height)
038 if err != nil {
039 log.Fatal(err)
040 }
041 if err := os.WriteFile(*out, rendered, 0644); err != nil {
042 log.Fatal(err)
043 }
044 log.Printf("%s ready\n", *out)
045 }
046 func readPoints(input io.Reader) ([]point, error) {
047 scanner := bufio.NewScanner(input)
048 var points []point
049 for scanner.Scan() {
050 var p point
051 if _, err := fmt.Sscanf(scanner.Text(), "%s %f %f %f", &p.Name, &p.Lat, &p.Lon, &p.Value); err != nil {
052 return nil, err
053 }
054 points = append(points, p)
055 }
056 if err := scanner.Err(); err != nil {
057 return nil, err
058 }
059 return points, nil
060 }
061 func valueRange(points []point) (minVal, maxVal float64) {
062 minVal = math.Inf(1)
063 maxVal = math.Inf(-1)
064 for _, p := range points {
065 minVal = math.Min(minVal, p.Value)
066 maxVal = math.Max(maxVal, p.Value)
067 }
068 return minVal, maxVal
069 }
070 func renderMap(points []point, width, height int) ([]byte, error) {
071 if width <= 0 || height <= 0 {
072 return nil, strconv.ErrSyntax
073 }
074 minVal, maxVal := valueRange(points)
075 ctx := sm.NewContext()
076 ctx.SetSize(width, height)
077 ctx.SetTileProvider(sm.NewTileProviderOpenStreetMaps())
078 ctx.SetUserAgent("heatmap-example/1.0")
079 for _, p := range points {
080 pos := s2.LatLngFromDegrees(p.Lat, p.Lon)
081 weight := normalized(p.Value, minVal, maxVal)
082 marker := sm.NewMarker(pos, markerColor(weight), 42)
083 marker.Label = fmt.Sprintf("%.0f", p.Value)
084 marker.SetLabelColor(labelColor(weight))
085 ctx.AddMarker(marker)
086 }
087 img, err := ctx.Render()
088 if err != nil {
089 return nil, err
090 }
091 return encodePNG(img)
092 }
093 func normalized(value, minVal, maxVal float64) float64 {
094 if maxVal <= minVal {
095 return 1
096 }
097 return (value - minVal) / (maxVal - minVal)
098 }
099 func markerColor(weight float64) color.Color {
100 name := "deepskyblue"
101 if weight >= 0.75 {
102 name = "crimson"
103 } else if weight >= 0.50 {
104 name = "darkorange"
105 } else if weight >= 0.25 {
106 name = "gold"
107 }
108 col, _ := csscolorparser.Parse(name)
109 return col
110 }
111 func labelColor(weight float64) color.Color {
112 if weight >= 0.25 && weight < 0.75 {
113 return color.Black
114 }
115 return color.White
116 }
117 func encodePNG(img image.Image) ([]byte, error) {
118 var buf bytes.Buffer
119 if err := png.Encode(&buf, img); err != nil {
120 return nil, err
121 }
122 return buf.Bytes(), nil
123 }
Wer genau hinschaut, sieht, dass die Stationsmarker in Abbildung 5 bis Abbildung 8 farblich unterlegt sind, hohe Messwerte erscheinen in Rot, niedrigere in Orange oder gar Blau. Hierzu bestimmt valueRange() ab Zeile 61 den minimalen und den maximalen Messwert der Reihe. Um nun zu prüfen, ob ein Wert sich als globales Maximum qualifiziert, muss die for-Schleife ab Zeile 64 erst einmal abfragen, ob die Variable maxVal bereits gesetzt wurde, denn falls nicht, ist der aktuelle Wert automatisch das Maximum. In Skriptsprachen wie Perl nehmen ungesetzte Variablen den Wert undef an, aber Go setzt jede deklarierte Variable automatisch auf den Null-Value, was bei Floats "0.0" ist. Nun ist aber 0.0 ein Wert, der tatsächlich in der Messreihe vorkommen könnte, und deshalb setzt Zeile 63 das Maximum auf negativ unendlich. Damit startet maxVal als unmöglicher Wert, und Zeile 66 kann mit Max() ohne Fallunterscheidung prüfen, ob der aktuelle Messwert als Maximum taugt. Für das Minimum gilt Analoges.
Die Darstellung der Landkarte mit den Markern übernimmt das Paket go-staticmaps, das Zeile 15 unter dem Kürzel sm importiert. Die Funktion zum Setzen eines Markers, NewMarker() in Zeile 82, benötigt die Geo-Position des Ballons als Variable vom Typ LatLng, die das Paket golang/geo/s2 von Github ohne viel Federlesens aus den Floats aus der Eingabe produziert.
Messwerte können stark schwanken, zwischen 0 und Werten um die tausend oder höher. Für eine sinnvolle farbliche Zuordnung bestimmt normalized() ab Zeile 93 für jeden Messwert eine Abbildung in den Bereich zwischen 0 und 1. So kann markerColor() ab Zeile 99 später Werte im obersten Quantil (über 0.75) rot einfärben und Werte in den unteren Quantilen farblich kühler mit mehr Blauanteil unterlegen.
Zahlenwerte auf rotem Hintergrund sollten in einem weißen Font erscheinen, aber auf einem helleren Marker lesen sich schwarze Zahlen besser mit mehr Kontrast. Die Funktion labelColor() zieht hierzu die entsprechenden Register. Die fertige Landkarte liegt nach dem Aufruf von Render() in Zeile 87 als image vor, und encodePNG() ab Zeile 117 macht eine PNG-Datei daraus, die Wohnungssuchende mit einem Image-Viewer begutachten können, bevor sie eine Immobilie in Betracht ziehen.
Angenehmes Wetter an einem zukünftigen Wohnort kann man auch mit weiteren Messdaten ermitteln. Der in Kasten 1 beschriebene /daily-Endpoint bietet weitere Möglichkeiten, die Wetter-Bonität zu ermitteln. Herrscht Wind? Wenn es nicht gerade stürmt wie im Klondike, ist Wind eine gute Sache, da er frische Luft produziert und unangenehme Übertemperaturen wegbläst. Regen? Der Wert precipTotal gibt die Regenmenge in Millimetern an, und der ideale Wohnort sollte auch mal eine Woche Sonnenschein ohne Niederschlag bieten. Zu hohe Luftfeuchtigkeit gilt es ebenfalls zu meiden, wer schon mal im Sommer in Florida war, kann ein Lied davon singen.
Das vorgestellte Tool lässt sich nun einfach um diese Werte erweitern und der Pipe-Mechanismus stellt alle erdenklichen Messwerte geografisch auf der Landkarte dar. Wohl dem, der vorher abcheckt, ob ein neuer Wohnort alle Vorgaben erfüllt!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2026/10/snapshot/
Michael Schilli, "Daumen im Wind": Linux-Magazin 05/26, S.XXX, https://www.linux-magazin.de/ausgaben/2026/05/snapshot/
Geschichte des Wetter-Projekts "Weather Underground", https://en.wikipedia.org/wiki/Weather_Underground_(weather_service)
Preise für API-Services auf api.weather.com: https://www.weathercompany.com/weather-data-apis/weather-data-apis-packages-pricing/
Element Of Crime - "Wenn es dunkel und kalt wird in Berlin", https://www.youtube.com/watch?v=CdonEISrnFE