Add MapClick adapter and "fallback" logic when observations are stale

- Create utils/mapclick.mjs with centralized MapClick API functionality
- Refactor modules to use the new utility:
  - Current Weather
  - Latest Observations
  - Regional Forecast
- Add staleness checking utility for use by modules
This commit is contained in:
Eddy G
2025-06-24 12:38:14 -04:00
parent e81c957416
commit 996baa78aa
10 changed files with 814 additions and 60 deletions

View File

@@ -12,6 +12,7 @@ import {
temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
} from './utils/units.mjs';
import { debugFlag } from './utils/debug.mjs';
import { isDataStale, enhanceObservationWithMapClick } from './utils/mapclick.mjs';
// some stations prefixed do not provide all the necessary data
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
@@ -39,6 +40,8 @@ class CurrentWeather extends WeatherDisplay {
while (!observations && stationNum < filteredStations.length) {
// get the station
station = filteredStations[stationNum];
const stationId = station.properties.stationIdentifier;
stationNum += 1;
let candidateObservation;
@@ -52,20 +55,12 @@ class CurrentWeather extends WeatherDisplay {
stillWaiting: () => this.stillWaiting(),
});
} catch (error) {
console.error(`Unexpected error getting Current Conditions for station ${station.properties.stationIdentifier}: ${error.message} (trying next station)`);
console.error(`Unexpected error getting Current Conditions for station ${stationId}: ${error.message} (trying next station)`);
candidateObservation = undefined;
}
// Check if request was successful and has data
if (candidateObservation && candidateObservation.features?.length > 0) {
// Check if the observation data is old
const observationTime = new Date(candidateObservation.features[0].properties.timestamp);
const ageInMinutes = (new Date() - observationTime) / (1000 * 60);
if (ageInMinutes > 180 && debugFlag('currentweather')) {
console.warn(`Current Observations for station ${station.properties.stationIdentifier} are ${ageInMinutes.toFixed(0)} minutes old (from ${observationTime.toISOString()}), trying next station`);
}
// Attempt making observation data usable with METAR data
const originalData = { ...candidateObservation.features[0].properties };
candidateObservation.features[0].properties = augmentObservationWithMetar(candidateObservation.features[0].properties);
@@ -83,7 +78,7 @@ class CurrentWeather extends WeatherDisplay {
const augmentedData = candidateObservation.features[0].properties;
const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
if (debugFlag('currentweather') && metarReplacements.length > 0) {
console.log(`Current Conditions for station ${station.properties.stationIdentifier} were augmented with METAR data for ${metarReplacements.join(', ')}`);
console.log(`Current Conditions for station ${stationId} were augmented with METAR data for ${metarReplacements.join(', ')}`);
}
// test data quality - check required fields and allow one optional field to be missing
@@ -99,28 +94,47 @@ class CurrentWeather extends WeatherDisplay {
{ name: 'ceiling', check: (props) => props.cloudLayers?.[0]?.base?.value === null, required: false },
];
const missingRequired = requiredFields.filter((field) => field.required && field.check(augmentedData)).map((field) => field.name);
const missingOptional = requiredFields.filter((field) => !field.required && field.check(augmentedData)).map((field) => field.name);
// Use enhanced observation with MapClick fallback
// eslint-disable-next-line no-await-in-loop
const enhancedResult = await enhanceObservationWithMapClick(augmentedData, {
requiredFields,
maxOptionalMissing: 1, // Allow one optional field to be missing
stationId,
stillWaiting: () => this.stillWaiting(),
debugContext: 'currentweather',
});
candidateObservation.features[0].properties = enhancedResult.data;
const { missingFields } = enhancedResult;
const missingRequired = missingFields.filter((fieldName) => {
const field = requiredFields.find((f) => f.name === fieldName && f.required);
return !!field;
});
const missingOptional = missingFields.filter((fieldName) => {
const field = requiredFields.find((f) => f.name === fieldName && !f.required);
return !!field;
});
const missingOptionalCount = missingOptional.length;
// Check final data quality
// Allow one optional field to be missing
if (missingRequired.length === 0 && missingOptionalCount <= 1) {
// Station data is good, use it
observations = candidateObservation;
if (debugFlag('currentweather') && missingOptional.length > 0) {
console.log(`Data for station ${station.properties.stationIdentifier} is missing optional fields: ${missingOptional.join(', ')} (acceptable)`);
console.log(`Data for station ${stationId} is missing optional fields: ${missingOptional.join(', ')} (acceptable)`);
}
} else {
const allMissing = [...missingRequired, ...missingOptional];
if (debugFlag('currentweather')) {
console.log(`Data for station ${station.properties.stationIdentifier} is missing fields: ${allMissing.join(', ')} (${missingRequired.length} required, ${missingOptionalCount} optional) (trying next station)`);
console.log(`Data for station ${stationId} is missing fields: ${allMissing.join(', ')} (${missingRequired.length} required, ${missingOptionalCount} optional) (trying next station)`);
}
}
} else if (debugFlag('verbose-failures')) {
if (!candidateObservation) {
console.log(`Current Observations for station ${station.properties.stationIdentifier} failed, trying next station`);
console.log(`Current Conditions for station ${stationId} failed, trying next station`);
} else {
console.log(`No features returned for station ${station.properties.stationIdentifier}, trying next station`);
console.log(`No features returned for station ${stationId}, trying next station`);
}
}
}
@@ -140,14 +154,36 @@ class CurrentWeather extends WeatherDisplay {
// stop here if we're disabled
if (!superResult) return;
// preload the icon
preloadImg(getLargeIcon(observations.features[0].properties.icon));
// Data is available, ensure we're enabled for display
this.timing.totalScreens = 1;
// Check final data age
const { isStale, ageInMinutes } = isDataStale(observations.features[0].properties.timestamp, 80); // hourly observation + 20 minute propagation delay
this.isStaleData = isStale;
if (isStale && debugFlag('currentweather')) {
console.warn(`Current Conditions: Data is ${ageInMinutes.toFixed(0)} minutes old (from ${new Date(observations.features[0].properties.timestamp).toISOString()})`);
}
// preload the icon if available
if (observations.features[0].properties.icon) {
const iconResult = getLargeIcon(observations.features[0].properties.icon);
if (iconResult) {
preloadImg(iconResult);
}
}
this.setStatus(STATUS.loaded);
}
async drawCanvas() {
super.drawCanvas();
// Update header text based on data staleness
const headerTop = this.elem.querySelector('.header .title .top');
if (headerTop) {
headerTop.textContent = this.isStaleData ? 'Recent' : 'Current';
}
let condition = this.data.observations.textDescription;
if (condition.length > 15) {
condition = shortConditions(condition);
@@ -247,17 +283,23 @@ const parseData = (data) => {
data.WindGust = windConverter(observations.windGust.value);
data.WindUnit = windConverter.units;
data.Humidity = Math.round(observations.relativeHumidity.value);
data.Icon = getLargeIcon(observations.icon);
// Get the large icon, but provide a fallback if it returns false
const iconResult = getLargeIcon(observations.icon);
data.Icon = iconResult || observations.icon; // Use original icon if getLargeIcon returns false
data.PressureDirection = '';
data.TextConditions = observations.textDescription;
// set wind speed of 0 as calm
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
// difference since last measurement (pascals, looking for difference of more than 150)
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
if (pressureDiff > 150) data.PressureDirection = 'R';
if (pressureDiff < -150) data.PressureDirection = 'F';
// if two measurements are available, use the difference (in pascals) to determine pressure trend
if (data.features.length > 1 && data.features[1].properties.barometricPressure?.value) {
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
if (pressureDiff > 150) data.PressureDirection = 'R';
if (pressureDiff < -150) data.PressureDirection = 'F';
}
return data;
};