Full Width [alt+shift+f] Shortcuts [alt+shift+k]
Sign Up [alt+shift+s] Log In [alt+shift+l]
1

Comparison of Models Used for Aviation Briefings

from John Wiseman [alt+shift+b] in technology

I have a collection of prompts and tools that I give to LLMs to generate daily aviation briefings. The briefings are supposed to be a summary of everything interesting that’s happened in the air in the past several hours, either globally or in a region of interest. The model should contextualize aircraft activity in terms of behavioral analysis, historical patterns, geography, and current events: The fire west of Piñon Hills stood at roughly 2,700 acres and 0% containment in the latest reports, with cooler overnight conditions helping crews strengthen lines. This morning’s air picture is recon and rotary rather than Saturday’s tanker parade: N8PQ, the Aero Commander 690A, is back overhead at 8,000 ft, K-MAX N107MW is working the fire at 4,200 ft, Bell 205 N33HX is staging at Fox Field, and Tanker 104, an Erickson Aero Tanker MD-87, sits on the ramp at San Bernardino International. Writing a worthwhile briefing is a challenging task. It’s agentic, with models using 15-20 tools including querying databases, searching the web, and writing and executing scripts to process data. It also requires judgment from a model on what to include in the briefing and how to put together separate pieces of data to tell a story without overstating a conclusion. The agents filter through a large amount of data looking for interesting items, find corroborating sources, take screenshots, and edit it all into a short (less than 6000 characters) report. I’ve tried Claude Opus 4.8, Fable 5, OpenAI GPT-5.6-Sol, and Kimi K3 on this task. The tl;dr if you’re paying retail API token prices: Opus 4.8 is cheap, fast, and its output is good. Kimi K3 costs about the same as Opus 4.8 and its output is of similar quality (it’s saved by the fact-checker) but it takes 5-6x as long as Opus 4.8. Fable 5 costs 2-5x as much as Opus 4.8 or Kimi K3. I like the feel of its output best, but it needs to be reined in by the fact-checker almost as much as Kimi K3. GPT-5.6-Sol is cheap and fast and makes few...
18th Jul 2026

Stay updated

Get a weekly newsletter with the top 5 articles worth reading every week.

More from John Wiseman

Embedding the Sky

Two projects at the intersection of geospatial data and AI. First, a semantic search engine for aerial imagery, fine-tuned on OpenStreetMap-tagged data. Second, WARP—a custom neural model that learns embeddings of aircraft flight trajectories, trained on roughly a million aircraft tracks. Given any flight path, WARP can instantly retrieve the most similar trajectories from history. It enables applications from OSINT and anomaly detection to safety analysis and flight planning. The same latent structure that made word2vec transformative for language now becomes available for aircraft behavior. Presented at Spec.LA on December 18, 2025. I'm going to quickly show two things tonight. The past few years I've been working mostly in geospatial, aviation, and AI, and that's what these are. One is geospatial semantic search and the other is a foundation model for aircraft. I believe they're both extremely valuable and useful. # Both projects are about embeddings. Embeddings are points inside a latent or conceptual space. Parts of that space that are conceptually similar are closer together. There are neural models that will generate an embedding of an image or text or other things. With the right model, an embedding of a photo of a golden retriever is close to the embedding of the text "golden retriever" and far from the embedding of an image of an aircraft boneyard. # I showed an early version of PIMINTO at Spec a long time ago. I've refined it a lot since then. It uses a model that's fine tuned on satellite imagery and text describing the imagery based on OpenStreetMap tags. I'm going to give a live demo of the new site, which is public. piminto.obliscence.com | Demo video # Next is WARP, which is a model I'm building to create embeddings of aircraft paths. Back to aircraft, big surprise. # I trained the model on about a million aircraft tracks, pretty much everything that flew in the western U.S. over the course of a week. # At this point training takes 18 hours on a 4090 GPU. # I've come up with a custom architecture for this task. # I kept feeding the single file into GPT o3 or GPT 5, either asking it to critique the code or describing specific problems I was running into. Sometimes I'd paste a CSV of the loss curve. It would give me detailed descriptions of what to do next, and I'd paste those into Cline with Sonnet 4.5 doing the actual coding. # How does it do? # I hoped that if I took a few thousand trajectories and clustered their embeddings I would see clear separation and similarities. But that didn't really happen. A few extreme examples looked good, like skydiving aircraft being outliers, but otherwise I couldn't really make sense of it. But then I read someone who said sometimes clustering just looks nonsensical even if the embeddings are good at capturing similarity. # So I wrote an interface to let me compare any of the million trajectories. I can select one trajectory and it will almost instantly show me the other most similar trajectories in the database. In this screenshot the blue is the query trajectory and the red is the most similar trajectory it could find. This one isn't too bad. The altitude and speed profile are very similar but not exactly the same. Promising. # This is the signature of touch and go practice. This is a good match. # These are two different skydiving aircraft. They climb up to altitude, everyone jumps out, then they race back to the field. # These are two different flights by the same aircraft. But what does it really look like? # This is pretty cool. This looks like two training flights, by the same aircraft but on different days. Each time following the same route and including kind of a loop and then some tight circles. # These are two different Thales Watchkeeper drones, flying on different days over Fort Bliss. As a reminder, I chose the track on the left and then the system searched the database in a fraction of a second and determined that the track on the right was most similar to it. # This starts to hint at some potential uses. Both of these helicopters are doing some sort of utility work in forest. Hauling something maybe. Again, the track on the left is the query track, and the one on the right is the best match. # These are two different aircraft on two different days, dropping sterile fruit flies on different parts of Los Angeles. There are many other aircraft flying these sorts of lawnmower patterns, for example doing LIDAR collection, but it determined the one on the right is the most similar. # A useful aircraft embedding model unlocks a lot of interesting and valuable use cases, similar to how word2vec and text embeddings unlocked a whole class of semantic search, clustering, retrieval, anomaly detection, and analogy-style reasoning over language—except now the same kind of latent structure becomes available for aircraft behavior, trajectories, and operational patterns. # # { img.style.cursor = 'pointer'; img.addEventListener('click', () => { lightboxImg.src = img.src; lightbox.classList.add('active'); }); }); function closeLightbox() { lightbox.classList.remove('active'); } document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeLightbox(); });

27th Feb 2026 1 votes

More in technology

Is This A Joke? In The Auth Header? (F5 BIG-IP UnAuth Heap-Overflow to RCE CVE-2026-94127)

Well, well, well, well, well, well, well, well, well, well, well, well, well, well, well. We're back. Sorry. We've been watching the onslaught of vulnerabilities flood the internet. Every man, dog, and their grandmas (apparently?) are now using LLMs to find and reproduce vulnerabilities - it’

15 hours ago 1 votes
The Reason You Prohibit Things

You want less of them. That’s the reason. You may find that it’s too hard to stop people from doing the thing, literally blood, sweat, and tears trying to prosecute people, but that’s a different thing.

17 hours ago
Solitaire Alone Together

Solitaire Alone Together I made a new game. It's called Solitaire Alone Together. It's Windows 98 solitaire, but you can play with everyone else on the internet. Read the full post on my blog! Here's a raw link, if you need it: https://eieio.games/blog/solitaire-alone-together

3 days ago 1 votes
All The Ways I Broke My Website

This post is a living diary of all the times I messed up something with my website in a funny way. I value those who have the confidence to own their mistakes and share the learning with others, and so this is me doing just that! That Time I Accidentally Made a Tarpit That Time I Accidentally Made Really Large Headers That Time I Accidentally Made a Tarpit Back to Top A "tarpit" is an unofficial term used in computing to describe an intentionally slow response to a request. In these modern times many people are using tarpits as a way to combat the relentless theft of data by AI companies, although there's little to no evidence of that actually being in any way effective. I don't use tarpits, at least not intentionally, but there was that one time when I accidentally created a tarpit and trapped all visitors in it. As I've shared previously, I refuse connections from IP addresses that are blocked or belong to a blocked subnet, and I enforce this firewall during the TCP handshake. The logic here is straightforward: there's no reason to waste resources doing a TLS handshake, accepting an HTTP request, and then rejecting the connection if I already know I'm going to reject it at the earliest step. At the time, the code worked like this: the HTTP server would repeatedly call the Accept() function below expecting a new connection. I've added some comments to help explain the logic. func (l *firewallListener) Accept() (net.Conn, error) { // Accept the connection from the TCP listener. This blocks until there is a connection to accept or the listner was closed. conn, err := l.l.AcceptTCP() if err != nil { return conn, err } // Separate the IP address out from the remote address (which includes the port) ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } // Check if it's blocked, if so close the connection and return a refuseError if IsBlocked(ip, true) { conn.Close() return nil, &refuseError{} } // Otherwise return the connection on to the HTTP server return conn, nil } If the incoming connection was from a blocked IP then I'd return a refuseError. I need to use a specific error interface because the HTTP server will halt if it encounters a non-temporary error from the call to Accept(), so I need to return an error that satisfies the definition of a temporary error. I defined refuseError like this: type refuseError struct{} func (e *refuseError) Error() string { return "." } func (e *refuseError) Timeout() bool { return true } func (e *refuseError) Temporary() bool { return true } func (e *refuseError) Is(err error) bool { return err == context.DeadlineExceeded } This did accomplish the goal of rejecting connections before the TLS handshake for blocked addresses, but it had one really unintended and difficult to track down side-effect. Accepting connections is done serially, after which servers typically then process that request on a dedicated thread (or in Go's case a goroutine). This means that any delays during the accept loop will block all incoming connection. What I had missed while reviewing the code for Go's HTTP server is that when it receives a temporary error from Accept() is that while it doesn't abort, it does sleep for up to a maximum of 1 second. This sleep blocks the entire server for all incoming connections. You can see a trimmed copy of the code that does this below, with some marks I've added which I will explain. // src/net/http/server.go // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. for { // (1) rw, err := l.Accept() if err != nil { if s.shuttingDown() { return ErrServerClosed } // (2) if ne, ok := err.(net.Error); ok && ne.Temporary() { if tempDelay == 0 { tempDelay = 5 * time.Millisecond } else { tempDelay *= 2 } if max := 1 * time.Second; tempDelay > max { tempDelay = max } s.logf("http: Accept error: %v; retrying in %v", err, tempDelay) // (3) time.Sleep(tempDelay) continue } return err } connCtx := ctx if cc := s.ConnContext; cc != nil { connCtx = cc(connCtx, rw) if connCtx == nil { panic("ConnContext returned nil") } } tempDelay = 0 c := s.newConn(rw) c.setState(c.rwc, StateNew, runHooks) // before Serve can return // (4) go c.serve(connCtx) } At mark 1 the server calls the Accept() function, this is the exact function that I defined above where I might return a temporary error. At mark 2 it checks if an error was returned, and if so if that error is temporary. If there was a temporary error, at mark 3 it sleeps for an increasing amount of time up-to 1 second, otherwise, at mark 4 it processes the connection on a dedicated goroutine, which allows the server to accept the next connection. I'm not entirely sure why the Go developers added this sleep delay and the change when it was introduced doesn't provide any meaningful insight. Regardless, it caused significant latency connecting to my website when a flood of rejected requests was coming in. It just goes to show how important it is to write meaningful commit messages, because you never know when somebody might come back years later wondering "why was this done?". I sure home I don't come to eat those words later. Coincidentally, you can actually see this happening if you look carefully at one of the metric graphs I shared in my first post about my server's security model: Securing My Web Infrastructure. This is the graph I shared in that blog post and while I didn't know it at the time, the fact that these request spikes all cap-out at around 60 requests per minute was not a coincidence. These requests were not being made with a limit in mind, attackers rarely ever care about things like that, instead it the accidental tarpit I had created. The downside to this was that while the malicious requests were being rate-limited, all requests were being rate-limited, up to a point of taking so long they timed out. The Fix Fixing the issue was relatively straightforward enough. Instead of returning a temporary error to the HTTP server during the accept loop, just don't return anything at all and wait for the next valid connection. func (l *firewallListener) Accept() (net.Conn, error) { for { conn, err := l.l.AcceptTCP() if err != nil { return conn, err } ip := utils.SocketStringToIPAddress(conn.RemoteAddr().String()) if ip == nil { return nil, nil } if IsBlocked(ip, true) { conn.SetLinger(0) conn.Close() continue } return conn, nil } } Now, when the HTTP server calls Accept(), the only time it returns is with a connection from an IP that isn't blocked, or if there genuinely is an error. No more sleep delays, no more excessive timeouts. That Time I Accidentally Made Really Large Headers Back to Top For about 10 years now all major browsers have support for a security feature known as a Content Security Policy or CSP. A CSP is an HTTP header provided by the server that instructs the browser on where it can load assets from, this could be scripts, images, stylesheets, fonts, etc. The objective of using a CSP is to prevent against injected HTML that tries to load assets, such as a malicious Javascript file, from a remote source. With so much user-provided content being available online, it's very possible for this to happen without an attacker compromising the entire web server. CSP protects against that by saying "scripts can only be loaded from these domains". That's a really simplified way of looking at it, anyways. My web server supports injecting the CSP header automatically, but before I go on I need to explain a little bit about the structure of my web server. When an incoming HTTP request is accepted (having passed all firewall checks and assertions), we look at the destination host for the request. This can either be the value of the Host header or as specified during the TLS handshake. We then look at a map of hosts to apps. Apps are just an interface that accept a few methods: type App interface { Cleanup() ReloadConfig() ServeHTTP(rw http.ResponseWriter, r *http.Request) Setup(dataDir string) error Shutdown() } One of the apps is the Proxy app, which is a reverse proxy - it accepts the incoming HTTP request and then proxies it on to another host. This is a very common design, especially with increasingly complex TLS setups. Because each app is unique to a host, and different hosts have different requirements for CSP rules, the proxy app includes a CSP preset that we use to build the header value, or skip it entirely. When the proxy app was going to copy an HTTP request to the downstream host, it would build the CSP header, however there was a slight bug... func (a *App) ServeHTTP(rw http.ResponseWriter, inRequest *ht2.Request) { // --snip -- if a.CSP != nil { a.CSP.ConnectSrc += " " + inRequest.Origin } CopyHttpRequest(inRequest, outRequest, rw, CopyHttpRequestOptions{ Origin: inRequest.Origin, Csp: a.CSP, Cors: a.CORS, AddHeaders: !a.SkipHeaders, UseHTTP3: a.UseHTTP3, InsecureTLS: a.InsecureTLS, }) } I'm really unsure as to what I was doing with the line to append to the ConnectSrc, but the impact is that I'm appending to a variable that lives on the App, rather than a variable that is per-request. This meant that every time there was a request to the app, any request at all, the origin would be appended to the header value. This went on for quite a long time unnoticed and unresolved, largely because I am constantly tweaking and tinkering with my web server, after all, it's how I made having a website fun again. Each time I restarted the server process, the header value would be reset, but only for it to continue to grow and grow. Eventually, after a period of being busy with other matters, the server process stayed running for long enough that the header value grew too large and HTTP clients began to reject it. There is no defined maximum for an HTTP header value, however most HTTP clients use 100KiB, which is perfectly reasonable, and this header value would continue to grow well beyond that. Diagnosing this issue turned out to be difficult as tools like Curl would fail with errors relating to entities being too large, but stopped short of saying what specifically. I eventually used openssl s_client to send an HTTP request by hand and observed my terminal window being filled with a domain name repeated thousands of times. Looking at the commit history, it was really unclear why I added the culprit lines of code. The commit message just says "Improved CSP support". It just goes to show how important it is to write - hey look it's those words I'm now having to eat! The Fix The fix was to just delete those three lines of code. Yup, it really was that simple, and fixing this bug actually made a larger positive impact than I had expected, as it was immediately clear when I fixed the bug by looking at outbound network bytes: So much traffic was being wasted on excessive header sizes. You might look at these mistakes I've made and think "wow, Ian, these are some obvious mistakes, I never would have made them!" to which I say "good for you!" with the utmost sarcasm and disdain. I enjoy making and refining software, and making anything means making mistakes along the way. Each time I make mistakes such as the ones above, I improve my skills of investigation, diagnosing, and repair. Skills that, judging by my peers in the industry, seemingly everyone is quickly willing to throw away because a robot does it "better" than you. Header Image: "Car accident on the Ffestiniog to Bala road. Nobody was hurt" by Geoff Charles, CC BY-SA 4.0, via Wikimedia Commons.

5 days ago
Buienwatch, a custom Home Assistant integration for Buienradar and Buienalarm graphs on your Apple Watch

Back in 2021, I wired up data from Buienalarm and Buienradar through Node-RED and a big pile of Jinja2 templates to get a rain forecast graph on my Apple Watch: eight Unicode block characters showing the next two hours in 15-minute chunks, glanceable without unlocking the phone. It worked, and I used it every day […] The post Buienwatch, a custom Home Assistant integration for Buienradar and Buienalarm graphs on your Apple Watch appeared first on Style over Substance.

a week ago 0 votes
📚 BoredReading

You seem to be enjoying this.

Join free to unlock everything.

Create free account

Already have an account? Sign in