Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/adr/0027-equipment-measurements-are-validated-floats.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Equipment measurements are validated floats, and a rejected entry never reports success

Every numeric field on a `Telescope` or an `Eyepiece` is a **float**, and every
value entering one is range-checked against a single table of limits in
`equipment.py` before it reaches config. A value that fails re-renders the form
with the reason; it never renders the success banner.

## Context

Two defects with one root. `equipment_add_eyepiece` / `equipment_add_instrument`
parsed with bare `float()` / `int()` inside `except Exception: logger.error(...)`
and then fell through to the success template regardless
([#569](https://github.com/brickbots/PiFinder/issues/569)):

```
POST /equipment/add_eyepiece/-1 focal_length_mm=7,5
-> HTTP 200 + "Eyepiece added, restart your PiFinder to use"
-> eyepiece count unchanged. Nothing was saved.
```

And the types themselves rejected real gear. `aperture_mm` and
`focal_length_mm` were `int` on `Telescope`, so an 11" SCT (279.4mm) could not
be entered — and an older release that wrote the string `"279.5"` into config
made the PiFinder **unbootable**: `Equipment.from_dict` raised
`invalid literal for int()` inside `main()` before the UI came up
([#291](https://github.com/brickbots/PiFinder/issues/291)). Meanwhile
`Eyepiece.focal_length_mm` *was* a float, so the same field name carried two
types.

A decimal comma is the easiest way to trigger the parse failure — PiFinder ships
de/es/fr/zh, and a comma-locale keyboard offers a comma — but any unreadable
value did it, including the blank instrument name from #569's description.

## Decision

1. **Measurements are floats.** `aperture_mm`, `focal_length_mm` and `afov`
join `obstruction_perc` and `field_stop`. Optics are fractional: 279.4mm of
aperture, 1280.2mm behind a reducer, a 3.5mm Nagler. Whole millimetres
still *display* as whole millimetres — `format_measurement()` drops the
`.0`, so the tables read "1000", not "1000.0". Loading only gets more
tolerant: an int, a float or a numeric string all decode.

2. **One table of limits, two enforcement points.** `TELESCOPE_LIMITS` and
`EYEPIECE_LIMITS` live in `equipment.py`. The edit forms render them into
their client-side check; the API re-checks them in `telescope_from_form` /
`eyepiece_from_form`. The client's job is fast feedback, the API's job is
deciding what reaches config — the ranges are shared so the two can't drift.
The ranges themselves are documented in
[`docs/ax/equipment/CONTEXT.md`](../ax/equipment/CONTEXT.md).

3. **A failed save re-renders the form, never the success banner.** With the
message and the values the user typed, so one bad field doesn't cost them
the whole entry.

4. **A blank required field is an error, not a zero.** Only `obstruction_perc`
and `field_stop` have a meaning for zero ("refractor", "unknown"), and only
those default when left blank. The old handlers' `request.form.get(x) or "0"`
turned every empty field into a valid-looking record.

## Considered options

- **Keep `Telescope.focal_length_mm` as an int and reject decimals with a clear
message.** Honest, and the smallest change. Rejected: it makes the API refuse
values that physically exist, and it leaves the same field name carrying two
types across the two records. The display concern that motivated `int` is a
formatting concern, and `format_measurement()` answers it directly.
- **Validate in the dataclasses' `__post_init__`.** Rejected: the records are
also built by `from_dict` at boot, and raising there re-creates #291's
unbootable device. Validation belongs at the write boundary — the API — with
the loader staying permissive and falling back to defaults.
- **Client-side validation only.** Rejected outright: it is exactly what the
system already had, and #569 is a report of it being bypassed. A `POST` from
a script, a stale page, or any browser quirk reaches the same handler.

## Consequences

- **The config loader no longer aborts the boot.** `config.py` catches a
malformed equipment section, logs it and falls back to the shipped defaults.
A PiFinder with a hand-edited config comes up usable instead of not at all.
- **The DeepskyLog import obeys the same limits.** A record it can't make sense
of is skipped and counted in the result message rather than written through
and discovered at the next boot.
- **`Eyepiece.__str__` formats through `format_measurement()`**, so the eyepiece
label on the object-detail screen reads "25mm Plossl" rather than
"25.0mm Plossl".
- **The bounds are judgement calls, not physics.** 2000mm of aperture and 180°
of AFOV are past anything an amateur owns; they exist to catch a typo or a
mis-parse, not to police gear. Widen them if someone's real equipment doesn't
fit — that is a bug in the limit, not in the user's telescope.
- **Existing configs are untouched.** No migration: the stored values are
already numbers the float fields read, and nothing rewrites them until the
user edits that record.
53 changes: 47 additions & 6 deletions docs/ax/equipment.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ for round-tripping through `config.json`; the nested `Telescope` /
| --- | --- | --- |
| `make` | str | Manufacturer, free text. |
| `name` | str | Model / instrument name. |
| `aperture_mm` | int | Clear aperture in mm. |
| `focal_length_mm` | int | Focal length in mm. Numerator of `calc_magnification()`. |
| `aperture_mm` | float | Clear aperture in mm. |
| `focal_length_mm` | float | Focal length in mm. Numerator of `calc_magnification()`. |
| `obstruction_perc` | float | Central obstruction as a percentage (0 for a refractor). Informational; not used by the optics calcs here. |
| `mount_type` | str | `"alt/az"` or `"equatorial"`. |
| `flip_image` | bool | Top-to-bottom (vertical) mirror of the object image. See §6. |
Expand All @@ -74,11 +74,31 @@ the glossary's "Flagged ambiguities."
| `make` | str | Manufacturer, free text. |
| `name` | str | Model name. |
| `focal_length_mm` | float | Focal length in mm. Denominator of `calc_magnification()`; also the eyepiece sort key. |
| `afov` | int | Apparent field of view (AFOV) in degrees — a property of the eyepiece alone. |
| `afov` | float | Apparent field of view (AFOV) in degrees — a property of the eyepiece alone. |
| `field_stop` | float | Field-stop diameter in mm; default `0`. When non-zero it gives a more accurate TFOV (see §5). |

`Eyepiece.__str__` renders as `"{focal_length_mm}mm {name}"`, which is the
string the object-detail screen burns into the image as the eyepiece label.
`Eyepiece.__str__` renders as `"{focal_length_mm}mm {name}"` — through
`format_measurement()`, so a whole-millimetre eyepiece reads "25mm Plossl"
rather than "25.0mm Plossl". That is the string the object-detail screen
burns into the image as the eyepiece label.

### 2.5 Field rules (`TELESCOPE_LIMITS` / `EYEPIECE_LIMITS`)

Every measurement is a **float** and carries an inclusive `Limits(minimum,
maximum)` pair declared alongside the dataclasses. The ranges themselves are
tabulated in [`equipment/CONTEXT.md`](./equipment/CONTEXT.md); the rationale for
floats-everywhere is [ADR 0027](../adr/0027-equipment-measurements-are-validated-floats.md).

Two properties matter more than the numbers:

- **One table, two enforcement points.** The edit forms render the limits into
their client-side check (`views/equipment_validation.html`) and the API
re-checks them (`server.py`, `telescope_from_form` / `eyepiece_from_form`).
The client is feedback; the API decides what reaches config.
- **The records themselves don't validate.** `__post_init__` only sorts. Raising
in the dataclasses would re-create #291 — an unbootable device — because
`from_dict` builds the same records at load. Validation lives at the write
boundary; the loader stays permissive and falls back to defaults (§3.1).

### 2.3 `Equipment` (`equipment.py:33`)

Expand Down Expand Up @@ -122,6 +142,11 @@ always reads the repo-root `default_config.json` into
very wrong"), Equipment is built empty: `Equipment(telescopes=[], eyepieces=[])`.
- Otherwise the section is validated (§3.3) and
`Equipment.from_dict(eq_config)` builds the object.
- If `from_dict` **can't** decode the section, the failure is logged and the
shipped defaults are used instead. This used to be an uncaught raise inside
`main()`, so a config an older release had written with a string measurement
produced a PiFinder that booted to nothing (#291); measurements are floats
now, and the fallback covers whatever else a hand edit can produce.

### 3.2 When a save is actually triggered — the freeze nuance

Expand Down Expand Up @@ -328,14 +353,26 @@ list/table page) and `views/edit_instrument.html` / `views/edit_eyepiece.html`
| `GET /equipment` | List telescopes + eyepieces, show active radios, import button. |
| `GET /equipment/set_active_instrument/<id>` | Set active telescope, save. |
| `GET /equipment/set_active_eyepiece/<id>` | Set active eyepiece, save. |
| `GET /equipment/edit_instrument/<id>` | Edit form (id `< 0` = add new, blank `Telescope`). |
| `GET /equipment/edit_instrument/<id>` | Edit form (id `< 0` = add new, blank fields). |
| `POST /equipment/add_instrument/<id>` | Create or update a telescope, save. |
| `GET /equipment/delete_instrument/<id>` | Remove a telescope, save. |
| `GET /equipment/edit_eyepiece/<id>` | Edit form (id `< 0` = add new). |
| `POST /equipment/add_eyepiece/<id>` | Create or update an eyepiece, save. |
| `GET /equipment/delete_eyepiece/<id>` | Remove an eyepiece, save. |
| `POST /equipment/import_from_deepskylog` | Bulk import from DeepskyLog (see below). |

Every route that takes an `<id>` range-checks it and re-renders the list page
with "No such instrument / eyepiece" rather than letting a stale or hand-edited
URL raise `IndexError` as a 500.

The two `add_*` handlers build their record through `telescope_from_form` /
`eyepiece_from_form` (§2.5). On a `ValueError` they re-render the **edit form**
with the message and the values that were submitted; only a record that
validated reaches `save_equipment()`. Before #569 both handlers swallowed the
exception and rendered the success banner regardless, so an unparseable value —
a decimal comma being the easiest way to produce one — reported "Eyepiece added"
and saved nothing.

The instrument form (`edit_instrument.html`) exposes the orientation flags
directly as checkboxes — labelled "Flip image (upside down)" and
"Flop image (left right)" — plus "Reverse Arrow A/B". The POST handler
Expand All @@ -362,6 +399,10 @@ eyepieces:
`flip_image` / `flop_image` straight from DeepskyLog). `reverse_arrow_*`
default to `False`. HTML entities in names are unescaped.
- Eyepieces map `focalLength`, `apparentFOV` → `afov`, and `field_stop_mm`.
- Each record is re-checked against the same limits as the forms
(`check_equipment_limits`). One DeepskyLog can't supply usable values for is
skipped and counted in the result message, rather than written through and
discovered at the next boot.
- Each new record is appended only if not already present (dedup via
`list.index(...)` raising `ValueError`), then `save_equipment()`.

Expand Down
33 changes: 33 additions & 0 deletions docs/ax/equipment/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,39 @@ _Avoid_: FOV (unqualified).
Per-telescope flags that invert push-to chart arrow directions to match how the observer reads their eyepiece/finder. These orient the *arrows*, never the *image*.
_Avoid_: flip arrows, mirror arrows.

### Field rules

**Measurement**:
Any numeric field on a telescope or eyepiece — aperture, focal length, obstruction, AFOV, field stop. All measurements are **floats**: real optics are fractional (a 11" SCT is 279.4mm, a focal reducer turns 2032mm into 1280.2mm). Rendered for display through `format_measurement()`, which drops a meaningless `.0`.
_Avoid_: dimension, spec, number.

**Limits**:
The inclusive `(minimum, maximum)` range a measurement may take, declared once in `equipment.py` (`TELESCOPE_LIMITS`, `EYEPIECE_LIMITS`). The edit form renders them into its client-side check and the API re-checks them; neither is the sole authority, but the API is the one that decides what reaches config.
_Avoid_: bounds, constraints, validation rules (as a name for the table).

The rules the two forms and the API enforce:

| Record | Field | Required | Range | Notes |
| --- | --- | --- | --- | --- |
| Telescope | `make` | no | ≤ 64 chars | Free text, stripped. |
| | `name` | **yes** | ≤ 64 chars | Blank names read as an empty row in the menu and the tables. |
| | `aperture_mm` | yes | 1 – 2000 | |
| | `focal_length_mm` | yes | 1 – 20000 | Zero would make magnification zero. |
| | `obstruction_perc` | no (0) | 0 – 100 | A percentage; a refractor is 0. |
| | `mount_type` | yes | `alt/az` \| `equatorial` | Anything else has no meaning. |
| Eyepiece | `make` | no | ≤ 64 chars | |
| | `name` | **yes** | ≤ 64 chars | |
| | `focal_length_mm` | yes | 0.1 – 100 | `calc_magnification` divides by it, so never 0. |
| | `afov` | yes | 1 – 180 | Degrees. |
| | `field_stop` | no (0) | 0 – 100 | 0 means unknown — TFOV falls back to AFOV ÷ magnification. |

Two rules that are not about ranges:

- **A blank field is not a zero.** An empty required measurement is an error, never silently `0`. Only `obstruction_perc` and `field_stop` have a documented zero meaning, and only those default when left blank.
- **A rejected entry never reports success.** The handler re-renders the form with the message and the values the user typed. This is the defect [#569](https://github.com/brickbots/PiFinder/issues/569) was raised for: the old handlers logged the failure and rendered "Eyepiece added" anyway.

Recorded in [ADR 0027](../../adr/0027-equipment-measurements-are-validated-floats.md).

### Boundary terms

- **Roll** — the camera roll from the latest plate-solve, owned by [Positioning](../positioning/CONTEXT.md); the object-image baseline rotation consumes it.
Expand Down
14 changes: 13 additions & 1 deletion python/PiFinder/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,19 @@ def load_config(self):
):
eq_config["active_eyepiece_index"] = 0

self.equipment = equipment.Equipment.from_dict(eq_config)
try:
self.equipment = equipment.Equipment.from_dict(eq_config)
except (ValueError, TypeError, KeyError):
# A value the equipment dataclasses can't decode used to
# abort main() before the UI came up — an unusable PiFinder
# (#291). Fall back to the defaults and keep booting; the
# web forms validate everything they write, so a config
# that lands here was hand-edited or written by an older
# release.
logger.exception(
"Could not load saved equipment; falling back to defaults"
)
self.equipment = equipment.Equipment.from_dict(default_eq)

# Load the locations config
loc_config = self.get_option("locations")
Expand Down
63 changes: 58 additions & 5 deletions python/PiFinder/equipment.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,80 @@
from dataclasses import dataclass
from dataclasses_json import dataclass_json
from operator import attrgetter
from typing import Union
from typing import NamedTuple, Union


class Limits(NamedTuple):
"""The inclusive range a user-entered measurement may take."""

minimum: float
maximum: float


# Every measurement below is a float: real optics are fractional (a 11"
# SCT is 279.4mm of aperture, a focal reducer turns 2032mm into 1280.2mm,
# a Nagler is 3.5mm) and an int field made those values unenterable — or,
# once written to config as a string, unbootable (#291). See
# docs/adr/0027-equipment-measurements-are-validated-floats.md.
#
# These limits are the single source of the validation rules: the edit
# forms render them into their inputs and their client-side check, and the
# API handlers re-check them before anything reaches config. Documented
# in prose in docs/ax/equipment/CONTEXT.md.
TELESCOPE_LIMITS = {
"aperture_mm": Limits(1, 2000),
"focal_length_mm": Limits(1, 20000),
"obstruction_perc": Limits(0, 100),
}

EYEPIECE_LIMITS = {
"focal_length_mm": Limits(0.1, 100),
"afov": Limits(1, 180),
"field_stop": Limits(0, 100),
}

# The mount types the instrument form offers. Not consumed at runtime —
# push-to arrows read the global ``mount_type`` option, not this one — but
# a value outside this set has no meaning, so the form rejects it.
MOUNT_TYPES = ("alt/az", "equatorial")

# Names longer than this overflow the on-device menu and the web tables.
NAME_MAX_LENGTH = 64


def format_measurement(value) -> str:
"""Render a measurement for display, dropping a meaningless ``.0``.

Focal lengths and apertures are stored as floats but are usually whole
millimetres; ``1000.0mm`` reads worse than ``1000mm``.
"""
try:
number = float(value)
except (TypeError, ValueError):
return str(value)
if number == int(number):
return str(int(number))
return str(number)


@dataclass
class Eyepiece:
make: str
name: str
focal_length_mm: float
afov: int
afov: float
field_stop: float = 0

def __str__(self):
return f"{self.focal_length_mm}mm {self.name}"
return f"{format_measurement(self.focal_length_mm)}mm {self.name}"


@dataclass
class Telescope:
make: str
name: str
aperture_mm: int
focal_length_mm: int
aperture_mm: float
focal_length_mm: float
obstruction_perc: float
mount_type: str
flip_image: bool
Expand Down
Loading
Loading