Working on queries to get latest metar airports first

This commit is contained in:
2025-04-15 22:48:05 -04:00
parent d81f7bcedb
commit f5446ac0eb
8 changed files with 209 additions and 86 deletions

View File

@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS airports (
latitude REAL NOT NULL, latitude REAL NOT NULL,
has_tower BOOLEAN DEFAULT false, has_tower BOOLEAN DEFAULT false,
has_beacon BOOLEAN DEFAULT false, has_beacon BOOLEAN DEFAULT false,
public BOOLEAN DEFAULT false public BOOLEAN DEFAULT false,
metar_observation_time TIMESTAMPTZ
); );
CREATE INDEX ON airports (iata); CREATE INDEX ON airports (iata);
@@ -25,6 +26,7 @@ CREATE INDEX ON airports (iso_country);
CREATE INDEX ON airports (iso_region); CREATE INDEX ON airports (iso_region);
CREATE INDEX ON airports (municipality); CREATE INDEX ON airports (municipality);
CREATE INDEX ON airports (longitude, latitude); CREATE INDEX ON airports (longitude, latitude);
CREATE INDEX ON airports (metar_observation_time);
CREATE TABLE IF NOT EXISTS runways ( CREATE TABLE IF NOT EXISTS runways (
id UUID PRIMARY KEY NOT NULL, id UUID PRIMARY KEY NOT NULL,

View File

@@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::str::FromStr; use std::str::FromStr;
use chrono::{DateTime, Utc};
use futures_util::try_join; use futures_util::try_join;
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -121,6 +122,7 @@ struct AirportRow {
pub has_tower: Option<bool>, pub has_tower: Option<bool>,
pub has_beacon: Option<bool>, pub has_beacon: Option<bool>,
pub public: bool, pub public: bool,
pub metar_observation_time: Option<DateTime<Utc>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -141,6 +143,7 @@ pub struct UpdateAirport {
pub runways: Option<Vec<UpdateRunway>>, pub runways: Option<Vec<UpdateRunway>>,
pub frequencies: Option<Vec<UpdateFrequency>>, pub frequencies: Option<Vec<UpdateFrequency>>,
pub public: Option<bool>, pub public: Option<bool>,
pub latest_metar_observation: Option<DateTime<Utc>>,
} }
impl Into<AirportRow> for Airport { impl Into<AirportRow> for Airport {
@@ -160,6 +163,10 @@ impl Into<AirportRow> for Airport {
has_tower: self.has_tower, has_tower: self.has_tower,
has_beacon: self.has_beacon, has_beacon: self.has_beacon,
public: self.public, public: self.public,
metar_observation_time: match self.latest_metar {
Some(m) => Some(m.observation_time),
None => None,
},
} }
} }
} }
@@ -303,7 +310,8 @@ impl Airport {
Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?; Self::push_condition_bounds(&mut builder, &mut has_where, &query.bounds)?;
// Order by AircraftCategory // Order by AircraftCategory
builder.push(" ORDER BY CASE category "); builder.push(" ORDER BY (metar_observation_time IS NULL), ");
builder.push(" CASE category ");
builder.push(" WHEN 'large_airport' THEN 1 "); builder.push(" WHEN 'large_airport' THEN 1 ");
builder.push(" WHEN 'medium_airport' THEN 2 "); builder.push(" WHEN 'medium_airport' THEN 2 ");
builder.push(" WHEN 'small_airport' THEN 3 "); builder.push(" WHEN 'small_airport' THEN 3 ");
@@ -516,7 +524,20 @@ impl Airport {
} }
// TODO // TODO
pub async fn update(_icao: &str, _airport: &UpdateAirport) -> ApiResult<()> { pub async fn update(icao: &str, airport: &UpdateAirport) -> ApiResult<()> {
let pool = db::pool();
let mut query_builder: QueryBuilder<Postgres> =
QueryBuilder::new(format!("UPDATE {} SET ", TABLE_NAME));
if let Some(latest_metar_observation) = airport.latest_metar_observation {
query_builder.push("metar_observation_time = ");
query_builder.push_bind(latest_metar_observation);
}
query_builder.push(" WHERE icao = ").push_bind(icao);
let query = query_builder.build();
query.execute(pool).await?;
Ok(()) Ok(())
} }

View File

@@ -8,6 +8,7 @@ use std::str::FromStr;
use redis::{AsyncCommands, RedisResult}; use redis::{AsyncCommands, RedisResult};
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::airports::{Airport, UpdateAirport};
use crate::db::redis_async_connection; use crate::db::redis_async_connection;
const TABLE_NAME: &str = "metars"; const TABLE_NAME: &str = "metars";
@@ -63,7 +64,7 @@ pub enum ReportModifier {
#[serde(rename = "AUTO")] #[serde(rename = "AUTO")]
Auto, Auto,
#[serde(rename = "COR")] #[serde(rename = "COR")]
Corrected Corrected,
} }
impl FromStr for ReportModifier { impl FromStr for ReportModifier {
@@ -72,7 +73,7 @@ impl FromStr for ReportModifier {
match s { match s {
"AUTO" => Ok(ReportModifier::Auto), "AUTO" => Ok(ReportModifier::Auto),
"COR" => Ok(ReportModifier::Corrected), "COR" => Ok(ReportModifier::Corrected),
_ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))) _ => Err(Error::new(400, format!("Invalid report modifier '{}'", s))),
} }
} }
} }
@@ -122,7 +123,10 @@ impl FromStr for AutomatedStationType {
match s { match s {
"AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator), "AO1" => Ok(AutomatedStationType::WithoutPrecipitationDiscriminator),
"AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator), "AO2" => Ok(AutomatedStationType::WithPrecipitationDiscriminator),
_ => Err(Error::new(400, format!("Invalid automated station type '{}'", s))) _ => Err(Error::new(
400,
format!("Invalid automated station type '{}'", s),
)),
} }
} }
} }
@@ -522,9 +526,7 @@ impl Metar {
format!( format!(
"P{}", "P{}",
visibility_whole visibility_whole
+ (visibility_left[1..visibility_left.len()] + (visibility_left[1..visibility_left.len()].parse::<f64>()? / visibility_right)
.parse::<f64>()?
/ visibility_right)
) )
} else { } else {
format!( format!(
@@ -895,10 +897,7 @@ impl Metar {
) -> Vec<String> { ) -> Vec<String> {
let mut missing_metar_icaos: Vec<String> = vec![]; let mut missing_metar_icaos: Vec<String> = vec![];
let current_time = chrono::Local::now().naive_local().and_utc().timestamp(); let current_time = chrono::Local::now().naive_local().and_utc().timestamp();
let db_metars_set: HashSet<&str> = db_metars let db_metars_set: HashSet<&str> = db_metars.iter().map(|icao| icao.icao.as_str()).collect();
.iter()
.map(|icao| icao.icao.as_str())
.collect();
let station_icaos_set: HashSet<&str> = station_icaos.iter().map(|s| s.as_str()).collect(); let station_icaos_set: HashSet<&str> = station_icaos.iter().map(|s| s.as_str()).collect();
for difference in db_metars_set.symmetric_difference(&station_icaos_set) { for difference in db_metars_set.symmetric_difference(&station_icaos_set) {
missing_metar_icaos.push(difference.to_string()); missing_metar_icaos.push(difference.to_string());
@@ -986,17 +985,63 @@ impl Metar {
let pool = db::pool(); let pool = db::pool();
let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!( let metar_rows: Vec<MetarRow> = sqlx::query_as::<_, MetarRow>(&format!(
r#" r#"
SELECT DISTINCT ON (icao) * FROM {} WHERE icao = ANY($1) ORDER BY icao, observation_time DESC SELECT DISTINCT ON (icao) * FROM {}
"#, WHERE icao = ANY($1)
AND observation_time >= NOW() - INTERVAL '50 minutes'
ORDER BY icao, observation_time DESC
"#,
TABLE_NAME TABLE_NAME
)) ))
.bind(icao_list) .bind(icao_list)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
metars = metar_rows for metar_row in metar_rows {
.into_iter() let metar = match Metar::from_db(metar_row) {
.filter_map(|metar_db| Metar::from_db(metar_db).ok()) Ok(m) => m,
.collect(); Err(err) => {
log::error!("{}", err);
continue;
}
};
let icao = metar.icao.clone();
let observation_time = metar.observation_time.clone();
tokio::spawn(async move {
match Airport::update(
&icao,
&UpdateAirport {
icao: None,
iata: None,
local: None,
name: None,
category: None,
iso_country: None,
iso_region: None,
municipality: None,
elevation_ft: None,
longitude: None,
latitude: None,
has_tower: None,
has_beacon: None,
runways: None,
frequencies: None,
public: None,
latest_metar_observation: Some(observation_time),
},
)
.await
{
Ok(_) => {}
Err(err) => log::error!(
"Unable to update airport {} with the latest observation time: {}",
icao,
err
),
};
});
metars.push(metar);
}
} }
let mut conn = redis_async_connection().await?; let mut conn = redis_async_connection().await?;
@@ -1011,17 +1056,19 @@ impl Metar {
let result: RedisResult<Option<bool>> = conn.get(icao).await; let result: RedisResult<Option<bool>> = conn.get(icao).await;
match result { match result {
Ok(Some(value)) => { Ok(Some(value)) => {
if value { if !value {
updated_missing_icao_list.push(icao); updated_missing_icao_list.push(icao);
} }
} }
Ok(None) => { Ok(None) => updated_missing_icao_list.push(icao),
updated_missing_icao_list.push(icao); Err(err) => {
log::error!("{}", err);
return Err(err.into());
} }
Err(err) => return Err(err.into()),
} }
} }
} }
dbg!(&updated_missing_icao_list);
if !updated_missing_icao_list.is_empty() { if !updated_missing_icao_list.is_empty() {
log::trace!( log::trace!(
"Retrieving missing METAR data for {:?}", "Retrieving missing METAR data for {:?}",

4
ui/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "aviation-ui", "name": "aviation-ui",
"version": "0.1.2", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aviation-ui", "name": "aviation-ui",
"version": "0.1.2", "version": "0.1.0",
"dependencies": { "dependencies": {
"@mantine/core": "^7.17.2", "@mantine/core": "^7.17.2",
"@mantine/form": "^7.17.2", "@mantine/form": "^7.17.2",

View File

@@ -1,5 +1,8 @@
import { Divider, Drawer, Group } from '@mantine/core'; import { Box, Divider, Drawer, Group, Text } from '@mantine/core';
import { Airport, AirportCategory } from '@lib/airport.types.ts'; import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
import { useEffect, useState } from 'react';
import { getMetars } from '@lib/metar.ts';
export default function AirportDrawer({ export default function AirportDrawer({
airport, airport,
@@ -8,9 +11,24 @@ export default function AirportDrawer({
airport: Airport | null; airport: Airport | null;
setAirport: (airport: Airport | null) => void; setAirport: (airport: Airport | null) => void;
}) { }) {
const [metar, setMetar] = useState<Metar | undefined>(undefined);
useEffect(() => {
if (airport != null) {
getMetars({ icaos: [airport.icao] }).then((m) => {
if (m.length > 0) {
setMetar(m[0]);
}
});
}
}, [airport]);
if (!airport) { if (!airport) {
return null; return null;
} }
const metarColor = getMarkerColor(metar?.flight_category || 'UNKN');
return ( return (
<Drawer <Drawer
opened={true} opened={true}
@@ -25,27 +43,35 @@ export default function AirportDrawer({
withOverlay={false} withOverlay={false}
closeOnClickOutside={false} closeOnClickOutside={false}
> >
<Group> <Box mb='lg'>
<div>ICAO: {airport.icao}</div> {metar && metar.flight_category && (
<div>Category: {airportCategoryToText(airport.category)}</div> <Group justify='space-between' mb='md'>
<div> <Text style={{ color: metarColor }}>{metar.flight_category}</Text>
Country / Region: {airport.iso_country}, {airport.iso_region} <Text size='sm'>{metar.observation_time ? new Date(metar.observation_time).toLocaleString() : 'N/A'}</Text>
</div> </Group>
<div>Municipality: {airport.municipality || 'N/A'}</div>
<div>Local Code: {airport.local || 'N/A'}</div>
<div>Elevation: {airport.elevation_ft}</div>
<div>
Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)}
</div>
<div>Control Tower: {airport.has_tower ? 'Yes' : 'No'}</div>
<div>Beacon: {airport.has_beacon ? 'Yes' : 'No'}</div>
{airport.latest_metar && airport.latest_metar.flight_category && (
<>
<Divider my='sm' />
<div>Flight Category: {airport.latest_metar.flight_category}</div>
</>
)} )}
</Group> <Group>
<div>ICAO: {airport.icao}</div>
<div>Category: {airportCategoryToText(airport.category)}</div>
<div>
Country / Region: {airport.iso_country}, {airport.iso_region}
</div>
<div>Municipality: {airport.municipality || 'N/A'}</div>
<div>Local Code: {airport.local || 'N/A'}</div>
<div>Elevation: {airport.elevation_ft}</div>
<div>
Coordinates: {airport.latitude.toFixed(4)}, {airport.longitude.toFixed(4)}
</div>
<div>Control Tower: {airport.has_tower ? 'Yes' : 'No'}</div>
<div>Beacon: {airport.has_beacon ? 'Yes' : 'No'}</div>
{metar && metar.flight_category && (
<>
<Divider my='sm' />
<div>Flight Category: {metar.flight_category}</div>
</>
)}
</Group>
</Box>
</Drawer> </Drawer>
); );
} }

View File

@@ -2,6 +2,7 @@ import { Airport, AirportCategory } from '@lib/airport.types.ts';
import { Marker, Popup } from 'react-leaflet'; import { Marker, Popup } from 'react-leaflet';
import L from 'leaflet'; import L from 'leaflet';
import { useRef } from 'react'; import { useRef } from 'react';
import { getMarkerColor } from '@lib/metar.types.ts';
export default function AirportMarker({ export default function AirportMarker({
index, index,
@@ -34,21 +35,6 @@ export default function AirportMarker({
); );
} }
function getMarkerInfo(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): [string, number] {
switch (flightCategory) {
case 'IFR':
return ['#ff0100', 5];
case 'LIFR':
return ['#7f007f', 6];
case 'MVFR':
return ['#00f', 7];
case 'VFR':
return ['#018000', 8];
case 'UNKN':
return ['#696969', 4];
}
}
function createCustomIcon(airport: Airport): L.DivIcon { function createCustomIcon(airport: Airport): L.DivIcon {
if (airport.category === AirportCategory.HELIPORT) { if (airport.category === AirportCategory.HELIPORT) {
return L.divIcon({ return L.divIcon({
@@ -73,12 +59,12 @@ function createCustomIcon(airport: Airport): L.DivIcon {
} else { } else {
// Default to a filled circle. // Default to a filled circle.
const flightCategory = airport.latest_metar?.flight_category || 'UNKN'; const flightCategory = airport.latest_metar?.flight_category || 'UNKN';
const info = getMarkerInfo(flightCategory); const color = getMarkerColor(flightCategory);
if (flightCategory == 'UNKN') { if (flightCategory == 'UNKN') {
return L.divIcon({ return L.divIcon({
html: ` html: `
<div style=" <div style="
background-color: ${info[0]}; background-color: ${color};
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
@@ -93,7 +79,7 @@ function createCustomIcon(airport: Airport): L.DivIcon {
return L.divIcon({ return L.divIcon({
html: ` html: `
<div style=" <div style="
background-color: ${info[0]}; background-color: ${color};
width: 18px; width: 18px;
height: 18px; height: 18px;
border-radius: 50%; border-radius: 50%;

10
ui/src/lib/metar.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Metar } from '@lib/metar.types.ts';
import { getRequest } from '@lib/index.ts';
export async function getMetars({ icaos, force }: { icaos: string[]; force?: boolean }): Promise<Metar[]> {
const response = await getRequest('metars', {
icaos: icaos,
force: force
});
return response?.json() || {};
}

View File

@@ -3,12 +3,24 @@ export interface SkyCondition {
cloud_base_ft_agl: number; cloud_base_ft_agl: number;
} }
export interface QualityControlFlags { export interface PeakWind {
auto: boolean; degrees: number;
auto_station_without_precipitation: boolean; speed: number;
auto_station_with_precipication: boolean; hour?: number;
maintenance_indicator_on: boolean; minute?: number;
corrected: boolean; }
export interface Remarks {
peak_wind?: PeakWind;
auto_station_type?: string;
maintenance_indicator_on?: boolean;
rvr_missing?: boolean;
precipitation_identifier_information_not_available?: boolean;
precipitation_information_not_available?: boolean;
freezing_rain_information_not_available?: boolean;
thunderstorm_information_not_available?: boolean;
visibility_at_secondary_location_not_available?: boolean;
sky_condition_at_secondary_location_not_available?: boolean;
} }
export interface RunwayVisualRange { export interface RunwayVisualRange {
@@ -19,25 +31,44 @@ export interface RunwayVisualRange {
} }
export interface Metar { export interface Metar {
icao: string;
raw_text: string; raw_text: string;
station_id: string;
observation_time: string; observation_time: string;
temp_c: number; flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN';
dewpoint_c: number; report_modifier?: string;
wind_dir_degrees: string; becoming_change?: boolean;
wind_speed_kt: number; no_significant_change?: boolean;
wind_gust_kt: number; temporary_change?: boolean;
variable_wind_dir_degrees: string; temp_c?: number;
visibility_statute_mi: string; dew_point_c?: number;
estimated_humidity?: number;
wind_dir_degrees?: string;
wind_speed_kt?: number;
wind_gust_kt?: number;
variable_wind_dir_degrees?: string;
visibility_statute_mi?: string;
runway_visual_range: RunwayVisualRange[]; runway_visual_range: RunwayVisualRange[];
altim_in_hg: number; altimeter_in_hg?: number;
sea_level_pressure_mb: number; sea_level_pressure_mb?: number;
quality_control_flags: QualityControlFlags; remarks: Remarks;
weather_phenomena: string[]; weather_phenomena: string[];
sky_condition: SkyCondition[]; sky_condition: SkyCondition[];
flight_category: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'; max_temp_c?: number;
three_hr_pressure_tendency_mb: number; min_temp_c?: number;
max_t_c: number; density_altutude?: number;
min_t_c: number; }
precip_in: number;
export function getMarkerColor(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): string {
switch (flightCategory) {
case 'IFR':
return '#ff0100';
case 'LIFR':
return '#7f007f';
case 'MVFR':
return '#00f';
case 'VFR':
return '#018000';
case 'UNKN':
return '#696969';
}
} }