184 lines
9.6 KiB
Markdown
184 lines
9.6 KiB
Markdown
# Amateurfunk — BNetzA Question Catalog → Anki Decks
|
|
|
|
A small two-stage Python pipeline that downloads the German amateur-radio
|
|
exam question catalog ("Fragenkatalog") published by the Bundesnetzagentur
|
|
(BNetzA) and turns it into Anki decks.
|
|
|
|
The full source-discovery notes, JSON schema, exam-structure details, and
|
|
per-stage design decisions live in `DESIGN.md`. This file is a short
|
|
orientation for anyone (human or agent) opening the project.
|
|
|
|
## What the catalog is
|
|
|
|
- Official German amateur-radio exam questions for classes **N, E, A**
|
|
(German license tiers).
|
|
- Published by the Bundesnetzagentur under the **DL-DE→BY-2.0** open
|
|
data license (free reuse, attribution required).
|
|
- Distributed as a single ZIP containing one JSON file with the full
|
|
question tree, plus a `svgs/` folder with figures referenced by
|
|
individual questions.
|
|
- Current edition at time of writing: **3. Auflage, März 2024**
|
|
(issued 2024-03-20, valid from 2024-06-24, ~1750 questions).
|
|
|
|
## Canonical source
|
|
|
|
- ZIP (machine-readable): `https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/Fragenkatalog/PruefungsfragenZIP.zip?__blob=publicationFile`
|
|
- Landing page (short link): `https://www.bnetza.de/amateurfunk-fragenkatalog`
|
|
- PDF (human-readable, not used by this pipeline): same path with
|
|
`Pruefungsfragen.pdf` instead of `PruefungsfragenZIP.zip`.
|
|
|
|
The ZIP URL is stable across editions — BNetzA replaces the file
|
|
in-place. The `Last-Modified` HTTP header is reliable for change
|
|
detection. The filename inside the ZIP (`fragenkatalog3b.json`) encodes
|
|
the edition (`3b` = 3rd edition, revision b) and will change on new
|
|
editions, so we discover it from the archive rather than hard-coding.
|
|
|
|
## Pipeline overview
|
|
|
|
```
|
|
BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
|
|
├── fragenkatalog*.json
|
|
├── svgs/
|
|
├── README.txt
|
|
└── manifest.json
|
|
|
|
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
|
├── amateurfunk-technische-kenntnisse-n.apkg
|
|
├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks)
|
|
├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks)
|
|
├── amateurfunk-betriebliche-kenntnisse.apkg
|
|
└── amateurfunk-kenntnisse-von-vorschriften.apkg
|
|
|
|
shorthand.json ──[Stage 2b: amateurfunk_shorthand.py]──► anki/
|
|
└── amateurfunk-abkuerzungen-q-gruppen.apkg
|
|
|
|
technical.json ──[Stage 2c: amateurfunk_technical.py]──► anki/
|
|
└── amateurfunk-technische-abkuerzungen.apkg
|
|
```
|
|
|
|
### Stage 1 — `amateurfunk_fetch.py`
|
|
|
|
1. Download the ZIP from the canonical URL.
|
|
2. Verify it is a valid ZIP and contains the expected JSON + SVG files.
|
|
3. Extract to a target directory (default: `./data/<edition>/`).
|
|
4. Emit a small `manifest.json` next to the data: source URL,
|
|
fetched-at timestamp, `Last-Modified` from the server, JSON edition
|
|
metadata, sha256 of the ZIP.
|
|
5. Be idempotent — re-running without an upstream change is a no-op.
|
|
The skip key is the HTTP `Last-Modified` header recorded on the
|
|
previous manifest; the ZIP is deleted by default after extraction,
|
|
so the recorded sha256 is provenance, not a re-verification target.
|
|
See `DESIGN.md` §4 for the full idempotency contract.
|
|
|
|
### Stage 2 — `amateurfunk_anki.py`
|
|
|
|
1. Read the latest edition from `data/` (following
|
|
`manifest-latest.json` to a per-edition directory).
|
|
2. Split the catalog into five categories. Betriebliche and
|
|
Vorschriften get one deck each (shared across every candidate).
|
|
Technische is additionally fanned out per license class using a
|
|
strict equality split on the question's `class` field: one `.apkg`
|
|
each for N, E, and A. The E (463) and A (716) packages — the large
|
|
pools — are each built as a deck *tree*: one sub-deck per
|
|
first-level catalog topic (the 11 subsections) under an anchoring
|
|
`Technische Kenntnisse::E` / `::A` parent, so each imports as a
|
|
single file but studies topic by topic. N stays a single flat deck.
|
|
The set of split classes is `TOPIC_SPLIT_CLASSES`. The
|
|
`klasse-N|E|A` tag is still emitted on every note for inside-Anki
|
|
filtering.
|
|
3. Render every question as an Anki note: shuffled A/B/C/D choices on
|
|
the front, the displayed position of the correct answer on the
|
|
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
|
delimiters; the catalog's safe inline markup (`<u>...</u>`) is
|
|
preserved. If the question's number has an entry in
|
|
`explanations.json` (see EXPLANATIONS.md), an English explanation
|
|
block is appended to the back; a "low confidence" badge shows for
|
|
entries with `confidence < 7`.
|
|
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
|
|
package it as a `.apkg` ZIP with deterministic timestamps. By
|
|
default each build mints a fresh shuffle seed, so answers are
|
|
reshuffled every run. Pass `--seed` (and `--epoch`) for the
|
|
reproducible-build contract: same catalog + same seed + same
|
|
timestamp → byte-identical output across runs.
|
|
|
|
The Anki design decisions (shuffle seeding, deterministic build epoch,
|
|
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
|
|
|
|
### Stage 2b — `amateurfunk_shorthand.py`
|
|
|
|
A sibling builder for a standalone reference deck of Q-groups and
|
|
operating abbreviations — the ones in the exam plus the most common
|
|
on-air shorthand the exam never covers (real operating knowledge, not
|
|
just the test). Content lives in the hand-curated `shorthand.json`
|
|
(editorial, tracked in git, like `explanations.json`); the
|
|
`references/Q-Codes.md` reference is where the exam-present codes were
|
|
catalogued.
|
|
|
|
Each code is one Anki *note* with two card templates — forward
|
|
(code → meaning) and reverse (meaning → code) — so a single record
|
|
drives both directions. A Q-group means one thing as a statement
|
|
(`QSO`) and another as a question (`QSO?`), so each Q-group yields two
|
|
notes; plain abbreviations yield one. All IDs/GUIDs are hashed from the
|
|
displayed code form (stable re-import). The deck is catalog-independent
|
|
and fully deterministic; it only consults `data/` to borrow the
|
|
manifest build epoch when present. Low-level apkg/SQLite machinery is
|
|
imported from `amateurfunk_anki.py` so the two stay in lockstep. The
|
|
glossary machinery shared with Stage 2c (two-template note type,
|
|
two-cards-per-note writer, packager, build-epoch resolver, entry
|
|
validator) also lives here.
|
|
|
|
### Stage 2c — `amateurfunk_technical.py`
|
|
|
|
A third glossary deck, same card mechanics as Stage 2b (one note,
|
|
forward+reverse templates), for the *technical* vocabulary rather than
|
|
operating shorthand: modulation/modes (SSB, FM, CW), signal domains
|
|
(NF, HF, ZF), building blocks (VFO, PLL, AGC), components, measurements
|
|
(dB, SWR, PEP), propagation, digital modes, and the
|
|
organisations/regulations (ITU, CEPT, EMV) — exam terms plus common HAM
|
|
abbreviations beyond the exam. Content lives in the curated
|
|
`technical.json`; each entry carries a German `category` (Betriebsart,
|
|
Bauteil, …) shown on the card and used as a `kategorie-*` tag. The
|
|
shared glossary machinery is imported from `amateurfunk_shorthand.py`;
|
|
this script only adds the data shape, the deck/model names, and the tag
|
|
scheme. IDs live in their own `technical` namespace so the two glossary
|
|
decks never collide on import.
|
|
|
|
## Repo conventions
|
|
|
|
- Python 3.11+, standard library only. No third-party dependencies
|
|
in either stage.
|
|
- Single-file scripts: `amateurfunk_fetch.py`, `amateurfunk_anki.py`.
|
|
No frameworks, no CLI library beyond `argparse`.
|
|
- Style: section banners, commented constants, docstrings on every
|
|
function, inline comments at decision points. The two scripts
|
|
intentionally read the same way.
|
|
- Outputs are build artifacts: kept under `data/` and `anki/`, both
|
|
gitignored.
|
|
- License attribution string (required by DL-DE→BY-2.0) is preserved
|
|
verbatim from the upstream `README.txt` whenever we redistribute the
|
|
data.
|
|
|
|
## Working on this repo
|
|
|
|
- `EXPLANATIONS.md` — the editorial contract for agents asked to add
|
|
or improve per-question explanations. The schema, the workflows
|
|
("explain everything unexplained", "improve everything below
|
|
confidence 7"), the source/confidence guidance, and the **MathJax
|
|
typesetting rules (§4a) with a verification sweep** live there. The
|
|
`$...$` typesetting is the most error-prone part of the file — read
|
|
§4a before touching any formula, and fix every occurrence in both the
|
|
explanation body and the `Hilfsmittel:` note.
|
|
- Start from `DESIGN.md` — it has the JSON schema, the question/answer
|
|
conventions (answer A is always correct upstream → consumers shuffle
|
|
before display), the LaTeX-in-questions caveat, the exam-structure
|
|
rationale for the Anki package layout (Betriebliche, Vorschriften,
|
|
and Technische split per license class), and per-stage design notes.
|
|
- Do not invent new download URLs; the ones in `DESIGN.md` were
|
|
verified against the live BNetzA site.
|
|
- When BNetzA publishes a new edition, expect a new
|
|
`fragenkatalog<N><rev>.json` filename inside the ZIP. The fetcher
|
|
must not hard-code the current name.
|
|
- Both stages have a fixture-driven test suite. Run with
|
|
`python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki`.
|
|
Network access is only needed for the manual smoke test of Stage 1.
|