Extracting POH Data with an AI Agent

Table of contents


Overview

AI agents read POH text and tables well, but they can't sample a graph accurately enough to put the result straight into a model. So the work is split:

This guide is written for both the agent and the developer. Agents: follow it step by step, and report anything you could not do. A volunteer who doesn't work in the code base uses the Digitizer alone instead, and hands the project to a developer; see Two ways to digitize a POH.

Starting an agent

Start the agent in this repository, so that it reads the AI-Assisted Development Guide, then give it these prompts in turn. Replace the angle-bracket parts.

Find the data. The agent lists what the POH has, in the performance section and elsewhere, such as oxygen tables in a systems section, and proposes a data path for each item, or a reason to leave it out, as described in Choosing what to digitize. Name the closest model already in the app as the reference model.

List the graphs and tables in the POH at <path> that the app could read, in the performance section and elsewhere, following doc/dev/aiDigitizing.md, and map them to the data paths of the <App> <reference model> model. For each one give its PDF page, its POH figure or page number, its title, whether it is a graph or a table, whether it is printed sideways, and its data path or the reason to leave it out. Also list the reference model's tables that nothing in the POH matches. Don't build anything yet.

Correct the list: it is what the agent digitizes. In the same session, "the list you made" plus your corrections will do for <list> below.

Digitize the graphs:

Build the Digitizer project digitized/<App>/<document>.ppdig from the POH at <path>, following doc/dev/aiDigitizing.md. Calibrate and sample the graphs in this list, with the data paths it gives, and leave out the items it marks to leave out: <list>. Give each graph its POH title and source, and look at a ppdig render of each one. Run ppdig check, and report your confidence in each graph and anything you left out. Don't change aircraftData.

Transcribe the tables:

Transcribe the tables in this list, with the data paths it gives, from the POH at <path> into the Digitizer project digitized/<App>/<document>.ppdig, following doc/dev/aiDigitizing.md: <list>. Run ppdig check, then compare the project with the <model> model using make <App>.ppdigCompare. List each difference with the POH page, the POH value and the code value. Don't change anything else.

Leave out the comparison when the app has no model for the POH yet.

For example, for a 1969 Cessna 310P owner's manual, whose closest model in the app is the 1970 310Q:

List the graphs and tables in the POH at <path>/310P 1969 Owners Manual.pdf that the app could read, in the performance section and elsewhere, following doc/dev/aiDigitizing.md, and map them to the data paths of the C310 C310Q#70 model. For each one give its PDF page, its POH figure or page number, its title, whether it is a graph or a table, whether it is printed sideways, and its data path or the reason to leave it out. Also list the reference model's tables that nothing in the POH matches. Don't build anything yet.

Then review the project as described in Reviewing an agent's project.

The digitize-poh skill, in .claude/skills/ at the repository root, gives an agent these steps in order, and stops after the list for your corrections. In Claude Code, run /digitize-poh <POH pdf> <App> <reference model>; tell another agent to read and follow its SKILL.md.

Setup

Naming

Each graph and table has three fields that tie it to the POH and the model:

Name the inputs and outputs with the standard Ptable keys, and keep the POH's units. Give a unit only when it differs from the app's default for that key, as in the model files.

Items that feed one Ptable

Items with the same data path combine into one Ptable, so a POH's separate graphs and tables don't have to be merged by hand:

Print the combined Ptable with ppdig export --path <data path>, or see it with View Ptable in the Digitizer. Its comment cites each item. ppdig check reports an error, and export refuses, when the items have different inputs, outputs or units, have blank cells, or give the same inputs different outputs. Units are never converted to make items agree. It warns when a family doesn't move one way from one outer value to the next, which is sometimes right: Bonanza and Baron stall speeds can fall as weight rises.

Choosing what to digitize

Digitize only the data the app reads: each item must feed a Ptable that the app's calculations read, its data path. Leave out a POH graph or table that the app computes another way. For example, the app works out range and endurance from the cruise speed and fuel flow, so a range or endurance profile usually has no data path. It depends on the app and the model, not on the kind of data: some models adjust range and endurance with factor tables from a supplement, and those the app does read.

Take the data paths from the app's code; never invent one. List a reference model's Ptables, the closest model already in the app, with:

make C310.ptables model=CT310R#76

It prints each data path with the table's title, inputs and output, in the table's units. Add args="--options <id>,..." to include the tables that options add. A path marked unread is one that no calculation read in the last make <App>.tableCoverage run; don't map data to it without asking. To see the POH figure a table came from, find its path in the model file and read the source its comment cites. For a new aircraft type with no models yet, the data paths are the ones the base methods in typeData.js read; see Create the type data.

Match each POH item to the reference table with the same role: the same inputs and output, and usually a similar title. When an item matches no table, or more than one, leave its path empty and ask. Also report the reference model's tables that no POH item matches: the new model may need that data from another page or a supplement, or may compute it another way.

Digitizing graphs

Render and measure the page

Render each page with ppdig page. It renders at 300 DPI, which is what the project embeds, so the pixel positions you measure are the ones the spec uses:

npm --prefix tools/digitizer run -s ppdig -- page <pdf> <page> -o page.png

Pixel positions are canvas coordinates, measured from the top-left corner of the image: pixel (column i, row j) covers i to i + 1 and j to j + 1, so its center is (i + 0.5, j + 0.5). Add 0.5 to an array index or to a centroid computed from array indices; otherwise every grid line and sample sits half a pixel up and to the left of the printed line. Render a copy at a higher resolution to read small labels, but measure on the 300 DPI image.

Sideways pages. POH graphs and tables are often printed sideways. Turn the page upright as the Digitizer's Edit Page does, with --rotate 90 (clockwise, the Digitizer's Rotate Clockwise) or --rotate -90 (counter-clockwise), and measure on the rotated image. Give the spec the same rotate. To keep only the graph or table, give the spec a crop: a rectangle of the rotated page. Pixel positions in the spec stay on the rotated, uncropped page; build moves them into the cropped image.

Measure the grid numerically; don't estimate it by eye. For example, sum the dark pixels along each pixel row and column of the graph area: grid lines show up as peaks. Then read the axis labels visually to give each line its value. These positions only need to be near the printed lines; ppdig regrid fits the grid to the image, as described in Anchor the grid.

Anchor the grid

Don't anchor every crossing. A developer can't correct a grid in which every point is pinned, and POHs that have been printed and scanned repeatedly have lines whose width varies, so the exact center of any one line is uncertain anyway. Anchor only enough points for the Digitizer's grid relaxation to put every line on its ink, in the order of the grid calibration technique:

  1. Corners. The edge lines are often drawn heavier than the rest, so their ink doesn't locate them. Place each corner and edge by extending the interior lines beside it, so that relaxation puts the nearest interior crossing on its line. An edge can end up off the center of its heavy line; that is correct.
  2. Edges. Anchor the edge point most out of line. Each anchor moves the points around it, so look again before choosing the next one, and repeat until the edges are aligned. These anchors align most rows and columns from end to end.
  3. Interior points. Once the edges are done, anchor the interior point most out of line in the same way, and repeat until each row and column is on its ink.

Each row and column keeps its own measured position; don't smooth lines into a regular spacing. An interior line left off its ink is the error to avoid, and because an anchor's offset spreads along the edge to its neighbors, one line that is out of step can take anchors on the lines beside it too.

ppdig regrid does this for you. It detects the crossings in the image with the Digitizer's own detector and measures each interior row and column. A line keeps its own offset but takes its shape from the nearest lines that ink covers, because a scan warps neighboring lines alike, so a curve that merges into a line or a text box that hides it doesn't move it. It then places anchors as above, re-running the grid relaxation after each one, until no line is more than 0.5 px out, a quarter of the width of a typical scanned line (--tol changes this). It keeps anchors placed by hand in the Digitizer. As when you adjust a grid in the Digitizer, samples keep their place on the image and their values follow the grid.

npm --prefix tools/digitizer run -s ppdig -- regrid digitized/C310/1976_T310R.ppdig

Expect anchors on about 5% of the crossings of a large chart, and a larger share on a small one. Run it once after build or add, then look at the result with render. Because a spec's samples are in graph units, a sample placed through a rough grid lands off its curve until you correct it, so get the grid close before you convert samples to graph units.

Fit the curves

Find each curve from the image ink, not from a visual estimate. Tracing curve pixels tends to fail on charts whose grid lines are as heavy as the curves. What works is fitting a smooth curve, such as a quadratic, that follows the most ink along the expected path, with the grid lines masked out. Keep each family of curves consistent: guide lines in an adjustment graph run roughly parallel, and the curves of a family should not cross.

Then sample each curve by the sampling rules:

ppdig resample does this for a graph's spline curves. Give each curve a few rough samples, because they only guide the fit, then run it after regrid:

npm --prefix tools/digitizer run -s ppdig -- resample digitized/C310/1976_T310R.ppdig --name "Takeoff Distance: weight"

For each curve it prints the samples' grid lines and how far the result is from the ink, in pixels and in the output's units, beside the same figures for the old samples. Check these:

A graph that joins its samples with straight lines gets samples that keep those lines on the ink. In a graph that takes each series' parameter from a sample, such as a tandem panel's guide lines, each parameter is taken again from the new sample, so check the parameters against the printed values. With no --name, it re-samples every spline graph and passes over the others; name a graph to re-sample one of the others.

ppdig auto runs regrid, resample and check together, one graph at a time, as far as that goes without a person:

npm --prefix tools/digitizer run -s ppdig -- auto digitized/C310/1976_T310R.ppdig

It reports each graph as one of these, and exits with status 1 if any needs attention:

Treat "resampled" as a claim to check, not a result to trust. The closer the rough samples are to the curve the better: tested with samples up to 3 px off the curve, every end it fitted landed within 5 px of where it was read by hand, but with samples 6 px off or more, some ends were wrong and not all of those were flagged.

Write the spec

A spec is a JSON file that describes the graphs and tables to add. Samples are given in graph units. The builder converts them to pixel positions through the calibrated grid, so they land where the Digitizer would put them.

{
	"pdf": "../../Aircraft/Cessna/C310/1976_T310R.pdf",
	"projectName": "1976 T310R",
	"graphs": [{
		"name": "Takeoff Distance — 50 ft Obstacle",
		"source": "Fig 5-3, p. 5-9",
		"path": "tko.obstacleTbl",
		"page": 91,
		"rotate": 90,
		"crop": {"x": 300, "y": 150, "width": 2000, "height": 1300},
		"axes": {
			"xMin": -40, "xMax": 50, "xIncrement": 10, "xRounding": 1, "xLabel": "oat",
			"yMin": 0, "yMax": 4000, "yIncrement": 500, "yRounding": 10, "yLabel": "runwayDistance"
		},
		"lines": {
			"cols": [412, 531, 650, 770, 889, 1008, 1127, 1246, 1366, 1485],
			"rows": [2110, 2006, 1902, 1798, 1694, 1590, 1486, 1382, 1278]
		},
		"parameterName": "pressureAltitude",
		"verification": {"value": "Mono Increase", "slope": "None"},
		"series": [
			{"parameterValue": 0, "points": [[-40, 980], [0, 1240], [50, 1650]]},
			{"parameterValue": 4000, "points": [[-40, 1180], [0, 1500], [50, 2050]]}
		]
	}],
	"tables": [{
		"name": "Landing Distance",
		"source": "Table 5-9, p. 5-20",
		"path": "ldg.obstacleTbl",
		"pages": [102],
		"regionBounds": {"x": 180, "y": 640, "width": 2200, "height": 1500},
		"columns": [{"name": "pressureAltitude"}, {"name": "oat"}, {"name": "runwayDistance"}],
		"verification": {"value": "Mono Increase", "slope": "None"},
		"rows": [[0, 0, 1370], [0, 10, 1410], [2500, 0, 1450]]
	}]
}

The pdf path is relative to the spec. The numbers above are only an illustration.

Graph fields:

Table fields: pages (the first is shown), rotate and crop (as for a graph), regionBounds (the data area, in pixels on the rotated, uncropped page), columns (inputs first, then the output; each column has a name and an optional units and limit), inputKeyCount (defaults to all columns but the last) and rows, with one value per column. A table is stored as flat rows, one per output value: a 3-input table has a row for every weight, temperature and altitude. Leave out columns and rows for a table whose data comes from ppdig seed.

separators gives the printed table's cells, as {"columns": [x, ...], "rows": [y, ...]} in pixels on the rotated, uncropped page, inside regionBounds. build and add move each one onto the nearest printed rule or into the middle of the gap between columns or lines of text, as the Digitizer's Table panel does. Measure them from the page's ink.

Build, look and check

npm --prefix tools/digitizer run -s ppdig -- build spec.json -o digitized/C310/1976_T310R.ppdig
npm --prefix tools/digitizer run -s ppdig -- render digitized/C310/1976_T310R.ppdig --name "Takeoff Distance — 50 ft Obstacle" -o overlay.png

Extracting tables

Checking a project

ppdig check reports:

Fix every error. Fix each warning or explain it in your report.

Comparing a project with a model

ppdigCompare.js interpolates every exported row in the model's Ptable and lists the rows where the model differs from the project. Inputs and outputs are compared in the project's units. Run it from the V5 project root:

make C310.ppdigCompare model=CT310R#76 file=digitized/C310/CT310R_76.ppdig args="--name 'Max Perf TO Obstacle p2' --set flaps=apch"

See ppdigCompare.js for all the options. A difference means that the project or the model is wrong: check the POH before changing either.

Re-verifying a model's data

A model whose graphs were sampled by hand, before the Digitizer, can be checked against its POH with the same tools. The project is built as for a new POH, with each graph's grid calibrated and each table's separators placed from the page, but its samples and rows are the model's own values. The developer then sees the model's data over the page in the Digitizer, and the agent measures it.

  1. Write the model's Ptables with their data to a file:
    make C310.modelDump model=C310Q#73 out=c310q73.json
    
    Add args="--sn <serial> --options <a,b>" for data that depends on them. The values are in each table's own units, as the model file declares them.
  2. Build each graph with its grid and no series, run regrid and check the grid with render, as described in Digitizing graphs. Build each table with its regionBounds and separators and no columns or rows. Name each item's axes, parameter, columns and units with the model's keys, and give each axis a rounding at least as fine as the model's values.
  3. Fill the items from the model:
    npm --prefix tools/digitizer run -s ppdig -- seed digitized/C310/1973_310q.ppdig --model c310q73.json
    
    Each item with a data path takes the model's Ptable at that path, less the inputs its fixedInputs hold. A graph's samples are placed through its grid as it is, so get the grid right first; its spline and input limits are set as the model's. It refuses an item whose keys or units differ from the model's, and one that already has samples or rows unless given --replace. It warns of values outside the axes and of values the axis rounding would change on export. Each item it fills is marked unreviewed, with a note naming the model, and the model becomes the project's reference model.
  4. Check that the project holds the model's values: ppdigCompare.js should show no differences for any item.
  5. Measure the model's values against the page, without changing the project:

The project keeps the model's values. The developer corrects what's wrong in the Digitizer, then ppdigCompare.js lists what the model needs. The model-to-ppdig skill, in .claude/skills/ at the repository root, takes an agent through these steps.

Comparing two POHs

ppdig compare-pages overlays a page of one POH on a page of another, to tell whether a graph or table is the same in both, such as in two revisions of a model's POH:

npm --prefix tools/digitizer run -s ppdig -- compare-pages 1973_310Q.pdf 91 1974_310Q.pdf 95 -o fig6-3.png

Faint ink on one page matches ink on the other, since one scan can drop a light grid that another prints. A line has to move about 5 px, 0.4 mm, before it shows. When the layout differs, most of the ink is unmatched and the values must be compared instead. The compare-pohs skill, in .claude/skills/ at the repository root, takes an agent through comparing every graph and table of two POHs this way.

Making a model file

ppdig model lays out a project's Ptables as a model data file, each at its data path and cited, as the start of a new model or of the tables to replace in an existing one:

npm --prefix tools/digitizer run -s ppdig -- model digitized/C310/1969_310p.ppdig --ref C310/C310Q#70 --name c310P_69Data -o 310P.js

The file holds only the Ptables. The rest of the model, its weights and scalars, its methods, maxAltitude, acType.createModel() and its tests, comes from the POH as described in Create the model files. The ppdig-to-model skill, in .claude/skills/ at the repository root, takes an agent through that and checks the result with ppdigCompare.js. Use it only on a project whose items have all been reviewed.

Before handing back

Agents: before you report the work as done,

Then report, for each graph and table:

Reviewing an agent's project

Developers: open the project in the Digitizer with Load Project, then for each graph:

  1. Check the grid first: with Show Grid, every grid line should lie on the printed line. Fix the grid before looking at the samples.
  2. Check that the values of samples on grid lines are the grid lines' values, and that each series follows its printed curve.
  3. Look at the verification flags in the value panel.
  4. Edit samples as needed, then save the project.

ppdig marks every graph and table it builds or changes as unreviewed, and the Digitizer's Project panel shows the mark. Opening an item in the Digitizer clears the mark, whether or not you changed it, so open each one only when you mean to check it. An item that only came up by itself, such as when a project loads, stays unreviewed. The mark is cleared in the file when you save the project. Files from before the mark existed count as reviewed.

A bar above the graph counts what's left and opens the next item, worst first, as described in Auto-sampling a POH. It works the same for an agent's project. ppdig auto leaves the notes and worst fit it reports on each graph for the bar to show.

While any item is unreviewed, View Ptable and ppdig export warn you and name them, whichever item you're exporting. ppdig export also ends the item's comment with (UNREVIEWED), so a pasted Ptable says so. Don't put a project's data in a model until the warning is gone.

Don't pick a new page image for a built graph, or rotate or crop it again with Edit Page: its grid and samples are pixel positions on its image, so they would no longer line up. The same is true of any graph made in the Digitizer. To change a graph's page view, change the spec and add it again.

For each table, compare the values against the page image beside them. The first time a table built by ppdig is opened, the Digitizer reads the page's text by OCR, which takes a few seconds. Then export each item with View Ptable, paste it into the model file, run ppdigCompare.js to confirm that the model matches the project, and commit the .ppdig with the model. Cite the project in the model's table comments.

Auto-sampling in the Digitizer

The Digitizer can do most of this without an agent session, as described in Auto-sampling a POH. Claude works through tools in the browser much as an agent works through ppdig: it views and zooms the page with pixel rulers, turns it, has a panel's grid measured from the ink, has its curves fitted to the ink from rough points or its straight lines found from the ink, has a table's cells read by the Digitizer's OCR and corrects what it misread, sees the results drawn over the page, and saves each graph or table or gives up on it with the reason. It's never trusted for pixel positions. The tools are in src/utils/agentTools.ts, which runs in node too, so a test can drive them with the same calls Claude makes.

A misread axis is the danger, because the numbers look plausible. So each axis has to agree three ways: the range and increment Claude read, the grid lines counted on the page, and where Claude said the chart is. A disagreement stops the graph, which comes back with its page and no samples, for a person to set up. Lines that could be chosen another way, uneven spacing, and Claude's own doubts flag the graph for attention.

A table's cells come from the page, not from where Claude says they are. Its separators are moved onto the printed rules found in the ink, or into the middle of the gap between words where there's no rule, so a separator Claude places by eye can't cut through a number. The page's OCR misses some numbers, such as one alone in a large ruled box, so each cell it leaves empty is read again on its own, enlarged and clear of its rules; only words with a digit are kept, and not a piece of a word the page's OCR already read. Claude says which columns are inputs and which is the result. An input can be read from a column, given once, given for each group of columns that repeat the same results, such as a group for each altitude, or read from a column of each group, as in a table printed in two halves side by side; so a table is saved once for each kind of result, however it's laid out. Before it's saved, every value must be a number, no two rows may have the same inputs, and Claude has to check on the page any column that doesn't run smoothly. Each correction Claude makes is listed for the reviewer with what the OCR read.

Measure this before relying on it for a new POH or a new model of Claude. Auto-sample a POH that has a checked project, save the result, then score it:

npm --prefix tools/digitizer run -s ppdig -- score built.ppdig digitized/C310/1969_310p.ppdig

It reports the graphs found, whether their axes agree, and the worst difference in each graph's output.

The ppdig command

Command Purpose
page <pdf> <page> -o <png> [--rotate <deg>] Render a 1-based PDF page as the project embeds it, at 300 DPI, turned clockwise by a quarter-turn multiple as Edit Page does.
build <spec.json> -o <ppdig> Build a new project from a spec.
regrid <ppdig> [--name <item>] [--tol <px>] Fit graphs' grids to their images with as few anchors as that takes, keeping anchors placed by hand.
auto <ppdig> [--name <graph>] [--tol <px>] [--no-regrid] [-o <ppdig>] Run regrid, resample and check on graphs. See Fit the curves. Exits with status 1 if any graph needs attention.
add <ppdig> <spec.json> Add a spec's items to a project, replacing items with the same name. The spec's pdf is optional; the embedded PDF is used.
resample <ppdig> [--name <graph>] [--tol <px>] [--ends <ends>] [-o <ppdig>] Re-place spline curves' samples from the image's ink, on grid lines, as few as fit. --tol is the limit on the distance from the ink, 1.4 px by default. See Fit the curves.
score <built.ppdig> <checked.ppdig> Compare a project that was auto-sampled with one a person has checked of the same POH, graph by graph and table cell by cell. See Auto-sampling in the Digitizer.
seed <ppdig> --model <dump.json> [--name <item>] [--replace] Fill graphs' samples and tables' rows from a model's Ptables at their data paths, through the grids as they are. See Re-verifying a model's data. Exits with status 1 if any item isn't seeded.
check <ppdig> Report errors and warnings. Exits with status 1 on errors.
export <ppdig> [--name <item> | --path <data path>] [--json] With --path, print the combined Ptable. Warns of any unreviewed items. Otherwise print each item's Ptable source as View Ptable shows it, or with --json, its exported rows in POH units.
render <ppdig> --name <graph> -o <png> [--scale <n>] Draw a graph's grid and samples over its page.
compare-pages <pdfA> <pageA> <pdfB> <pageB> -o <png> [--rotate-a <deg>] [--rotate-b <deg>] [--frame-a <x,y,w,h>] [--frame-b <x,y,w,h>] [--area <x,y,w,h>] [--tol <px>] [--shift <px>] Overlay a page of one POH on a page of another and list the ink only one has. See Comparing two POHs.
model <ppdig> [--ref <App>/<model>] [--name <data>] [-o <js>] Lay out the project's Ptables as a model data file, checked against a reference model. See Making a model file.

The command is tools/digitizer/scripts/ppdig.ts, and its code is in tools/digitizer/src/utils/ppdig.ts. It uses the Digitizer's own grid and export code, so a project built with it loads and exports exactly as one made in the Digitizer.

The .ppdig format

A .ppdig file is JSON: {"version": 3, "timestamp": <ms>, "project": {...}}. The types in tools/digitizer/src/types/project.ts are authoritative. In brief:

Every position, whether a sample, a grid point or a table region, is a pixel position in that item's image, in canvas coordinates measured from the top-left corner: the center of the top-left pixel is (0.5, 0.5).