Ollin

The weather outside

A DataFeed reads any address. A Weather is a feed that already knows the address to ask and the shape of the answer, so a sketch reads the sky over a place the way it reads a slider: cloudCover, windSpeed, condition, and the rest, in plain units. Make one for a place, or for a place by name, start() it, and read it in draw().

The sun is not fetched at all. Place.sun(at:) works out where it is from the place and the clock, so a sketch can put it in the sky, and know when it rises and sets, with no network.

Nothing throws. Before the first answer arrives, and whenever the network is down, every reading is nil and problem says why. A failure never takes the last good reading away.

Contents

Weather

Weather(at place: Place, every interval: Double = 900)
Weather(latitude: Double, longitude: Double, every interval: Double = 900)
Weather(in name: String, every interval: Double = 900)
final class Sky: Sketch {
    private let sky = Weather(in: "Oaxaca")

    override func setup() {
        sky.start()
    }

    override func draw() {
        background(sky.isDay == true ? .white : .black)
        let clouds = sky.cloudCover ?? 0
        drawCircle(center: center, radius: 100 + clouds * 200)
    }
}

A Place is a latitude and a longitude in degrees, north and east positive: Place(latitude: 19.43, longitude: -99.13) is Mexico City. A name ("Oaxaca", "Tromsø", "Bath, Maine") is looked up once, when the weather starts, and the reading then carries the place it resolved to and the name the lookup knows it by. A name nothing matches leaves the weather empty, and problem says so.

every: is in seconds, and never goes below sixty. The default asks every fifteen minutes, which is how often the conditions are updated.

CallWhat it does
start()Asks now, then every interval seconds. A weather that is already running ignores the call. A name is looked up first, and the forecast is asked once the place is known.
stop()Stops asking. What arrived stays readable.
refresh()Asks now instead of waiting for the next turn. It does nothing while a request is in flight.

Reading the sky

Each read is nil until the first answer arrives.

ReadUnitMeaning
temperature°CAir temperature.
apparentTemperature°CWhat it feels like, wind and humidity included.
humidity0...1Relative humidity.
cloudCover0...1How much of the sky is cloud.
precipitationmmRain, showers, and melted snow in the last hour.
snowfallmmSnow in the last hour, as millimeters of snow.
windSpeedm/sWind speed.
windDirectiondegreesWhere the wind blows from, clockwise from north: 90 is an east wind.
windGustsm/sThe strongest gust in the last hour.
pressurehPaAir pressure at sea level.
isDayBoolWhether the sun is up there.
conditionConditionThe sky in a word.
sunrise, sunsetDateToday's, there.

updateCount counts the readings that differed from the one before. The service stamps every answer with the time it was generated, so the bytes differ on every poll while the sky does not, and a weather counts readings rather than bytes. timeSinceUpdate is seconds since the reading last changed, or nil before the first one.

The whole reading at once

reading is a Weather.Reading, nil before the first answer. It holds every field above and a few more. place is the point the answer was read for. name is what the lookup calls the place, for a weather made by name. observedAt is when the conditions were measured. code is the condition as the World Meteorological Organization's number. highTemperature and lowTemperature are the day's, and elevation and timeZone are the place's.

Keep one to compare against the next, or build one by hand to draw a sky without asking for it:

let storm = Weather.Reading(place: Place(latitude: 19.43, longitude: -99.13),
                            temperature: 16, cloudCover: 1, precipitation: 4,
                            windSpeed: 9, windDirection: 240, condition: .thunderstorm)

Every field the initializer does not name has a plain default: a still, clear, mild day.

The sky in a word

Condition is an open set of names, so a code the service learns later still arrives, as Condition(rawValue: "weather code 42"). The raw value is the word itself ("partly cloudy"), so it can be drawn as it is.

ConditionCodes
.clear, .mostlyClear, .partlyCloudy, .overcast0, 1, 2, 3
.fog45, 48
.drizzle, .freezingDrizzle51 to 55; 56, 57
.rain, .freezingRain61 to 65; 66, 67
.snow, .snowGrains71 to 75; 77
.showers, .snowShowers80 to 82; 85, 86
.thunderstorm, .hail95; 96, 99

isPrecipitating is true for anything falling, and isSnowing for snow of any kind. The reading's code keeps the exact number, which is where light, moderate, and heavy live: 61, 63, and 65 are all .rain.

Where the sun is

place.sun(at date: Date = Date()) -> SunPosition

A SunPosition is an elevation in degrees above the horizon (negative at night) and an azimuth in degrees clockwise from north (90 is east). isUp is true while any of the disc shows, which is when the center is about 0.83 degrees below the horizon, refraction included. The elevation itself is the geometric height of the sun's center.

let sun = place.sun(at: Date())
let x = map(sun.azimuth, 60, 300, 0, width)          // east on the left, looking south
let y = horizon - sun.elevation / 90 * height * 0.7

The position is the standard low-precision solar model, the one behind the NOAA solar calculator, good to a small fraction of a degree for any date within a few centuries of now. That is enough to place the sun where a viewer would look for it and to know the hour it rises. It needs no network, so a sky can be the right color for the hour before the first reading arrives.

How it is going

ReadTypeMeaning
isRunningBoolWhether the weather is asking.
isWaitingBoolWhether a request is in flight right now.
failureCountIntRequests that have failed in a row. Back to zero on the next answer.
problemString?Why the last request failed, or why the name could not be found, in a sentence a sketch can draw. nil once an answer arrives.

A failure never clears the last reading. Keep drawing it, with the notice over the top:

if let problem = sky.problem { drawStatus(problem, style: .warning) }

The politeness is the feed's: the next request waits for the last one to finish, a run of failures backs off to eight times the interval, and a name that could not be reached is retried from a minute rather than from the weather's own interval.

In an export

In a headless export the weather reads once, while start() runs, and holds that reading for every frame. A name is looked up and the forecast fetched before start() returns. timeSinceUpdate reads zero throughout. An export that fetched per frame would render differently every run.

Where the data comes from

The conditions come from Open-Meteo, an open service that serves the national weather services' forecasts under the CC BY 4.0 license. It needs no key and no account, and it is free for non-commercial use up to ten thousand requests a day. A sketch asking every fifteen minutes uses about a hundred. A work sold or shown commercially wants one of the service's own plans. A page or a print that carries the numbers should say where they came from.

The forecast is read at a grid point near the place, so the place on a reading can sit a few kilometers from the one asked for.

Where the Mac is

There is no .here yet. Give the weather the place or the name, and let it look the rest up. Reading the Mac's own location needs a permission prompt that macOS shows only to an executable carrying an Info.plist, which a sketch run from the terminal does not have. A sketch packaged as an app can carry one. That half is still ahead.

See also

  • LiveData - DataFeed, the feed a weather is built on, and PushFeed for a server that sends rather than answers
  • Data - loadTable and loadJSON, for a document read once
  • Atmosphere - the procedural sky the 3D mode lights a scene with, whose sun a reading can steer
  • Installation - what else changes for a sketch that runs for weeks

↑↓ move ↩ open