# Muzzle velocity

The chronograph numbers of one logged group: mean, SD, ES, shot count, the shots themselves and the ammo temperature, and how a fit uses them.

## Generated table

**Record:** observation · **Covers:** `mv`

| Field | Type | Required | Limits in the schema | Solver reads | In a shared log |
|---|---|---|---|---|---|
| `mv.ammo_temp_F` | number | no | - | no | as stored |
| `mv.es_fps` | number | yes | - | no | as stored |
| `mv.mean_fps` | number | yes | - | no | as stored |
| `mv.n_shots` | number | yes | - | no | as stored |
| `mv.not_measured` | boolean | no | - | no | as stored |
| `mv.sd_fps` | number | yes | - | no | as stored |
| `mv.shots_fps` | array of number | no | - | no | as stored |
| `mv.source` | `'chronograph'` | no | - | no | as stored |
| `mv.spread_not_measured` | `'true'` | no | - | no | as stored |
| `mv.string` | object | no | - | no | removed when notes are hidden |
| `mv.string.at` | string | yes, within | - | no | removed when notes are hidden |
| `mv.string.id` | string | yes, within | - | no | removed for every reader |
| `mv.string.name` | string | yes, within | - | no | removed when notes are hidden |

## What it holds

The `mv` block of a logged group holds the velocity the bullets of that group left the muzzle at: the mean, the standard deviation, the extreme spread and the number of chronographed shots. Optionally it holds each shot and the ammo temperature. It is the group's own velocity, apart from the velocity of the profile. All four summary numbers are always there.

## What to know first

**A fit solves each group at its own mean.** The group's `mv.mean_fps` is the velocity of that solve, not the profile's `ammo.mv_fps`.

**A voice log without a velocity stores the profile's velocity, marked `mv.not_measured`.** SD and ES are 0. A fit starts a marked group as skipped; set to use, it has no velocity doubt. Groups logged by voice before 2026-10-10 have no mark: only their notes say so.

**With a shot list the summary is computed from it.** Mean, SD (n minus 1) and ES are rounded to 0.1 fps; those typed boxes are ignored. "Shots in group" is its own count, taken from the list when blank. Without a list both counts are one box.

**SD and the count set the weight. ES is never used.** A larger SD or fewer shots widens the group's uncertainty.

**`mv.ammo_temp_F` feeds only the temperature fit.** The connector cannot set it.

## The fields

**`mv.mean_fps`** The average velocity of the group, in feet per second. Required. The typed boxes take any number. When a shot list is given, it is the list's average rounded to 0.1 fps. A fit flies the group at this number, plus an offset in the "MV true" method. The mean is what the Session table shows (as "3045.2 plus or minus 5.7"). Example: 3045.2.

**`mv.sd_fps`** The sample standard deviation of the shots, in feet per second (divided by the shots minus 1, as the common chronographs report it). Required. A single shot has SD 0 when computed from a list. A voice log without one stores 0, which reads as "perfectly steady" and adds nothing to the velocity part of the group's uncertainty. Example: 5.7.

**`mv.es_fps`** The extreme spread: the fastest shot minus the slowest, in feet per second. Required. Nothing in a fit reads it. A voice log without one stores 0. Example: 14.

**`mv.n_shots`** The number of chronographed shots, a number (the connector takes a whole number from 0 to 100; the app form takes a whole number of 1 or more since 2026-10-13; the sync schema takes any, 2.5 included). Required. It can differ from the group's own count (`group.n_shots`, on the [group](group.md) page), because a chronograph can miss a shot. Typed without a list, the app form stores the same number in both. A voice log without a count stores 0 in both. The temperature fit uses this count. A fit of the drag uses the group's count first and this one only if the group's is 0. Example: 5.

**`mv.shots_fps`** Each chronographed shot, in feet per second, in the order entered. Optional. Absent when the summary was typed or logged by voice. When present, the summary above was computed from it when the group was saved. The shots are typed or pasted separated by spaces, commas or semicolons, or added one at a time with "Add shot". Nothing in a fit reads the list. Only the CSV export writes it and the shared view shows it. Example: 3041, 3052, 3049, 3038, 3046.

**`mv.ammo_temp_F`** The temperature of the ammunition when the group was shot, in degrees Fahrenheit. Optional, and absent when left blank. The form carries the last group's value into the next one, with the line "From the last group" under the box until it is typed in (since 2026-10-13), and the Calculator's ammo temperature into a group logged from it. Example: 81.

**`mv.not_measured`** True when no velocity was measured for the group, so that `mv.mean_fps` holds the profile's velocity and not a measurement. Optional; absent on every group logged with a measured velocity. Written only as true: by `log_group` when the call gave no `mv_mean_fps`, whatever spread or count it gave, and since 2026-10-19 by the app's group form when it filled "MV mean" in with the profile's velocity and the shooter left it so. A fit starts a marked group as skipped and shows why; the shooter can still set it to use. A group logged by voice before 2026-10-10 has no mark, and nothing is read from its notes to supply one. Example: true.

**`mv.spread_not_measured`** True when the group's SD and ES were not measured: the app's group form filled them in, from the profile's newest chronograph string, or 0 when it has none (since 2026-10-19). Optional; only ever true, and never stored on a group whose shots are listed. A fit weighs the group by its stored SD as by any other, so a filled-in SD counts as the profile's usual spread. An edit keeps the mark only while SD and ES are unchanged. Example: true.

**`mv.source`** The word `chronograph` when the shots in `mv.shots_fps` came from a chronograph the app can read and have not been edited since: read from one of its stored strings, or heard shot by shot while the group form followed the chronograph live. Optional, and absent on every other group: typed shots, a typed summary, a voice log. It says where the numbers came from; nothing computes with it. It exists only together with a shot list.

**`mv.string`** Which string of the chronograph the shots came from. Optional; present only with `mv.source`, and absent when a group that followed the chronograph live took shots from two of its strings (one string can also hold several groups). Three members, all required when the block is there: `mv.string.id` (the string's own id as the device gives it, or one made from its time and shot count when the device gives none; at most 40 characters), `mv.string.name` (the name the string has on the device, cleaned to plain text and cut to 40 characters; it may be empty) and `mv.string.at` (the string's time as the device recorded it, ISO text, at most 30 characters; it may be empty). For a group that followed the chronograph live, the id is the 16-byte id the device sends with each shot, written as 32 hex characters, and the time is when the group's first shot was heard. The id is how the app knows that a stored string was already used by a group of the same session. Example: id `41355c76-112f-4375-b07d-2fe3c866dbaf`, name "Rifle 8", time `2026-10-13T19:02:00.000Z`.

**Limits, by writer.** The limits table of a profile page does not fit here: a logged group is not checked by the profile save, and the probe behind that table does not run on log pages. These limits were found by running each writer.

- App, the group form on the Session screen. Typed summary: mean, SD, ES and the count must each be a plain decimal (a minus sign and a comma or a point are fine; a plus sign or an exponent is not), or the group is refused with the names of the boxes. Since 2026-10-13 the form refuses a mean of 0 or below, a negative SD or ES, and a count that is not a whole number of 1 or more. Nothing else is checked: a mean of 500 or 10000, an SD larger than the mean, an ES below the SD and a single shot with an SD all save. A group stored before that date with a mean of 0 or below has no prediction, and a fit that includes it stops with "group at N yd is out of range for this profile".
- App, shot list: every token must be a number above 0, or the whole box is refused ("Missing or invalid: Shots"; since 2026-10-16 the app names the box by its label, while the connector's message keeps the field name, `shots_fps`). There is no upper bound: 10000 saves. One shot gives SD 0 and ES 0. Once any shot is listed, the typed mean, SD and ES are ignored, even if they hold numbers. Ammo temperature: blank is absent; any number is stored (-20 and 5000 both save); text is refused with "Ammo temp" (`ammo_temp_F` before 2026-10-16).
- App, shots read from a chronograph (since 2026-10-13): a string is used only when it has 1 to 200 shots and every shot is from 100 to 6,000 fps; a string outside that is refused whole. The shots go into the box to one decimal. The mark is written only when the box, at the moment of saving, holds exactly the shots that were read.
- Connector, the input of `log_group`: `mv_mean_fps` 500 to 5000, `mv_sd_fps` 0 to 200, `mv_es_fps` 0 to 500, `n_shots` a whole number from 0 to 100. Each end is included; a value just outside is refused and nothing is logged. SD above the mean, ES below the SD and one shot with an SD are accepted. There are no inputs for the shots or the ammo temperature, and an input with another name is dropped without an error (run for this page: a call with a shot list and a temperature logged a group with neither).
- Server and sync: the record schema asks that the four summary numbers exist and are numbers, and that the shots are numbers. It checks no range and does not check that the summary matches the shot list. `mv.source` must be the word `chronograph`; `mv.string` must have its three members as text within their lengths, and nothing else.
- Fitter: a group with no `mv` block at all is left out with "the logged record is incomplete". Nothing else about the values is checked.

## Who writes it

**The app.** On the Session screen the box "Chronograph, each shot (fps)" takes the shots and shows "Mean, SD, ES, n" as it computes them. Under it are three boxes, "MV mean", "MV SD" and "MV ES", shown only while no shot is listed, and "Ammo temp". "Shots in group" is prefilled with the number of shots, and can be changed. The form keeps the ammo temperature from group to group and clears the shots and the typed numbers. "Log this shot" on the Calculator carries its ammo temperature, but no velocity. Since 2026-10-19 a new group whose shots are not listed is filled in: "MV mean" with the profile's `ammo.mv_fps` (stored marked `mv.not_measured`, so a fit starts the group skipped), and "MV SD" and "MV ES" with the profile's newest chronograph string, or 0 (marked `mv.spread_not_measured`). A mark is stored only while its box still holds what was filled in. Since 2026-10-13 a saved group can be edited on the Session screen, its velocity included; a voice group's `mv.not_measured` mark is dropped when its mean is changed or a shot list is typed. A chronograph file import is not built: the shots are typed, pasted, or read from a chronograph the app can connect to (next paragraph).

**The app, from a chronograph (since 2026-10-13).** While a chronograph is added as a device, the group form shows "From chronograph" under the shots box. One press reads the newest string on the device and puts its shots in the box, with a line that says which string it took (its name, shot count and time), "Choose another" and "Undo". Shots already typed are replaced only after a question. If a group of the same session already used the newest string, nothing is filled and the form says there is no new string. Saving the group then writes `mv.source` and `mv.string`; editing the box first means neither is written. The chronograph is only read: nothing on it is changed.

**The connector.** `log_group` takes `mv_mean_fps`, `mv_sd_fps`, `mv_es_fps` and `n_shots`, and builds the record with the same function as the app. A mean left out becomes the profile's `ammo.mv_fps` as saved, not adjusted for any temperature table. An SD, ES or count left out becomes 0. The group's notes then say "[MV not measured]" and "[SD/ES/n not given]", with no number (since 2026-10-15; before that they listed "MV N fps (profile, not measured)" and "SD/ES/n not given (stored 0)"), and the reply's `assumed` keeps the longer wording with the velocity. A group whose mean was left out is also stored with `mv.not_measured` true, and the tool's spoken line says that no velocity was measured; a mean given, with or without the rest, stores no mark. The Calculator's ammo temperature on the phone is not read. A mean given with no count is stored with a count of 0, and today's measured velocity leaves that group out (decided by the owner, 2026-10-16), so give the count with the velocity.

**The fitter and the server** never write these fields. A backup file restored in the app, and sync between a person's own devices, copy them as they were.

## Who reads it

**The fitter.** `toCalGroups` copies `mv.mean_fps` and `mv.sd_fps` into the group's row, and takes the shot count as the group's count when that is above 0, else `mv.n_shots`, else 1. Every solve of a fit flies the group at its own mean. The uncertainty of a group adds, in quadrature, a reading floor of 0.03 mil, half the size over the square root of the count, and the velocity term: the SD over the square root of the count, times the change of come-up per foot per second at that group's range. Run for this page on a made-up 1,000 yd group (size 0.25 mil, 5 shots, 0.0045 mil of come-up per fps): an SD of 0 gives 0.0634 mil, 3 gives 0.0637, 10 gives 0.0666 and 30 gives 0.0876. With SD 10, a count of 1, 3, 5 and 10 gives 0.1362, 0.0824, 0.0666 and 0.0516. An SD of 4000 gives 8.05 mil, which weighs the group at almost nothing. The sign of the SD does not matter.

**"MV true" and the profile's velocity.** The method flies each used group at its own mean plus one common offset, and the offset is fitted to the drops. What it saves as the profile's `ammo.mv_fps` is the plain average of the used groups' means plus that offset (run for this page: means of 3040, 3050 and 3060 gave 3050 plus an offset of 74.0, saved as 3124.0). So the saved velocity is anchored to the chronographed means, and a chronographed mean is not the profile's velocity until a person or a calibration saves it. If the profile has a velocity-against-temperature table, the same save moves every row of it by the offset (less any offset an earlier "MV true" already added); the group means enter only the single velocity. It uses only groups still above Mach 1.2 at the target.

**The temperature fit.** "MV vs temperature" in the app and in `calibrate_profile` (method `mv_temp`) reads every group that has `mv.ammo_temp_F`, takes the group's mean and `mv.n_shots`, and draws a line weighted by the count (a count of 0 weighs as 1). Groups of fewer than 3 shots, and groups marked `mv.not_measured`, start as skipped. It needs two temperatures at least 10 degrees apart, and it saves a table of two rows into the profile (see the [velocity](../profile/velocity.md) page). It does not go through `toCalGroups`, so it does not check the profile version.

**A group with no measured velocity.** `toCalGroups` carries `mv.not_measured` into the group's row, and the one starting rule of the Calibrate screen, of `calibrate_profile` and of the temperature fit sets a marked group to "skip", with the reason "no measured velocity" shown beside it. The admins' pooled fit, which has no choice per group, leaves marked groups out. A marked group that the shooter sets to "use" is fitted like any other: at the profile's velocity, with no velocity doubt.

**Guided calibration** leaves a group marked `mv.not_measured` out altogether (since 2026-10-13: it is not counted as a measurement, on the Guide card or in the Workbench's "What to shoot next"). It flags a group as weak when its SD is above 15 fps, and uses the median SD of the logged groups (8 fps if none) as the noise it assumes for the next shot it suggests.

**The stored predictions** are solved with the group's mean ([prediction](prediction.md)), without the temperature table.

**Today's measured velocity.** The Calculator's offer, its velocity line and the solve it switches on, the profile's Today section, and the connector's `solve`, `range_card` and `get_context` work out one velocity from the groups of the active log logged with the profile. They read `mv.shots_fps`, else `mv.mean_fps` with `mv.n_shots`, and leave out a group marked `mv.not_measured` and a mean with a count of 0. Which groups count, the speed bounds and the `all` and `latest` modes are on the [session](session.md) page, under `measured_mv`.

**The app.** The Session table shows the mean and SD of each group. The form's calculated dial uses the typed ammo temperature with the profile's table. The CSV export writes `mean`, `SD`, `ES`, the count, the shots (joined by spaces) and the temperature.

**The connector.** `get_session` returns the block as stored, except that a chronograph string's name comes under `stored_text` (since 2026-10-15). `calibrate_profile` previews and saves the two fits above.

**The server.** The admins' pooled export lists the mean, SD, ES and chronograph count of each group, for shares that allow any use; it has no column for the shots or the temperature. The Community pages work out a rough speed at the target from the group's mean, or from the profile's velocity when the mean is 0.

## Why it is stored

A bullet that left slow looks like a drag error, and the two grow together with range. So each group keeps its own velocity, and the fit uses it in place of the profile's. The SD and the count say how much the velocity itself is in doubt. The ES and the shot list are kept as evidence: they let a reader see a string (a flyer shows in the ES) and keep the data traceable, but no fit reads them. The ammo temperature is kept because velocity moves with it, and a table of velocity against temperature can only be fitted from groups that say how warm the ammunition was. The project's traceability rule asks for the four summary numbers on every data row, with the shots and the temperature where there are any. The same rule sets, for a row generated by another calculator, the profile velocity, SD 0, ES 0 and count 0, which is what a voice log without a velocity stores. The mark `mv.not_measured` says the same in the record itself, where a fit can read it.

## Changing it

A logged group is not versioned: it is one record. Since 2026-10-13 the Session screen can edit a saved group, its velocity included (what an edit keeps and works out again is on the [group](group.md) page). An edit keeps `mv.source` and `mv.string` only while the shots are unchanged, and never adds them. A deleted group is left out of every fit and every shared view. Changing a velocity has no default effect on a calibration (`on_edit: none`). A calibration that used the group keeps the group's id in its list. Changing the profile's velocity later does not change a logged group, since the group keeps its own. The fitter uses a group when its profile version has the same bullet, twist, sight height and zero as the profile being fitted; the velocity is not part of that test (see [versions](../profile/versions.md)).

## Sharing and privacy

Every row of the table but those of `mv.string` reads "as stored": a reader of a shared log, whether one person, the public or the admins, sees the mean, SD, ES and count, every single shot, the ammo temperature, the mark of a velocity that was not measured and the mark of a spread that was not measured (`mv.spread_not_measured`) exactly as the owner does, so a reader's fit starts a marked group as skipped too. The location and notes switches do not touch them. A reader can work out how consistent the load is, how a string ran against the temperature of the day, and, with the profile, how the group's velocity compares with the profile's.

**The chronograph string.** `mv.source` is shown wherever the shots are. The string's id never leaves the owner's account: it is removed for every reader. The string's name is text the shooter typed on the device, so it follows the notes switch: with notes shown a reader sees the name and the time, with notes hidden the whole `mv.string` block is removed.

The use permission of a share changes what the admins may do with the log, not what it contains. The admins' file export carries the mean, SD, ES and count only for shares that allow any use, and never the shots or the temperature.

---

[All reference pages](../index.md)
