Skip to content

Commit

Permalink
Merge branch 'johan/aurora'
Browse files Browse the repository at this point in the history
This adds Norhern Lights forecasts on clear nights.
  • Loading branch information
walles committed Nov 1, 2024
2 parents 0b3f845 + 7e82bb5 commit b7ed084
Show file tree
Hide file tree
Showing 9 changed files with 244 additions and 26 deletions.
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Try it here: <https://walles.github.io/weatherclock/>

[![Screenshot](weatherclock-screenshot.webp)](https://walles.github.io/weatherclock/)

Displays the weather forecast for the upcoming 11 hours for the current
location on a clock face.
Displays the weather forecast for the upcoming 11 hours for the current location
on a clock face. On clear nights it shows northern ligths forecasts.

It shows temperature where ordinary clocks show hour numbers, and weather
symbols for each hour.
Expand All @@ -28,6 +28,13 @@ To update the favicon:
- `public/logo512.png`
- Commit changes to `src/weatherclock.xcf` and the icons in `public/`

## Northern Lights Icons

To update the northern lights icons:

- Edit `src/images/aurora-icon.blend` using [Blender](https://blender.org)
- Render icons into `public/aurora-high.png` and `public/aurora-low.png`

## Deploy

To deploy updates:
Expand Down
Binary file added public/aurora-high.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/aurora-low.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
124 changes: 124 additions & 0 deletions src/AuroraForecast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* One forecasted KP value.
*/
type Datapoint = {
timestamp: Date;
kpValue: number;
};

export class AuroraForecast {
data: Datapoint[];

/**
* The incoming data is a JSON array.
*
* The first element contains header information for the other elements:
* ["time_tag","kp","observed","noaa_scale"]
*
* The other elements are the forecasted KP values. Timestamps are in UTC:
* ["2024-10-24 00:00:00","3.00","observed",null]
*/
constructor(data: any) {
let forecast = [];

for (let i = 1; i < data.length; i++) {
const [timestamp, kpValue] = data[i];
forecast.push({
timestamp: new Date(`${timestamp}Z`),
kpValue: parseFloat(kpValue),
});
}

this.data = forecast;

console.log("AuroraForecast created with", this.data.length, "datapoints");
console.log(forecast);
}

/**
* Return the forecasted KP value for the given time. If we don't have a value
* for a precise time, we interpolate between the two closest values.
*/
getKpValue(time: Date): number {
if (this.data.length === 0) {
return 0;
}

if (time < this.data[0].timestamp) {
return this.data[0].kpValue;
}

if (time > this.data[this.data.length - 1].timestamp) {
return this.data[this.data.length - 1].kpValue;
}

for (let i = 0; i < this.data.length - 1; i++) {
if (time >= this.data[i].timestamp && time < this.data[i + 1].timestamp) {
const timeDiff = this.data[i + 1].timestamp.getTime() - this.data[i].timestamp.getTime();
const timeDiffNow = time.getTime() - this.data[i].timestamp.getTime();
const kpDiff = this.data[i + 1].kpValue - this.data[i].kpValue;
const kpNow = this.data[i].kpValue + (kpDiff * timeDiffNow) / timeDiff;
return kpNow;
}
}

return 0;
}

/**
* Return the forecasted KP value for the given time, adjusted for the
* latitude according to the table on this page:
* https://hjelp.yr.no/hc/en-us/articles/4411702484754-Aurora-forecast-on-Yr.
*/
getAdjustedKpValue(time: Date, latitude: number): number {
let adjustment: number;
if (latitude >= 75) {
adjustment = 1;
} else if (latitude >= 66) {
adjustment = 0;
} else if (latitude >= 64.5) {
adjustment = 1;
} else if (latitude >= 62.5) {
adjustment = 2;
} else if (latitude >= 60.4) {
adjustment = 3;
} else if (latitude >= 58.3) {
adjustment = 4;
} else if (latitude >= 56.3) {
adjustment = 5;
} else if (latitude >= 54.2) {
adjustment = 6;
} else if (latitude >= 52.2) {
adjustment = 7;
} else if (latitude >= 50.1) {
adjustment = 8;
} else if (latitude >= 48) {
adjustment = 9;
} else {
// Not part of the table, use some big value
adjustment = 10;
}

return this.getKpValue(time) - adjustment;
}

/**
* Return an aurora symbol based on the forecasted KP value.
*
* Based on the second table on this page:
* https://hjelp.yr.no/hc/en-us/articles/4411702484754-Aurora-forecast-on-Yr.
*/
getAuroraSymbol(time: Date, latitude: number): string | null {
const kpValue = this.getAdjustedKpValue(time, latitude);

if (kpValue >= 4) {
return "../../aurora-high";
}

if (kpValue >= 1) {
return "../../aurora-low";
}

return null;
}
}
112 changes: 93 additions & 19 deletions src/Clock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,22 @@ import ErrorDialog from './ErrorDialog'
import ClockCoordinates from './ClockCoordinates'
import TimeSelect, { NamedStartTime } from './TimeSelect'
import { Forecast } from './Forecast'
import { AuroraForecast } from './AuroraForecast'

const HOUR_HAND_LENGTH = 23
const MINUTE_HAND_LENGTH = 34

/** Cache positions for this long */
const POSITION_CACHE_MS = 5 * 60 * 1000

/** Cache forecasts for this long */
const FORECAST_CACHE_MS = 2 * 60 * 60 * 1000
const HOUR_MS = 60 * 60 * 1000

/** Cache weather forecasts for two hours */
const FORECAST_CACHE_MS = 2 * HOUR_MS

/** Cache aurora forecasts for seven hours. The resolution is 3h over a couple
* of days, so we won't win much from fetching it more often. */
const AURORA_FORECAST_CACHE_MS = 7 * HOUR_MS

/** If we move less than this, assume forecast is still valid */
const FORECAST_CACHE_KM = 5
Expand All @@ -42,15 +49,20 @@ type ClockState = {
}
positionTimestamp?: Date

forecast?: Map<number, Forecast>
forecastMetadata?: {
weatherForecast?: Map<number, Forecast>
weatherForecastMetadata?: {
// FIXME: Rather than the current timestamp, maybe track when yr.no
// thinks the next forecast will be available? That information is
// available in the XML.
timestamp: Date
latitude: number
longitude: number
}

auroraForecast?: AuroraForecast
auroraForecastMetadata?: {
timestamp: Date
}
}

class Clock extends React.Component<ClockProps, ClockState> {
Expand Down Expand Up @@ -114,12 +126,13 @@ class Clock extends React.Component<ClockProps, ClockState> {
return
}

if (this.forecastIsCurrent()) {
// Forecast already current, never mind
return
if (!this.forecastIsCurrent()) {
this.download_weather()
}

this.download_weather()
if (!this.auroraForecastIsCurrent()) {
this.bump_aurora_forecast()
}
}

startGeolocationIfNeeded = () => {
Expand Down Expand Up @@ -163,6 +176,10 @@ class Clock extends React.Component<ClockProps, ClockState> {
positionTimestamp: new Date()
})

if (!this.auroraForecastIsCurrent()) {
this.bump_aurora_forecast()
}

if (!this.forecastIsCurrent()) {
this.download_weather()
}
Expand Down Expand Up @@ -193,13 +210,16 @@ class Clock extends React.Component<ClockProps, ClockState> {
return deg * (Math.PI / 180)
}

/**
* Relates to the weather forecast, not any other forecast.
*/
forecastIsCurrent = () => {
if (!this.state.forecast) {
if (!this.state.weatherForecast) {
// No forecast at all, that's not current
return false
}

const metadata = this.state.forecastMetadata!
const metadata = this.state.weatherForecastMetadata!
const ageMs = Date.now() - metadata.timestamp.getTime()
if (ageMs > FORECAST_CACHE_MS) {
// Forecast too old, that's not current
Expand All @@ -218,11 +238,27 @@ class Clock extends React.Component<ClockProps, ClockState> {
}

console.debug(
`Forecast considered current: ${ageMs}ms old and ${kmDistance}km away`
`Forecast considered current: ${ageMs / 1000.0}s old and ${kmDistance}km away`
)
return true
}

auroraForecastIsCurrent = () => {
if (!this.state.auroraForecast) {
// No forecast at all, that's not current
return false
}

const metadata = this.state.auroraForecastMetadata!
const ageMs = Date.now() - metadata.timestamp.getTime()
if (ageMs > AURORA_FORECAST_CACHE_MS) {
// Forecast too old, that's not current
return false
}

return true
}

download_weather = () => {
const latitude = this.state.position!.latitude
const longitude = this.state.position!.longitude
Expand All @@ -247,8 +283,8 @@ class Clock extends React.Component<ClockProps, ClockState> {
const forecast = self.parseWeatherXml(weatherXmlString)

self.setState({
forecast: forecast,
forecastMetadata: {
weatherForecast: forecast,
weatherForecastMetadata: {
timestamp: new Date(),
latitude: latitude,
longitude: longitude
Expand All @@ -260,7 +296,7 @@ class Clock extends React.Component<ClockProps, ClockState> {

ReactGA.exception({
description: `Downloading weather failed: ${error.message}`,
fatal: !this.state.forecast
fatal: !this.state.weatherForecast
})

this.setState({
Expand All @@ -276,6 +312,40 @@ class Clock extends React.Component<ClockProps, ClockState> {
})
}

bump_aurora_forecast = () => {
const url = 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json'
console.log('Getting aurora forecast from: ' + url)

const self = this
fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`Response code from aurora upstream: ${response.status}`)
}
return response.json()
})
.then(data => {
const forecast = new AuroraForecast(data)

self.setState({
auroraForecast: forecast,
auroraForecastMetadata: {
timestamp: new Date()
}
})
})
.catch(error => {
console.error(error)

ReactGA.exception({
description: `Downloading aurora forecast failed: ${error.message}`,
fatal: !this.state.auroraForecast
})

// Let's not tell the user, aurora forecasts not showing is a corner case
})
}

/* Parses weather XML from yr.no into a weather object that maps timestamps (in
* milliseconds since the epoch) to forecasts. */
parseWeatherXml = (weatherXmlString: string): Map<number, Forecast> => {
Expand Down Expand Up @@ -353,7 +423,7 @@ class Clock extends React.Component<ClockProps, ClockState> {
console.log('Geolocation failed')
ReactGA.exception({
description: `Geolocation failed: ${error.message}`,
fatal: !this.state.forecast
fatal: !this.state.weatherForecast
})
this.setState({
// FIXME: Add a report-problem link?
Expand Down Expand Up @@ -416,7 +486,7 @@ class Clock extends React.Component<ClockProps, ClockState> {
{this.getClockContents()}
</svg>
{this.state.error}
{this.state.forecast ? (
{this.state.weatherForecast ? (
<TimeSelect
daysFromNow={this.props.startTime.daysFromNow}
onSetStartTime={this.props.onSetStartTime}
Expand All @@ -427,12 +497,14 @@ class Clock extends React.Component<ClockProps, ClockState> {
}

getClockContents = () => {
if (this.state.forecast) {
if (this.state.weatherForecast) {
if (this.props.startTime.daysFromNow !== 0) {
return (
<React.Fragment>
<Weather
forecast={this.state.forecast}
weatherForecast={this.state.weatherForecast}
auroraForecast={this.state.auroraForecast}
latitude={this.state.position!.latitude}
now={this.state.startTime.startTime}
/>
<text className='tomorrow'>{this.state.startTime.name}</text>
Expand All @@ -443,7 +515,9 @@ class Clock extends React.Component<ClockProps, ClockState> {
return (
<React.Fragment>
<Weather
forecast={this.state.forecast}
weatherForecast={this.state.weatherForecast}
auroraForecast={this.state.auroraForecast}
latitude={this.state.position!.latitude}
now={this.state.startTime.startTime}
/>
{this.renderHands()}
Expand Down
2 changes: 1 addition & 1 deletion src/Forecast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ export type Forecast = {
span_h: number; // Width of the span in hours
celsius?: number; // The forecasted temperatures in centigrades
wind_m_s?: number; // The forecasted wind speed in m/s
symbol_code?: string; // The weather symbol code. Resolve using public/api-met-no-weathericons/png/SYMBOL_CODE.png
symbol_code?: string; // The weather symbol code. Example values: 'clearsky_day', 'heavysnow'
precipitation_mm?: number;
};
Loading

0 comments on commit b7ed084

Please sign in to comment.