# Group

What was aimed at a target (the dial and any hold), where the group landed, how big it was, how many shots made it, and the mark for a group shot in calm air.

## Generated table

**Record:** observation · **Covers:** `dialed`, `hold`, `group`, `calm`, `entered`

| Field | Type | Required | Limits in the schema | Solver reads | In a shared log |
|---|---|---|---|---|---|
| `calm` | boolean | no | - | no | as stored |
| `dialed.assumed` | `'true'` | no | - | no | as stored |
| `dialed.elev_mil` | number | yes | - | no | as stored |
| `dialed.wind_mil` | number | yes | - | no | as stored |
| `entered.center_right` | object | no | - | no | removed for every reader |
| `entered.center_right.unit` | `'moa'` or `'in'` | yes, within | - | no | removed for every reader |
| `entered.center_right.value` | number | yes, within | - | no | removed for every reader |
| `entered.center_up` | object | no | - | no | removed for every reader |
| `entered.center_up.unit` | `'moa'` or `'in'` | yes, within | - | no | removed for every reader |
| `entered.center_up.value` | number | yes, within | - | no | removed for every reader |
| `entered.dialed_elev` | object | no | - | no | removed for every reader |
| `entered.dialed_elev.unit` | `'moa'` | yes, within | - | no | removed for every reader |
| `entered.dialed_elev.value` | number | yes, within | - | no | removed for every reader |
| `entered.dialed_wind` | object | no | - | no | removed for every reader |
| `entered.dialed_wind.unit` | `'moa'` | yes, within | - | no | removed for every reader |
| `entered.dialed_wind.value` | number | yes, within | - | no | removed for every reader |
| `entered.hold_elev` | object | no | - | no | removed for every reader |
| `entered.hold_elev.unit` | `'moa'` | yes, within | - | no | removed for every reader |
| `entered.hold_elev.value` | number | yes, within | - | no | removed for every reader |
| `entered.hold_wind` | object | no | - | no | removed for every reader |
| `entered.hold_wind.unit` | `'moa'` | yes, within | - | no | removed for every reader |
| `entered.hold_wind.value` | number | yes, within | - | no | removed for every reader |
| `entered.size` | object | no | - | no | removed for every reader |
| `entered.size.unit` | `'moa'` or `'in'` | yes, within | - | no | removed for every reader |
| `entered.size.value` | number | yes, within | - | no | removed for every reader |
| `group.center_right_mil` | number | yes | - | no | as stored |
| `group.center_up_mil` | number | yes | - | no | as stored |
| `group.n_shots` | number | yes | - | no | as stored |
| `group.size_mil` | number or null | yes | - | no | as stored |
| `hold.elev_mil` | number | yes, within | - | no | as stored |
| `hold.wind_mil` | number | yes, within | - | no | as stored |

## What it holds

These fields of a logged group say what was aimed and where the shots landed. `dialed` is the setting on the turrets. `hold` is anything held on the reticle on top of it. `group` is where the centre of the group landed on the target, how big it was and how many shots made it. `calm` is the shooter's mark that the air was still. All of these values are in mil. The dial and the group are always there. An absent `hold` means nothing was held, and an absent `calm` means the group is not marked. `entered` keeps the unit and number of a value typed in MOA or inches, only to show it back.

## What to know first

**Up and right are plus.** `dialed.wind_mil` plus is a dial to the right, `hold` plus is up or right, and `group.center_up_mil` and `group.center_right_mil` plus mean the group landed above or to the right of the aim point. The centre is where the shots hit, not the correction needed.

**The correction that would have centred the group is the aim minus the centre.** The aim is the dial (times the scope's tracking factor) plus the hold. A dial of 10.0 on a scope that tracks 0.98, a hold of 0.3 up and a group 0.5 high needed 9.6 mil.

**The dial is the turret reading, in mil, not the true angle.** The log is in mil whatever `scope.units` says. The fitter multiplies the dial by the tracking factor of the version it was shot with, not the hold. Windage has its own factor; the Workbench uses the newest version's.

**`group.n_shots` is the group's own count.** It is not the chronograph count, but a stored 0 makes the fitter use the chronograph count. It weights the group, and a fit starts a group of fewer than 3 shots as skipped. A blank count stores 0.

**`calm` changes no number.** It is a mark. It is not set for a typed wind of 0, and a marked group keeps whatever wind was typed.

## The fields

**`dialed.elev_mil`** and **`dialed.wind_mil`** What the turrets showed when the group was shot, in mil. Both required. Elevation plus is up; windage plus is a dial to the right. This is the number the app's Calculator shows (the come-up already divided by the scope's tracking factors, see [scope](../profile/scope.md)), so it is not the true angle if the scope does not track true. Examples: 9.8 and -0.5.

**`dialed.assumed`** True when the dial is the app's solution and nobody typed it: the group form follows the range with the solution until a dial box is typed in (since 2026-10-19). Optional; only ever true. The stored dial is used like any other; the mark only says where it came from. An edit keeps it only while both dial numbers are unchanged. Example: true.

**`hold.elev_mil`** and **`hold.wind_mil`** What was held with the reticle on top of the dial, in mil; up and right are plus, like the dial. Optional as a whole: absent means no hold. When present, both members are present: a hold typed on one side only stores 0 on the other, and a typed 0 is stored as a hold of 0, which the fitter and the Workbench treat like no hold. The aim of the shot is the dial plus the hold. Example: 0.3 and 0.

**`group.center_up_mil`** and **`group.center_right_mil`** Where the centre of the group landed, in mil, relative to the aim point (the spot on the target the shooter aimed at, hold included): up plus, right plus. Both required. Read from the target by the shooter, or worked out from the holes marked on a photo of the target ([target marks](marks.md)). The app's form starts both at 0, so a group saved without touching them reads as dead on. Examples: 0.5 (half a mil high) and -0.2 (a fifth of a mil left).

**`group.size_mil`** How big the group was, in mil. The key is always there but the value may be null, meaning "not measured". Typed, the app does not say how the size is measured (spread of the holes or their width); marked on a photo, it is the extreme spread, centre to centre, of the kept holes. The fitter takes it as the width of the group. A blank box in the app and a voice log with no size store null. Since 2026-10-13 the app refuses a box holding text that is not a number, and a negative size. Example: 0.4.

**`group.n_shots`** The number of shots in the group, which can differ from the number of chronographed shots (the chronograph can miss a shot that still hit). Required; the app and the connector accept any number the limits below allow. A voice log with no count stores 0; a marked group stores the holes kept (flyers left out). A stored 0 is read by the fitter as the velocity count (`mv.n_shots`), or 1 if that is 0 too. Example: 5.

**`entered`** One entry per field typed in another unit: `entered.dialed_elev`, `entered.dialed_wind`, `entered.hold_elev`, `entered.hold_wind`, `entered.center_up`, `entered.center_right`, `entered.size`, each with a unit and a value (`entered.dialed_elev.unit`, `entered.dialed_elev.value`, `entered.dialed_wind.unit`, `entered.dialed_wind.value`, `entered.hold_elev.unit`, `entered.hold_elev.value`, `entered.hold_wind.unit`, `entered.hold_wind.value`, `entered.center_up.unit`, `entered.center_up.value`, `entered.center_right.unit`, `entered.center_right.value`, `entered.size.unit`, `entered.size.value`). For a field typed in a unit other than mil: the unit (`moa`, or `in` for the centre and the size only) and the number as typed. Optional as a whole and per field; a field typed in mil has no entry, and a group typed all in mil has no `entered`. MOA is true MOA: 1 MOA is 0.290888 mil. Inches are turned into mil with the group's range: inches divided by (range in yards times 36), times 1000, so 7.2 in at 1,000 yd is 0.2 mil. Nothing computes from `entered`: the fitter, the predictions and every export read the mil fields above. The app shows a saved group in these units only while the typed number still stands for the stored mil; otherwise it shows the stored mil in the scope's unit. Example: `{ "dialed_elev": { "unit": "moa", "value": 25.25 } }` beside `dialed.elev_mil` 7.3449.

**`calm`** True when the shooter ticked "Shot in calm air" (the app) or said the air was calm (the connector, `calm`). Absent otherwise: the app and the connector never store false, although the schema allows it, and every reader treats false like absent. Example: true.

**Limits, by writer.** The profile limits table does not apply here: a logged group is not checked by the profile save or by the profile conversion, and the probe that tests that table does not run on log pages. These are the limits found by running each writer, with the values in mil unless stated.

- App, the group form on the Session screen: every number must parse as 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. There is no range check on any of them. The hold boxes may be left blank. The size box may be left blank or hold anything: it never refuses.
- Connector, the input of `log_group`, in mil after `unit` is applied (an amount said in MOA is converted first): `dialed_elev_mil` -50 to 300, `dialed_wind_mil` -50 to 50, `hold_elev_mil` and `hold_wind_mil` -50 to 50, `center_up_mil` and `center_right_mil` -20 to 20, `size_mil` 0 to 20, `n_shots` a whole number from 0 to 100. Each end is included; a value just outside is refused and nothing is logged.
- Server and sync: the record schema asks only that each is a number (or null for the size) and refuses anything else, so a record from a modified client can hold any number but not text.
- Fitter: a group whose dial, group, weather, velocity or wind object is missing is left out with the reason "the logged record is incomplete". Nothing else about the values is checked: text that is not a number in a stored number (possible only from a restored backup file) reaches the fit as not-a-number.

## Who writes it

**The app.** The group form on the Session screen has every field: "Dialed elev", "Dialed wind (+R)", "Hold elev (+ up)", "Hold wind (+R)", "Group center up", "Group center right", "Group size", "Shots in group" and the tick "Shot in calm air". Each of the first seven has its unit in its label: the profile's scope unit at first (MOA on an MOA scope), and a tap on the label steps the dial and the hold between mil and MOA; the centre and the size have a switch beside them instead (mil, MOA, inches), and a tap goes straight to that unit (since 2026-10-19). Either converts what is typed. The choice is kept per profile in the account's display preferences. A field in inches needs the range: without one the form says "enter the range first" and refuses the group. "Add group" saves it: a field in mil is stored as typed, as before; any other is converted to mil and its unit and number go into `entered`. "Log this shot" on the Calculator opens that form with the shot copied in: the range, air, angles, wind and the dial (the Calculator's own come-up, to two decimals). The hold, the centre, the size, the count and the calm tick are left for the shooter. "Dial this" on the form refills the dial from the calculated one. Since 2026-10-19 the form leads with "Your group" (range, the centre, the size and the shots in the group) and folds everything else under "Conditions (filled in)" with a one-line summary; "Record a group" beside "Log this shot" opens it with the cursor in the range. The dial follows the calculated one as the range or the air changes, marked `dialed.assumed`, until a dial box is typed in or the group has shots. Since 2026-10-13 the Session screen can also edit a saved group, delete it, and delete its photo (see "Changing it").

**The connector.** `log_group` adds a group by voice to the active log, or makes a session (or uses the one named; the rule is on the [session](session.md) page). It reads the dial, hold, centre and size in its `unit` input, `mil` or `moa` (not given: the scope's unit; since 2026-10-15), stores them in mil and never writes `entered`. It takes `dialed_elev_mil`, `dialed_wind_mil`, `hold_elev_mil`, `hold_wind_mil`, `center_up_mil`, `center_right_mil`, `size_mil`, `n_shots` and `calm`, builds the record with the same function as the app, and pushes it. It has no tool to edit or delete a group. Its other inputs (velocity, air, angles) are described on the [observation](observation.md) and [muzzle velocity](muzzle-velocity.md) pages.

**Other paths.** A backup file restored in the app writes each group as the file held it. Sync copies a group between a person's own devices unchanged. A calibration suggestion never carries a group's fields: it names groups by id, and gives the range and the residual of each held-out group. Neither the fitter nor the server sets any of these fields.

## Who reads it

The solver does not read these fields: they are what was shot, not what a solve needs. The fitter solves the group's range and air and compares with them.

**The fitter.** `toCalGroups` turns each group into one row for a fit. It leaves out deleted groups, groups shot with a version that has a different bullet, twist, sight height or zero (see "Changing it"), and groups shot before the latest turret move recorded by the zero step ([zero check](zero-check.md)). The row holds the dial, the group's tracking factor, the shot count, the size and the centre with the hold taken off it. The come-up the group needed, in true mil, is the dial times the factor, plus the hold, minus the centre: a hold of `h` up and a centre `c` high give `dial × factor + h − c`. This was run for this page with a made-up group: dial 10, factor 0.98, centre 0.5 high gave 9.30; adding a hold of 0.3 up gave 9.60. The factor is the group's own version's, so a tracking factor edited later does not change an old group's need; a fit that uses groups from versions with different factors warns of it.

Since 2026-10-20 the app's form saves a group whose "Shots in group" is left blank with no chronograph shots typed, as 0; its row says "Shot count not given" and a fit starts it as skipped with the reason "shot count not given". The count and the size set how much a group is trusted. A group's uncertainty adds a reading floor of 0.03 mil, half its size divided by the square root of its shot count and the spread of its velocity. A group with no size (null) is given the median of the measured sizes among the profile's groups the fit can use (since 2026-10-24), or 0.1 mil when none has a size, so a blank size never makes a group count for more than a measured one. A group logged with no measured velocity (the mark `mv.not_measured`, on the [muzzle velocity](muzzle-velocity.md) page) starts as "skip" whatever its count, with the reason shown beside it; set to "use" by hand, it is fitted at the profile's velocity. On the Calibrate screen, MV true starts such a group as "use" (since 2026-10-20, as it works the velocity out from the drop; the row says so) and switching to another fit puts it back to "skip" unless the shooter changed it; a group MV true leaves out under Mach 1.2 says "too slow for this fix". The range log's own correction is the exception too: under the group table the app says how far the groups land from the profile's come-ups now (the log's groups when it has two or more, else all the profile's; at least two ranges; "on" within 0.1 mil at every range), and its "Work out the fix" runs MV true over every group with 3 shots or more, groups with no measured velocity included, since MV true works the velocity out from the drops. "Use it" saves that fit only on the version it was worked out from. A group with no velocity that was logged by voice before 2026-10-10 carries no mark and starts as "use" when it has 3 shots or more. A group of fewer than 3 shots starts as "skip", not "use" (on the Calibrate screen and in `calibrate_profile`, where for velocity against temperature the count is the velocity string's `mv.n_shots`; the Workbench starts from the stored calibration's lists when there is one). Guided calibration also flags a group as weak for fewer than 3 shots or a size above 1 mil.

The elevation fits do not read the windage dial, the windage centre or `calm`. The Spin trim fit does, the same fit in the Workbench, in the Calibration section and, by voice, in `calibrate_profile`; so does the rest of the Workbench, for groups marked calm or typed with 2 mph of wind or less (for a group with wind by stretch, the largest speed of its best-guess stretches counts as its wind, and its prediction flies that best-guess wind; see [target](target.md)). It works out the windage that would have centred the group as the windage dial times the windage factor of the version the group was shot with (since 2026-10-24; before, the profile's newest), plus the windage hold, minus the centre-right (right plus), and compares it with each model's prediction made with the typed wind. A marked group is trusted for a wind of 0.5 mph in the Spin trim, and the error bar of a group with stretches is taken from 1 mph of crosswind on a path with every stretch calm. This was run for this page: a windage dial of 0.4, factor 1.1, hold 0.1 and centre 0.2 left gave 0.74.

**The app.** The Session screen lists each group with its dial (a star marks a hold), centre-up, predictions and velocity, and a tooltip gives the hold and how the aim compared with the suggested dial. The dial and the centre are shown in the unit they were typed in (`entered`), else in the scope's unit of the group's profile; the two predictions follow the dial's unit; and a line under the row names every unit that is not mil. Mil and MOA are shown to two decimals, inches to one. Since 2026-10-16 the Calibrate screen and the Workbench also show these angles in the scope's unit; the CSV export keeps its mil columns and, on an MOA scope's rows, adds `angle_unit` and an MOA copy of each angle column; the JSON backup stays in mil. The Calibrate screen, the guide and the Workbench read the groups through the fitter's step above. The export to CSV writes every field on this page; its correction columns use the aim, the dial plus the hold, minus the centre. They are not multiplied by the scope's tracking factor, so they differ from the fitter's come-up when the scope does not track true.

**The connector.** `get_session` returns the groups of a session as stored; since 2026-10-16, for a group whose profile has an MOA scope, each angle of the dial, hold, centre, size and predictions also comes in MOA beside the mil field, and the reply names each profile's scope unit. `calibrate_profile` fits from them: it lists the groups it left out with a reason and warns when used groups were logged at a different tracking factor, and a save is refused if the groups changed since the preview. `log_group` answers with the centre in words ("0.30 high, 0.10 left"), in the unit it was given, with "MOA" after each amount in MOA.

**The server.** The admins' pooled export lists each group's dial, centre, size, count and hold, for shares that allow any use. It has no column for `calm`.

## Why it is stored

The dial and the hold are the aim, and the centre is the miss, so together they say what come-up the shot really needed: the data a drag fit is made from. The hold is kept apart from the dial because a hold is read off the reticle and is not scaled by the scope's tracking factor. The count and the size say how much to trust the centre: a three-shot group read to a tenth of a mil is worth far less than a ten-shot one. The windage fields are stored with the elevation ones because the windage of a calm-air group is the one place spin drift shows, and `calm` says on purpose which groups to read that way.

## Changing it

A logged group is not versioned like a profile. It is one record, changed in place. Since 2026-10-13 the app can edit a saved group: on the Session screen the button on a group's row offers Edit and Delete, and Edit opens the group in the form, each unit field in the unit it was typed in, else (since 2026-10-16) in the scope's unit; a field left as it opened is saved back as its stored mil, unchanged by the rounding of what was shown. "Save changes" writes the same record (the same id, creation time and profile version, the suggested dial kept, and the photo kept unless a new one was taken while editing) with the form's values, works the two predictions and the density altitude out again (and, for a group with [target marks](marks.md), its centre, size and count from the marks at the saved range), and keeps each mark (`target.range_source`, an air source of `rangefinder`, `mv.not_measured`, `mv.source` with `mv.string` when it has one, `target.assumed`, `environment.assumed`) only while the values it speaks for are unchanged (the chronograph mark: while the shot list is the same, shot for shot). A saved calibration is not touched by an edit. One group is edited at a time, the record is read again when the save is pressed (a group deleted on another device meanwhile is not brought back), and a voice group's notes keep their list of defaults even after a value is typed: the mark is the one to trust. Before that date the shooter deleted a group and logged it again. A delete is a soft delete: the record stays, marked deleted, and no reader uses it after that (the shared view leaves it out, the fitter skips it). Deleting a group's photo also saves the record again. A hand edit on this page has no default effect on a calibration (`on_edit: none`).

**A saved calibration is not touched.** A calibration keeps the ids of the groups it used (`calibration.used`) and the groups it held out. Deleting a group later leaves those lists as they were. The Workbench reads them: it opens with those groups as used and held out and every other group skipped, and it says how many listed groups are no longer in the log. A later fit simply no longer sees the deleted group.

**A save through the connector can go stale.** A preview gives a fingerprint of the profile version and of each usable group's id, last-saved time and role. Deleting a group, deleting a group's photo, or logging a new one between the preview and the save changes it, and the save is refused with a message to preview again.

**Which profile version the group belongs to** is on the [observation](observation.md) page. The fitter uses a group only if its version has the same bullet, twist, sight height and zero as the profile being fitted (see [versions](../profile/versions.md)); a different tracking factor does not exclude it.

## Sharing and privacy

`entered` is never part of a shared log: the table reads "removed for every reader" on every one of its rows (`entered.dialed_elev`, `entered.dialed_elev.unit`, `entered.dialed_elev.value`, `entered.dialed_wind`, `entered.dialed_wind.unit`, `entered.dialed_wind.value`, `entered.hold_elev`, `entered.hold_elev.unit`, `entered.hold_elev.value`, `entered.hold_wind`, `entered.hold_wind.unit`, `entered.hold_wind.value`, `entered.center_up`, `entered.center_up.unit`, `entered.center_up.value`, `entered.center_right`, `entered.center_right.unit`, `entered.center_right.value`, `entered.size`, `entered.size.unit`, `entered.size.value`), and a reader's Session screen shows the stored mil in the sharer's scope unit. Every other row of the table reads "as stored": a reader of a shared log, whether one person, the public or the admins, sees the dial, the mark that the dial was the app's (`dialed.assumed`), the hold, the centre, the size, the count and the calm mark exactly as the owner does, whether or not location and notes are hidden. The "hide location" and "hide notes" switches do not touch these fields. A reader with the profile can work out how far off the shooter's dope was, how well they hold their groups and how steadily they shoot, and can tell which groups the shooter called calm.

The use permission of a share (anything, pooled results only, or helping the sharer only) changes what the admins may do with the log, not what it contains. For "pooled results only" the admin's pooled fit still works from the groups and their fields, but the groups are left out of the admins' file export. For "helping me only" the log does not enter the pool. A calibration suggestion shows a reader no group fields, only the range and the residual of each held-out group.

---

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