How we calculate
Every number the app shows you is produced by a function you can read, with a test beside it. This page puts them all in the open: the formula in words, the function exactly as it is written in the code, and a worked example you can check on paper. If something does not match what your federation gives you, we want to know — and until then, the last section tells you why the two numbers can differ without either being wrong.
The rings in the examples are from our demonstration set (RO 2099 9xxxxx).
They are not real birds.
Numbers in this text are written the English way, with a point. So is the
export file — always, in both languages. The file is comma-separated, so a
decimal comma would split a number across two columns; packages/core/src/csv.ts
writes every decimal with toFixed(3), which produces a point regardless of the
reader's language. The velocity and coefficient examples below come out of the
export like this:
Race Name,Release Date,Distance (km),Bird Ring,Bird Name,Position,Field Size,Velocity (m/min),Ace Points,Clock Time,Designated
Oradea,2099-05-18,342.000,RO 2099 900021,,37,397,1030.120,93.199,11:47:00,
Velocity
Distance divided by flight time, in metres per minute. The distance is from the release point to your loft, not to your town — which is why every loft has its own distance for the same race.
// packages/core/src/race.ts
export function computeVelocity(distanceKm: number, releaseAt: string, arrivalAt: string): number {
const flightMs = new Date(arrivalAt).getTime() - new Date(releaseAt).getTime();
if (flightMs <= 0) return 0;
const flightMinutes = flightMs / 60_000;
const distanceM = distanceKm * 1000;
return Math.round((distanceM / flightMinutes) * 1000) / 1000;
}
Example. RO 2099 900021, 342 km, released 06:15, home 11:47. The flight is
5 hours and 32 minutes, which is 332 minutes. 342,000 m ÷ 332 min = 1030.120
m/min.
We round to three decimals and no sooner: in a close race, the difference between two birds is in the second decimal.
Tested in packages/core/src/race.test.ts.
The coefficient
Position divided by the number of birds, multiplied by 1000. Lower is better. It is the sport's common currency: it tells you how well the bird flew relative to how many flew with it, so you can compare a race of 400 birds with one of 4000.
// packages/core/src/race.ts
export function computeAcePoints(position: number, fieldSize: number): number {
if (fieldSize <= 0 || position <= 0) return 0;
return Math.round((position / fieldSize) * 1000 * 1000) / 1000;
}
Example. Position 37 out of 397 birds: 37 ÷ 397 × 1000 = 93.199.
Tested in packages/core/src/analytics/coefficient.test.ts. Read the last
section of this page too — the coefficient is the one number here that we
often do not calculate.
COI — the coefficient of inbreeding
How closely related a bird's parents are, by Wright's formula. Over every common ancestor, the contribution of each path through the tree is summed. The function writes its own formula out:
// packages/core/src/pedigree.ts
/**
* Compute Wright's coefficient of inbreeding for the bird identified by
* `rootId`. The formula sums over every common ancestor A:
*
* F = Σ [ (1/2)^(n₁ + n₂ + 1) × (1 + F_A) ]
*
* where n₁ and n₂ are the path lengths from sire and dam to A, and F_A
* is the inbreeding coefficient of A itself.
*/
A common ancestor counts once for every time it appears, on every path. If a bird does not have both parents recorded the result is 0 — not because it is not inbred, but because there is nothing to calculate from; an incomplete pedigree shows as one in the app, rather than as a reassuring zero.
Example. RO 2099 900015 has the same ancestor on both lines, two
generations back on the sire's side and two on the dam's, and that ancestor is
not itself inbred: (1/2)^(2+2+1) × (1+0) = (1/2)^5 = 3.125%.
Tested in packages/core/src/pedigree.test.ts, including trees with cycles and
with missing parents.
The form curve
A bird's results from one season, in the order they happened. Not an average and not a score: a line, so you can see whether the bird is climbing or falling across the season.
The series carries two possible axes — the coefficient, and the percentage position in the field — because they are different questions: the first is what the federation said, the second is where the bird finished in the field we recorded.
Remember that lower is better on both. Improving form shows as a line going down.
packages/core/src/analytics/form.ts, tested in
packages/core/src/analytics/form.test.ts.
The decile
Results that finish in the first tenth of their field: position divided by the number of birds, less than or equal to 0.10.
Defined on position and field, not on the published coefficient — the two can differ anywhere the federation's denominator is not the number printed beside it, and if we used the coefficient the answer would depend on which importer wrote the row.
Example. Position 37 out of 397: 37 ÷ 397 = 0.0932. That is below 0.10, so it is in the first decile.
One small care from the code, so you can see the level it is worked at: the comparison is made on the raw position, not on the rounded percentage. In a field of 9999 birds, position 1000 is 0.10001 — which rounded to four decimals gives 0.1000 and would slip into the decile without deserving it.
Birds that did not arrive do not enter here at all: they are neither successes nor measurable failures. A bird that does not come home is not a poor position — it is something else, and it is counted under consistency.
packages/core/src/analytics/consistency.ts, tested in
packages/core/src/analytics/consistency.test.ts.
Consistency
Out of how many recorded races the bird was placed. The denominator is the placed results plus the non-arrivals you recorded yourself.
It is not the official prize percentage and we do not offer it as one. The
official figure divides by the birds basketed, and basketing sheets are data we
do not have. What we can honestly calculate is placed-over-recorded-entries, and
the type in the code carries that label with it (basis: 'recorded_entries'),
so a screen cannot lose it by inattention.
Example. 9 placed results and 3 non-arrivals, recorded by you: 9 ÷ 12 = 75%.
And the uncomfortable part, because it is yours and not ours: if all your results come from an import of classification sheets, this figure lies. A classification sheet lists the birds that placed and says nothing about the ones that did not come home — so every imported row is a placing, and the rate comes out at 100%. That is not a measurement of your loft, it is a measurement of the road the data came in by. The code counts non-arrivals separately precisely so that the screen can stay quiet instead of showing a perfect score.
Published numbers and derived numbers
This is the part you came to read, if you came to check.
When the federation has published a coefficient, we show theirs. We do not recalculate it. When they have published none, we calculate it ourselves from position and field — and we mark it as such. The mark is not a footnote: it is a field in the data type, beside the value.
// packages/core/src/analytics/coefficient.ts
export const coefficientSchema = z
.object({
value: z.number(),
/** True when this engine computed it; false when the source published it. */
derived: z.boolean(),
})
.readonly();
And when we average several results together, the result says how many of each
kind it is made of — storedCount and derivedCount — because an average over
numbers from different sources is a number you have a right to know the
composition of.
Why it matters. Everybody divides position by field size. Not everybody divides by the same field:
- UNCR (Romania) — position × 1000 divided by the total number of birds, to three decimals, lowest score wins. Transcribed from Regulamentul Național Columbofil, the Romanian national racing rulebook, 2026–2029 edition.
- UCPR (Romania) — position × 1000 divided by the birds entered (basketed), which is not the same number. Transcribed from UCPR's own rulebook; our documentation keeps the formula in the original, without naming an edition.
- KBDB (Belgium) — position divided by those placed (geklasseerden), summed across races. Transcribed from the KBDB rulebook, in Dutch, with no edition named.
- RPRA (United Kingdom) — position divided by the total, multiplied by 100, not by 1000. Transcribed from the RPRA rulebook, with no edition named.
And for the FCI and Olympiad championships, the denominator is the number of birds basketed, capped at 5000.
The formulas above are transcribed from the federations' rulebooks into our research documentation, with the original text quoted beside each — not from memory. Where a rulebook names its edition, we wrote it down; where it does not name one, we said so instead of inventing one.
And so it is clear what a rule change can break: nothing in your data. Because we never recalculate a published coefficient, if a federation changes its denominator tomorrow, the numbers in your account stay exactly the ones the federation gave you — the change cannot touch a single fancier's row. What it can do is make this page stale, so that the list above describes a rule no longer in force. That is why it is written with its provenance, and why we ask for your help: if you see one that is wrong or out of date, write to contact@thatpigeon.app. On a page called "how we calculate", a wrong formula is the one mistake with no excuse.
So: if we recalculated your federation's published coefficient with a formula of our own choosing, we would be quietly answering a different question from the one you asked. That is why we do not recalculate it. And it is why, when we compare results from different federations, we say so — a raw coefficient does not compare across bodies without a label.