From 5da96e5b5d073f3d59f0c73f20c5ac16fe4a5a0c Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Sun, 1 Mar 2026 10:27:24 -0600 Subject: [PATCH] refactor: remove rucsoundings API support rucsoundings was shut down and never came back --- README.md | 12 +- package.json | 1 - pnpm-lock.yaml | 8 -- src/features/rap/Hours.tsx | 2 - .../rap/extra/reportMetadata/Legend.tsx | 17 +-- .../rap/extra/reportMetadata/PointInfo.tsx | 58 ++------ .../extra/reportMetadata/ReportMetadata.tsx | 40 +----- src/features/rap/warnings/ReportStale.tsx | 46 ------ src/features/weather/weatherSlice.ts | 53 ++----- src/helpers/geo.test.ts | 36 +---- src/helpers/geo.ts | 10 -- src/models/WindsAloft.ts | 2 +- src/routes/Terms.tsx | 14 -- src/services/rapidRefresh.ts | 132 ------------------ vite.config.ts | 13 +- 15 files changed, 32 insertions(+), 412 deletions(-) delete mode 100644 src/features/rap/warnings/ReportStale.tsx delete mode 100644 src/services/rapidRefresh.ts diff --git a/README.md b/README.md index ea73beb..0585838 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,12 @@ Weather report tailored for paramotor pilots. Consolidates data from multiple sources. Worldwide coverage, with extra information within the United States. -1. ๐ŸŒ [Open-Meteo](https://Open-Meteo.com/) for international winds aloft and hourly weather forecasts +1. ๐ŸŒ [Open-Meteo](https://Open-Meteo.com/) for winds aloft and hourly weather forecasts 2. ๐ŸŒ Nearby [Terminal Aerodrome Forecasts](https://aviationweather.gov/gfa/#taf), if available 3. ๐ŸŒ Aviation Weather Center [SIGMETs](https://aviationweather.gov/gfa/#sigmet) (international support), [Gโ€‘AIRMETs](https://aviationweather.gov/gfa/#gairmet), and [CWAs](https://aviationweather.gov/gfa/#cwa) -4. ๐Ÿ‡บ๐Ÿ‡ธ The [NOAA Rapid Refresh Op40 analysis](https://rucsoundings.noaa.gov/) -5. ๐Ÿ‡บ๐Ÿ‡ธ NWS [hourly weather forecast](https://www.weather.gov/documentation/services-web-api) -6. ๐Ÿ‡บ๐Ÿ‡ธ National Weather Service [active alerts](https://alerts.weather.gov/cap/us.php?x=1) -7. ๐Ÿ‡บ๐Ÿ‡ธ Federal Aviation Administration [TFRs](https://tfr.faa.gov) +4. ๐Ÿ‡บ๐Ÿ‡ธ NWS [hourly weather forecast](https://www.weather.gov/documentation/services-web-api) +5. ๐Ÿ‡บ๐Ÿ‡ธ National Weather Service [active alerts](https://alerts.weather.gov/cap/us.php?x=1) +6. ๐Ÿ‡บ๐Ÿ‡ธ Federal Aviation Administration [TFRs](https://tfr.faa.gov) ![Screenshot of PPG.report website](https://user-images.githubusercontent.com/2166114/166601608-42c74bed-7c87-41ef-bd55-0911b470a9c4.png) @@ -55,7 +54,6 @@ Using a reverse proxy such as Nginx, configure the following: - GET `/api/timezone` โžก `http://api.timezonedb.com/v2.1/get-time-zone` (You will need to attach an API key. Note: This API is only used as a fallback for when the `/api/weather` endpoint fails, or when using Open-Meteo.) - GET `/api/openmeteo/{proxy+}` โžก `https://api.open-meteo.com/v1/{proxy}` Get worldwide winds aloft and forecast information - OPTIONAL endpoints (to further enhance basic global support): - - GET `/api/rap` โžก `https://rucsoundings.noaa.gov/get_soundings.cgi` - GET `/api/aviationweather` โžก `https://www.aviationweather.gov/api/data/taf` - GET `/api/weather/{proxy+}` โžก `https://api.weather.gov/{proxy}` Greedy path capturing, forwards to api.weather.gov. - GET `/api/pqs` โžก `https://epqs.nationalmap.gov/v1/json` Get United States altitude information for a given geolocation. @@ -64,7 +62,7 @@ Using a reverse proxy such as Nginx, configure the following: - GET `/api/aviationalerts` โžก self-hosted [aviation-wx](https://github.com/aeharding/aviation-wx) - **IMPORTANT!** For each outgoing API request, make sure to: - Attach a `User-Agent` header, as per [NOAA](https://www.weather.gov/documentation/services-web-api) and [Nominatim](https://operations.osmfoundation.org/policies/nominatim/) usage policies. - - **Keep these free APIs free - be a good API consumer!** Add caching for each route - I recommend at least 10 minutes for `rucsoundings.noaa.gov`, and one week for `nominatim.openstreetmap.org`. + - **Keep these free APIs free - be a good API consumer!** Add caching for each route - I recommend at least one week for `nominatim.openstreetmap.org`. ## Linking to ppg.report diff --git a/package.json b/package.json index 60e6954..2154974 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,6 @@ "emotion": "^11.0.0", "eslint-plugin-react-compiler": "^19.1.0-rc.2", "geolib": "^3.3.4", - "gsl-parser": "^3.0.1", "i18next": "^25.6.0", "i18next-browser-languagedetector": "^8.2.0", "iso8601-duration": "^2.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97b2f6c..17459ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: geolib: specifier: ^3.3.4 version: 3.3.4 - gsl-parser: - specifier: ^3.0.1 - version: 3.0.1 i18next: specifier: ^25.6.0 version: 25.6.0(typescript@5.9.3) @@ -2552,9 +2549,6 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - gsl-parser@3.0.1: - resolution: {integrity: sha512-Hvho7URfBuHbEGcse9C1HsAuEX/xOF3m1x9aZksg5xJIuUwjyuCtpHliOhkBcHcmkQNa7+RtEJQQ0S/u2FGBYg==} - gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -6827,8 +6821,6 @@ snapshots: graphemer@1.4.0: {} - gsl-parser@3.0.1: {} - gzip-size@6.0.0: dependencies: duplexer: 0.1.2 diff --git a/src/features/rap/Hours.tsx b/src/features/rap/Hours.tsx index 6f6ac35..d5530ec 100644 --- a/src/features/rap/Hours.tsx +++ b/src/features/rap/Hours.tsx @@ -13,7 +13,6 @@ import ReportElevationDiscrepancy, { import Extra from "./extra/Extra"; import Scrubber from "./Scrubber"; import { isEqual, startOfHour } from "date-fns"; -import ReportStale from "./warnings/ReportStale"; import LocalTimeWarning from "./warnings/LocalTimeWarning"; import Errors from "./Errors"; import { useAppSelector } from "../../hooks"; @@ -359,7 +358,6 @@ export default function Hours({ hours }: TableProps) { return ( <> - diff --git a/src/features/rap/extra/reportMetadata/Legend.tsx b/src/features/rap/extra/reportMetadata/Legend.tsx index 9c56686..f9c37b4 100644 --- a/src/features/rap/extra/reportMetadata/Legend.tsx +++ b/src/features/rap/extra/reportMetadata/Legend.tsx @@ -24,14 +24,6 @@ const YourLocation = styled.div` ${outputP3ColorFromRGB([0, 255, 0], "background")} `; -const WindsAloft = styled.div` - width: 1rem; - height: 1rem; - border-radius: 2px; - background: rgba(50, 0, 255, 0.3); - border: 2px solid rgb(50, 0, 255); -`; - const NWS = styled.div` width: 1rem; height: 1rem; @@ -49,22 +41,15 @@ const StyledPlaneSvg = styled(PlaneSvg)` interface LegendProps { showTaf: boolean; showNws: boolean; - showOp40: boolean; } -export default function Legend({ showTaf, showNws, showOp40 }: LegendProps) { +export default function Legend({ showTaf, showNws }: LegendProps) { return ( Selected location - {showOp40 && ( - - - Op40 Winds Aloft Gridpoint (approx) - - )} {showNws && ( diff --git a/src/features/rap/extra/reportMetadata/PointInfo.tsx b/src/features/rap/extra/reportMetadata/PointInfo.tsx index 2485ccc..edddabb 100644 --- a/src/features/rap/extra/reportMetadata/PointInfo.tsx +++ b/src/features/rap/extra/reportMetadata/PointInfo.tsx @@ -24,41 +24,18 @@ export default function PointInfo() { throw new Error("RAP not defined"); if (!timeZone) throw new Error("timeZone not defined"); - const altitudeInM = windsAloft.hours[0].altitudes[0].altitudeInM; - - const showOp40 = - typeof windsAloft === "object" && windsAloft.source === "rucSounding"; - - const aloftSource = (() => { - switch (windsAloft.source) { - case "openMeteo": - return ( - <> - - open-meteo.com - {" "} - / Best model - - ); - case "rucSounding": - return ( - <> - - rucsoundings.noaa.gov - {" "} - / Op40 - - ); - } - })(); + const aloftSource = ( + <> + + open-meteo.com + {" "} + / Best model + + ); const hourlySource = (() => { if (!weather || typeof weather !== "object") return; @@ -104,17 +81,6 @@ export default function PointInfo() { {heightUnitLabel} - {showOp40 && ( - -
Winds aloft gridpoint elevation
-
- {Math.round( - heightValueFormatter(altitudeInM, heightUnit), - ).toLocaleString()} - {heightUnitLabel} -
-
- )}
Winds aloft
{aloftSource}
diff --git a/src/features/rap/extra/reportMetadata/ReportMetadata.tsx b/src/features/rap/extra/reportMetadata/ReportMetadata.tsx index 5d12ed1..8d24def 100644 --- a/src/features/rap/extra/reportMetadata/ReportMetadata.tsx +++ b/src/features/rap/extra/reportMetadata/ReportMetadata.tsx @@ -1,13 +1,11 @@ import styled from "@emotion/styled"; -import { latLng, LatLngExpression, divIcon } from "leaflet"; +import { LatLngExpression, divIcon } from "leaflet"; import { useEffect, useRef } from "react"; import { MapContainer, GeoJSON, useMap, - Circle, FeatureGroup, - Rectangle, Marker, } from "react-leaflet"; import { useAppSelector } from "../../../../hooks"; @@ -17,7 +15,6 @@ import Legend from "./Legend"; import RefreshInformation from "./RefreshInformation"; import { DataList } from "../../../../DataList"; import { outputP3ColorFromRGB } from "../../../../helpers/colors"; -import { css } from "@emotion/react"; import OSMAttribution from "../../../../map/OSMAttribution"; import MyPosition from "../../../../map/MyPosition"; import Parallax from "../../../../shared/Parallax"; @@ -62,12 +59,8 @@ export default function ReportMetadata() { const aviationWeather = useAppSelector( (state) => state.weather.aviationWeather, ); - const windsAloft = useAppSelector((state) => state.weather.windsAloft); const weather = useAppSelector((state) => state.weather.weather); - const showOp40 = - typeof windsAloft === "object" && windsAloft.source === "rucSounding"; - return ( @@ -96,7 +89,6 @@ export default function ReportMetadata() { showNws={ !!(weather && typeof weather === "object" && "geometry" in weather) } - showOp40={showOp40} /> @@ -125,17 +117,10 @@ function MapController() { if (!windsAloft || typeof windsAloft !== "object") throw new Error("RAP report must be defined"); - const showOp40 = - typeof windsAloft === "object" && windsAloft.source === "rucSounding"; - const map = useMap(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const groupRef = useRef(null); - const rapPosition: LatLngExpression = [ - windsAloft.latitude, - windsAloft.longitude, - ]; const airportPosition: LatLngExpression | undefined = aviationWeather && typeof aviationWeather === "object" ? [aviationWeather.lat, aviationWeather.lon] @@ -148,31 +133,8 @@ function MapController() { }); }, [map, groupRef]); - const bounds = latLng(rapPosition).toBounds(40000); // 13km for op40 analysis - return ( - {showOp40 && ( - <> - - - - )} - {airportPosition && ( )} diff --git a/src/features/rap/warnings/ReportStale.tsx b/src/features/rap/warnings/ReportStale.tsx deleted file mode 100644 index 8d87f4b..0000000 --- a/src/features/rap/warnings/ReportStale.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import styled from "@emotion/styled"; -import { faClock } from "@fortawesome/pro-light-svg-icons"; -import { differenceInHours } from "date-fns"; -import { outputP3ColorFromRGB } from "../../../helpers/colors"; -import { useAppSelector } from "../../../hooks"; -import { Container, Icon, WarningMessage } from "./styles"; - -const StyledWarningMessage = styled(WarningMessage)` - ${outputP3ColorFromRGB([255, 200, 0])} -`; - -export default function ReportStale() { - const windsAloft = useAppSelector((state) => state.weather.windsAloft); - - if (!windsAloft || typeof windsAloft !== "object") return <>; - - // Need to determine behavior for outdated open meteo data - if (windsAloft.source !== "rucSounding") return <>; - - const difference = differenceInHours( - new Date(), - new Date(windsAloft.hours[0].date), - ); - - if (difference < 4) return <>; - - return ( - - - -
- Notice Due to{" "} - - upstream data issues - - , winds aloft data is currently{" "} - {difference} hours stale. -
-
-
- ); -} diff --git a/src/features/weather/weatherSlice.ts b/src/features/weather/weatherSlice.ts index b00e83f..58ce9ec 100644 --- a/src/features/weather/weatherSlice.ts +++ b/src/features/weather/weatherSlice.ts @@ -2,18 +2,14 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import type { RootState } from "../../store"; import { AppDispatch } from "../../store"; import * as nwsWeather from "../../services/nwsWeather"; -import { differenceInMinutes, isPast } from "date-fns"; +import { differenceInMinutes } from "date-fns"; import * as timezoneService from "../../services/timezone"; import * as aviationWeatherService from "../../services/aviationWeather"; import * as elevationService from "../../services/elevation"; import * as storage from "../user/storage"; import { WindsAloftReport } from "../../models/WindsAloft"; -import * as rapidRefresh from "../../services/rapidRefresh"; import * as openMeteo from "../../services/openMeteo"; -import { - isPossiblyWithinUSA, - isWithinNWSRAPModelBoundary, -} from "../../helpers/geo"; +import { isPossiblyWithinUSA } from "../../helpers/geo"; import { AxiosError } from "axios"; const UPDATE_INTERVAL_MINUTES = 30; @@ -608,13 +604,12 @@ export const getWeather = return; } - // Open Meteo can provide weather and elevation alongside winds aloft - const windsAloft = await loadWindsAloft(); + const windsAloftResult = await loadWindsAloft(); if (isStale()) return; - if (!windsAloft) return; // pending - const { elevation } = windsAloft; + if (!windsAloftResult) return; // pending + const { elevation } = windsAloftResult; if (elevation == null) loadElevation(); else dispatch(elevationReceived(elevation)); @@ -632,46 +627,18 @@ export const getWeather = } | undefined > { - if (!isWithinNWSRAPModelBoundary(lat, lon)) return fallback(); - try { - const windsAloft = await rapidRefresh.getWindsAloft(lat, lon); - - if ( - windsAloft.hours.filter(({ date }) => !isPast(new Date(date))) - .length < 10 - ) { - console.info("Stale NWS rapid refresh data"); - throw new Error("Rapid Refresh is too old"); - } + const { windsAloft } = await openMeteo.getWindsAloft(lat, lon); if (isStale()) return; dispatch(windsAloftReceived(windsAloft)); - } catch (_error) { - if (isStale()) return; - return fallback(); - } - - return {}; - - async function fallback() { - try { - // It would be nice in the future to intelligently choose an API - // instead of trial and error (and, it would be faster) - const { windsAloft } = await openMeteo.getWindsAloft(lat, lon); - - if (isStale()) return; - - dispatch(windsAloftReceived(windsAloft)); - - return { elevation: windsAloft.elevationInM }; - } catch (error) { - if (!isStale()) dispatch(windsAloftFailed()); + return { elevation: windsAloft.elevationInM }; + } catch (error) { + if (!isStale()) dispatch(windsAloftFailed()); - throw error; - } + throw error; } } diff --git a/src/helpers/geo.test.ts b/src/helpers/geo.test.ts index 58925c5..4a47b97 100644 --- a/src/helpers/geo.test.ts +++ b/src/helpers/geo.test.ts @@ -1,4 +1,4 @@ -import { isPossiblyWithinUSA, isWithinNWSRAPModelBoundary } from "./geo"; +import { isPossiblyWithinUSA } from "./geo"; describe("isPossiblyWithinUSA", () => { describe("should return true", () => { @@ -73,37 +73,3 @@ describe("isPossiblyWithinUSA", () => { }); }); }); - -describe("isWithinNWSRAPModelBoundary", () => { - describe("should return true", () => { - it("for coordinates on the northern edge of the RAP model boundary (Winnipeg, Canada)", () => { - expect(isWithinNWSRAPModelBoundary(49.8951, -97.1384)).toBe(true); - }); - - it("for coordinates on the southern edge of the RAP model boundary (Cancรบn, Mexico)", () => { - expect(isWithinNWSRAPModelBoundary(21.1619, -86.8515)).toBe(true); - }); - - it("for coordinates on the western edge of the RAP model boundary (Vancouver, Canada)", () => { - expect(isWithinNWSRAPModelBoundary(49.2827, -123.1207)).toBe(true); - }); - - it("for coordinates on the eastern edge of the RAP model boundary (Halifax, Canada)", () => { - expect(isWithinNWSRAPModelBoundary(44.6488, -63.5752)).toBe(true); - }); - }); - - describe("should return false outside the RAP model boundary", () => { - it("e.g. Anchorage, Alaska", () => { - expect(isWithinNWSRAPModelBoundary(61.2181, -149.9003)).toBe(false); - }); - - it("e.g. Mexico City, Mexico", () => { - expect(isWithinNWSRAPModelBoundary(19.4326, -99.1332)).toBe(false); - }); - - it("e.g. Madrid, Spain", () => { - expect(isWithinNWSRAPModelBoundary(40.4168, -3.7038)).toBe(false); - }); - }); -}); diff --git a/src/helpers/geo.ts b/src/helpers/geo.ts index 9c55e54..fbc94df 100644 --- a/src/helpers/geo.ts +++ b/src/helpers/geo.ts @@ -70,13 +70,3 @@ export function isPossiblyWithinUSA( return false; // Coordinates are not within the United States or its locations } - -export function isWithinNWSRAPModelBoundary( - latitude: number, - longitude: number, -): boolean { - const isWithinBoundary = - latitude >= 20 && latitude <= 55 && longitude >= -130 && longitude <= -60; - - return isWithinBoundary; -} diff --git a/src/models/WindsAloft.ts b/src/models/WindsAloft.ts index aa0d12e..6d0b70a 100644 --- a/src/models/WindsAloft.ts +++ b/src/models/WindsAloft.ts @@ -4,7 +4,7 @@ export interface WindsAloftReport { latitude: number; longitude: number; - source: "openMeteo" | "rucSounding"; + source: "openMeteo"; elevationInM?: number; } diff --git a/src/routes/Terms.tsx b/src/routes/Terms.tsx index 7a739a3..c257ec2 100644 --- a/src/routes/Terms.tsx +++ b/src/routes/Terms.tsx @@ -84,20 +84,6 @@ export default function Terms() { be shared with the following third parties in order to provide you information:
    -
  1. - NOAA's Rapid Refresh software โ€”{" "} - - https://rucsoundings.noaa.gov - -
    - Purpose: To show you weather information, - especially as it relates to conditions aloft. -
  2. -
  3. OpenStreetMap's Nominatim service โ€”{" "} { - return transformRapToWindsAloft(await getRap(lat, lon, data_source)); -} - -function transformRapToWindsAloft(rap: Rap[]): WindsAloftReport { - return { - latitude: rap[0].lat, - longitude: -rap[0].lon, - source: "rucSounding", - hours: rap.map(({ cape, cin, date, data }) => ({ - date, - cape, - cin, - altitudes: data.map( - ({ height, temp, windDir, windSpd, pressure, dewpt }) => ({ - windSpeedInKph: windSpd * 1.852, - windDirectionInDeg: windDir, - temperatureInC: temp / 10, - altitudeInM: height, - pressure: Math.round(pressure / 10), - - // Based on everything I've seen, invalid dewpt values are - // due to extremely low dewpts. So just set to -100ยฐC. - dewpointInC: dewpt != null ? dewpt / 10 : -100, - }), - ), - })), - }; -} - -async function getRap( - lat: number, - lon: number, - data_source: "Op40" | "GFS" = "Op40", -): Promise { - const report = await _getRap(lat, lon, data_source); - - const hoursStale = differenceInHours(new Date(), new Date(report[0].date)); - - if (hoursStale > 4) { - try { - const reportAdditional = await _getRap( - lat, - lon, - data_source, - hoursStale - 2, - addHours(new Date(report[report.length - 1].date), 1), - ); - - return [...report, ...reportAdditional]; - } catch (e) { - console.error( - "Error fetching additional rapidRefresh data (are you offline?)", - e, - ); - } - } - - return report; -} - -async function _getRap( - lat: number, - lon: number, - data_source: "Op40" | "GFS" = "Op40", - hours = 30, - start?: Date, -) { - const { data: asciiReports } = await axios.get(API_PATH, { - params: { - ...BASE_PARAMS, - data_source, - airport: getTrimmedCoordinates(+lat, +lon), - ...generateStartParams(hours, start), - }, - paramsSerializer: { - // For some reason encoding spaces stopped working sometime ~ April 2023 - // So instead, don't encode, just pass through (this is a bit dangerous though - // if we pass anything with & symbol for example) - encode: (params) => params, - }, - }); - - const report = parse(asciiReports); - - // Sometimes rapid refresh returns status=200 with empty body - if (report.length === 0) throw new Error("Report is empty"); - - return report; -} - -function generateStartParams( - hours: number, - start?: Date, -): Record { - if (!start) - return { - start: "latest", - n_hrs: `${hours}.0`, - }; - - return { - startSecs: Math.round(start.getTime() / 1000), - endSecs: Math.round(start.getTime() / 1000 + hours * 60 * 60), - }; -} diff --git a/vite.config.ts b/vite.config.ts index 33a3d62..85fb76d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ import { VitePWA } from "vite-plugin-pwa"; export default defineConfig(() => { return { optimizeDeps: { - include: ["metar-taf-parser", "gsl-parser"], + include: ["metar-taf-parser"], }, build: { outDir: "build", @@ -37,17 +37,6 @@ export default defineConfig(() => { }, }, }, - { - handler: "NetworkFirst", - urlPattern: /\/api\/rap.*/, - options: { - cacheName: "apiRapCache", - expiration: { - maxEntries: 100, - maxAgeSeconds: 60 * 60 * 4, // 4 Hours - }, - }, - }, { handler: "NetworkFirst", urlPattern: /\/api\/weather.*/,