AI: Add IATA resolution

This commit is contained in:
Emil Lerch 2025-12-18 10:41:32 -08:00
parent a1815e88f9
commit 3a7d65eeb7
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 181 additions and 8 deletions

View file

@ -46,10 +46,6 @@ This directory contains comprehensive documentation for rewriting wttr.in in Zig
- Based on location coordinates and timezone
- Display in custom format output
7. **Airport Database**
- IATA airport code to coordinates mapping
- Use dedicated airport data instead of geocoding service
## Documentation Files
### [TARGET_ARCHITECTURE.md](TARGET_ARCHITECTURE.md) ⭐ NEW

View file

@ -10,6 +10,10 @@
.url = "https://github.com/maxmind/libmaxminddb/archive/refs/tags/1.11.0.tar.gz",
.hash = "N-V-__8AAAYyBQCd9x7qVVFKQIxi01UZ1K8ZFZFfTzj99CvX",
},
.openflights = .{
.url = "https://github.com/jpatokal/openflights/archive/refs/heads/master.tar.gz",
.hash = "N-V-__8AAKQtFgNwqtlYfjcAZQB5M1Vqc6ZPqjHkEaMHsJoT",
},
},
.fingerprint = 0x710c2b57e81aa678,
.minimum_zig_version = "0.15.0",

View file

@ -7,6 +7,7 @@ pub const Config = struct {
cache_dir: []const u8,
geolite_path: []const u8,
geocache_file: ?[]const u8,
airports_dat_path: ?[]const u8,
pub fn load(allocator: std.mem.Allocator) !Config {
return Config{
@ -24,6 +25,7 @@ pub const Config = struct {
.cache_dir = std.process.getEnvVarOwned(allocator, "WTTR_CACHE_DIR") catch try allocator.dupe(u8, "/tmp/wttr-cache"),
.geolite_path = std.process.getEnvVarOwned(allocator, "WTTR_GEOLITE_PATH") catch try allocator.dupe(u8, "./GeoLite2-City.mmdb"),
.geocache_file = std.process.getEnvVarOwned(allocator, "WTTR_GEOCACHE_FILE") catch null,
.airports_dat_path = std.process.getEnvVarOwned(allocator, "WTTR_AIRPORTS_DAT") catch null,
};
}
@ -32,6 +34,7 @@ pub const Config = struct {
allocator.free(self.cache_dir);
allocator.free(self.geolite_path);
if (self.geocache_file) |f| allocator.free(f);
if (self.airports_dat_path) |f| allocator.free(f);
}
};
@ -45,4 +48,5 @@ test "config loads defaults" {
try std.testing.expectEqual(@as(usize, 10_000), cfg.cache_size);
try std.testing.expectEqualStrings("./GeoLite2-City.mmdb", cfg.geolite_path);
try std.testing.expect(cfg.geocache_file == null);
try std.testing.expect(cfg.airports_dat_path == null);
}

View file

@ -0,0 +1,131 @@
const std = @import("std");
pub const Airport = struct {
iata: []const u8,
name: []const u8,
latitude: f64,
longitude: f64,
};
pub const AirportDB = struct {
allocator: std.mem.Allocator,
airports: std.StringHashMap(Airport),
pub fn initFromFile(allocator: std.mem.Allocator, file_path: []const u8) !AirportDB {
const file = try std.fs.cwd().openFile(file_path, .{});
defer file.close();
const csv_data = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max
defer allocator.free(csv_data);
return try init(allocator, csv_data);
}
pub fn init(allocator: std.mem.Allocator, csv_data: []const u8) !AirportDB {
var airports = std.StringHashMap(Airport).init(allocator);
var lines = std.mem.splitScalar(u8, csv_data, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
const airport = parseAirportLine(allocator, line) catch continue;
if (airport.iata.len == 3) {
try airports.put(airport.iata, airport);
}
}
return AirportDB{
.allocator = allocator,
.airports = airports,
};
}
pub fn deinit(self: *AirportDB) void {
var it = self.airports.iterator();
while (it.next()) |entry| {
self.allocator.free(entry.key_ptr.*);
self.allocator.free(entry.value_ptr.name);
}
self.airports.deinit();
}
pub fn lookup(self: *AirportDB, iata_code: []const u8) ?Airport {
return self.airports.get(iata_code);
}
fn parseAirportLine(allocator: std.mem.Allocator, line: []const u8) !Airport {
// CSV format: ID,Name,City,Country,IATA,ICAO,Lat,Lon,...
var fields = std.mem.splitScalar(u8, line, ',');
_ = fields.next() orelse return error.InvalidFormat; // ID
const name_quoted = fields.next() orelse return error.InvalidFormat; // Name
_ = fields.next() orelse return error.InvalidFormat; // City
_ = fields.next() orelse return error.InvalidFormat; // Country
const iata_quoted = fields.next() orelse return error.InvalidFormat; // IATA
_ = fields.next() orelse return error.InvalidFormat; // ICAO
const lat_str = fields.next() orelse return error.InvalidFormat; // Lat
const lon_str = fields.next() orelse return error.InvalidFormat; // Lon
// Remove quotes from fields
const name = try unquote(allocator, name_quoted);
const iata = try unquote(allocator, iata_quoted);
// Skip if IATA is "\\N" (null)
if (std.mem.eql(u8, iata, "\\N")) {
allocator.free(name);
allocator.free(iata);
return error.NoIATA;
}
const lat = try std.fmt.parseFloat(f64, lat_str);
const lon = try std.fmt.parseFloat(f64, lon_str);
return Airport{
.iata = iata,
.name = name,
.latitude = lat,
.longitude = lon,
};
}
fn unquote(allocator: std.mem.Allocator, quoted: []const u8) ![]const u8 {
if (quoted.len >= 2 and quoted[0] == '"' and quoted[quoted.len - 1] == '"') {
return allocator.dupe(u8, quoted[1 .. quoted.len - 1]);
}
return allocator.dupe(u8, quoted);
}
};
test "parseAirportLine valid" {
const allocator = std.testing.allocator;
const line = "1,\"Goroka Airport\",\"Goroka\",\"Papua New Guinea\",\"GKA\",\"AYGA\",-6.081689834590001,145.391998291,5282,10,\"U\",\"Pacific/Port_Moresby\",\"airport\",\"OurAirports\"";
const airport = try AirportDB.parseAirportLine(allocator, line);
defer allocator.free(airport.iata);
defer allocator.free(airport.name);
try std.testing.expectEqualStrings("GKA", airport.iata);
try std.testing.expectEqualStrings("Goroka Airport", airport.name);
try std.testing.expectApproxEqAbs(@as(f64, -6.081689834590001), airport.latitude, 0.0001);
try std.testing.expectApproxEqAbs(@as(f64, 145.391998291), airport.longitude, 0.0001);
}
test "parseAirportLine with null IATA" {
const allocator = std.testing.allocator;
const line = "1,\"Test Airport\",\"City\",\"Country\",\"\\N\",\"ICAO\",0.0,0.0";
try std.testing.expectError(error.NoIATA, AirportDB.parseAirportLine(allocator, line));
}
test "AirportDB lookup" {
const allocator = std.testing.allocator;
const csv = "1,\"Munich Airport\",\"Munich\",\"Germany\",\"MUC\",\"EDDM\",48.353802,11.7861,1487,1,\"E\",\"Europe/Berlin\",\"airport\",\"OurAirports\"";
var db = try AirportDB.init(allocator, csv);
defer db.deinit();
const result = db.lookup("MUC");
try std.testing.expect(result != null);
try std.testing.expectEqualStrings("Munich Airport", result.?.name);
try std.testing.expectApproxEqAbs(@as(f64, 48.353802), result.?.latitude, 0.0001);
}

View file

@ -1,6 +1,7 @@
const std = @import("std");
const GeoIP = @import("geoip.zig").GeoIP;
const GeoCache = @import("geocache.zig").GeoCache;
const AirportDB = @import("airports.zig").AirportDB;
pub const Location = struct {
name: []const u8,
@ -20,12 +21,14 @@ pub const Resolver = struct {
allocator: std.mem.Allocator,
geoip: ?*GeoIP,
geocache: *GeoCache,
airports: ?*AirportDB,
pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache) Resolver {
pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache, airports: ?*AirportDB) Resolver {
return .{
.allocator = allocator,
.geoip = geoip,
.geocache = geocache,
.airports = airports,
};
}
@ -154,7 +157,24 @@ pub const Resolver = struct {
}
fn resolveAirport(self: *Resolver, code: []const u8) !Location {
// For now, treat as geocoded location
// Try airport database first
if (self.airports) |airports| {
// Convert to uppercase for lookup
var upper_code: [3]u8 = undefined;
for (code, 0..) |c, i| {
upper_code[i] = std.ascii.toUpper(c);
}
if (airports.lookup(&upper_code)) |airport| {
return Location{
.name = try self.allocator.dupe(u8, airport.name),
.latitude = airport.latitude,
.longitude = airport.longitude,
};
}
}
// Fall back to geocoding
return self.resolveGeocoded(code);
}
@ -196,6 +216,7 @@ test "resolver init" {
const allocator = std.testing.allocator;
var geocache = try GeoCache.init(allocator, null);
defer geocache.deinit();
const resolver = Resolver.init(allocator, null, &geocache);
const resolver = Resolver.init(allocator, null, &geocache, null);
try std.testing.expect(resolver.geoip == null);
try std.testing.expect(resolver.airports == null);
}

View file

@ -7,6 +7,7 @@ const Server = @import("http/server.zig").Server;
const RateLimiter = @import("http/rate_limiter.zig").RateLimiter;
const GeoIP = @import("location/geoip.zig").GeoIP;
const GeoCache = @import("location/geocache.zig").GeoCache;
const AirportDB = @import("location/airports.zig").AirportDB;
const Resolver = @import("location/resolver.zig").Resolver;
pub const std_options: std.Options = .{
@ -34,6 +35,9 @@ pub fn main() !void {
} else {
try stdout.print("Geocache: in-memory only\n", .{});
}
if (cfg.airports_dat_path) |f| {
try stdout.print("Airports database: {s}\n", .{f});
}
try stdout.flush();
// Initialize GeoIP database
@ -48,8 +52,20 @@ pub fn main() !void {
var geocache = try GeoCache.init(allocator, cfg.geocache_file);
defer geocache.deinit();
// Initialize airports database
var airports_db: ?AirportDB = null;
if (cfg.airports_dat_path) |path| {
airports_db = AirportDB.initFromFile(allocator, path) catch |err| blk: {
std.log.warn("Failed to load airports database: {}", .{err});
break :blk null;
};
}
if (airports_db) |*db| {
defer db.deinit();
}
// Initialize location resolver
var resolver = Resolver.init(allocator, &geoip, &geocache);
var resolver = Resolver.init(allocator, &geoip, &geocache, if (airports_db) |*db| db else null);
var cache = try Cache.init(allocator, .{
.max_entries = cfg.cache_size,
@ -90,5 +106,6 @@ test {
_ = @import("render/custom.zig");
_ = @import("location/geoip.zig");
_ = @import("location/geocache.zig");
_ = @import("location/airports.zig");
_ = @import("location/resolver.zig");
}