Compare commits

...

19 Commits

Author SHA1 Message Date
imple baeb325922 Improve explanations for DP0* callsigns 2026-07-02 17:57:30 +02:00
imple 4cb6412d4f Scale SVG images bigger 2026-06-24 13:46:39 +02:00
imple 3179f54681 Pull explanations from 50ohm.de when available 2026-06-24 13:09:52 +02:00
imple 4a14bbb989 Drop answer-letter references from explanations 2026-06-22 11:44:34 +02:00
imple 989d6d6888 Update documentation 2026-06-22 11:44:10 +02:00
imple 189a1954f7 Greatly improve LaTeX formula quality in explanations 2026-06-22 11:37:08 +02:00
imple c1603b1643 Update quality of references and satellite decks 2026-06-22 11:36:26 +02:00
imple c5c5f5f18e Significantly update explanations 2026-06-20 13:51:04 +02:00
imple 8fc3c0c32e Break A and E technical into subdecks 2026-06-18 17:46:33 +02:00
imple 6a4db1d889 Update CSS for Q-group examples 2026-06-17 12:48:16 +02:00
imple 2b73096d3c Add glossary decks 2026-06-17 11:48:27 +02:00
imple fb81e9c1aa Update explanations 2026-06-15 18:13:46 +02:00
imple 5c6fbebd65 Improve and complete all E* (Technische Kenntnisse, class E) explanations
Rewrite every class-E technical explanation to the relaxed, tutoring
standard: explain *why* the correct answer is correct, give the physics,
include formulas in inline LaTeX, and gloss German exam terms with their
common English equivalents (Hub/Frequenzhub = deviation, Sperrkreis =
trap, Raumwelle = sky wave, LC, etc.).

- All 463 E* entries now revised from the real catalog questions
  (answer_a = correct upstream); previously only 216 had been touched.
- Every revision bumped by exactly 1; no confidence ever lowered.
- Sources stay tier-appropriate (50ohm.de for technique, gesetze /
  BImSchV / BEMFV for the legal person-protection items).
- Emphasis uses <u>..</u> (the only sanctioned inline markup), not
  markdown asterisks.
- Fixed three carried-over nits: reverted off-scope VD709, and reset
  EE301/EE306 revisions (1->3) to a correct single +1.

Validated: schema clean, all keys present in the live catalog, balanced
LaTeX, no display math, no stray HTML. Full suite passes (27 tests,
including the live catalog-match test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:47:42 +02:00
imple 3ce060947d Improve "Kenntnisse von Vorschriften" explanations 2026-06-04 11:59:48 +02:00
imple fdc5932d7d Randomize questions on every Anki regen
Also make explanation section slightly more readable
2026-06-04 11:10:08 +02:00
imple 7bf6f98ee2 Update makefile clean target 2026-05-26 23:04:54 +02:00
imple 1222f86ae4 Update readme 2026-05-26 15:28:15 +02:00
imple 2c3c8ffcc5 Add helping study references 2026-05-26 15:26:04 +02:00
imple 899a169da7 Improve explanations 2026-05-26 15:25:42 +02:00
18 changed files with 9603 additions and 4780 deletions
+67 -10
View File
@@ -44,10 +44,16 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
├── amateurfunk-technische-kenntnisse-n.apkg ├── amateurfunk-technische-kenntnisse-n.apkg
├── amateurfunk-technische-kenntnisse-e.apkg ├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks)
├── amateurfunk-technische-kenntnisse-a.apkg ├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks)
├── amateurfunk-betriebliche-kenntnisse.apkg ├── amateurfunk-betriebliche-kenntnisse.apkg
└── amateurfunk-kenntnisse-von-vorschriften.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` ### Stage 1 — `amateurfunk_fetch.py`
@@ -70,10 +76,16 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
`manifest-latest.json` to a per-edition directory). `manifest-latest.json` to a per-edition directory).
2. Split the catalog into five categories. Betriebliche and 2. Split the catalog into five categories. Betriebliche and
Vorschriften get one deck each (shared across every candidate). Vorschriften get one deck each (shared across every candidate).
Technische is additionally fanned out per license class into three Technische is additionally fanned out per license class using a
decks (N / E / A) using a strict equality split on the question's strict equality split on the question's `class` field: one `.apkg`
`class` field. The `klasse-N|E|A` tag is still emitted on every each for N, E, and A. The E (463) and A (716) packages — the large
note for inside-Anki filtering. 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 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 the front, the displayed position of the correct answer on the
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)` back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
@@ -83,12 +95,54 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
block is appended to the back; a "low confidence" badge shows for block is appended to the back; a "low confidence" badge shows for
entries with `confidence < 7`. entries with `confidence < 7`.
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and 4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
package it as a `.apkg` ZIP with deterministic timestamps. Same package it as a `.apkg` ZIP with deterministic timestamps. By
input → byte-identical output across runs. 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, The Anki design decisions (shuffle seeding, deterministic build epoch,
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7. 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 ## Repo conventions
- Python 3.11+, standard library only. No third-party dependencies - Python 3.11+, standard library only. No third-party dependencies
@@ -109,8 +163,11 @@ SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
- `EXPLANATIONS.md` — the editorial contract for agents asked to add - `EXPLANATIONS.md` — the editorial contract for agents asked to add
or improve per-question explanations. The schema, the workflows or improve per-question explanations. The schema, the workflows
("explain everything unexplained", "improve everything below ("explain everything unexplained", "improve everything below
confidence 7"), and the source/confidence guidance live there. confidence 7"), the source/confidence guidance, and the **MathJax
`explanations.json` is an empty `{}` until agents populate it. 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 - Start from `DESIGN.md` — it has the JSON schema, the question/answer
conventions (answer A is always correct upstream → consumers shuffle conventions (answer A is always correct upstream → consumers shuffle
before display), the LaTeX-in-questions caveat, the exam-structure before display), the LaTeX-in-questions caveat, the exam-structure
+47 -23
View File
@@ -463,8 +463,10 @@ These do not block the Stage 1 implementation:
Given the per-edition directory produced by Stage 1, build a set of Given the per-edition directory produced by Stage 1, build a set of
Anki `.apkg` files that turn every catalog question into a flash card. Anki `.apkg` files that turn every catalog question into a flash card.
Same input must produce byte-identical output across runs — this is By default, each build uses a fresh shuffle seed so repeated imports
important so generated decks can be checksummed and cached cleanly. vary the answer order. When `--seed` and `--epoch` are supplied,
the same input must produce byte-identical output across runs — useful
for tests, checksums, and cacheable release builds.
### CLI shape ### CLI shape
@@ -475,9 +477,9 @@ amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT]
- `--data DIR` — fetch output root (default `./data`). Must contain - `--data DIR` — fetch output root (default `./data`). Must contain
`manifest-latest.json` pointing at a per-edition directory. `manifest-latest.json` pointing at a per-edition directory.
- `--out DIR` — destination for `.apkg` files (default `./anki`). - `--out DIR` — destination for `.apkg` files (default `./anki`).
- `--seed STR` — deterministic seed for answer shuffling. The default - `--seed STR` — deterministic seed for answer shuffling. Omit it
is fixed; changing it produces a different (but still deterministic) for the normal study-deck behavior: a fresh seed is generated for
shuffle. each build, so answers are reshuffled every time decks are rebuilt.
- `--epoch INT` — override the package timestamp epoch. By default we - `--epoch INT` — override the package timestamp epoch. By default we
derive it from the manifest's `fetched_at`; this flag is mainly for derive it from the manifest's `fetched_at`; this flag is mainly for
tests and explicit rebuilds. tests and explicit rebuilds.
@@ -492,25 +494,44 @@ output artifacts.
``` ```
anki/ anki/
amateurfunk-technische-kenntnisse-n.apkg (195 cards) amateurfunk-technische-kenntnisse-n.apkg (195 cards)
amateurfunk-technische-kenntnisse-e.apkg (463 cards) amateurfunk-technische-kenntnisse-e.apkg (463 cards, 11 sub-decks)
amateurfunk-technische-kenntnisse-a.apkg (716 cards) amateurfunk-technische-kenntnisse-a.apkg (716 cards, 11 sub-decks)
amateurfunk-betriebliche-kenntnisse.apkg (172 cards) amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards) amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
``` ```
Five `.apkg` files. Betriebliche and Vorschriften are shared across Betriebliche and Vorschriften are shared across every candidate
every candidate (class-1-only in the data per §3 axis 2) and stay as (class-1-only in the data per §3 axis 2) and stay as one deck each.
one deck each. Technische is fanned out per license class using a Technische is fanned out per license class using a **strict equality
**strict equality split** on the question's `class` field — class-1 split** on the question's `class` field — class-1 questions land in
questions land in the N deck only, class-2 in E only, class-3 in A the N deck only, class-2 in E only, class-3 in A only. The card counts
only. The card counts therefore equal the new-at-this-class slices therefore equal the new-at-this-class slices from §3, not the
from §3, not the cumulative study pools: a candidate studying for cumulative study pools: a candidate studying for class E imports
class E imports Technische-N + Technische-E + Betriebliche + Technische-N + Technische-E + Betriebliche + Vorschriften.
Vorschriften.
The class-E (463) and class-A (716) Technische pools are large enough
that a single flat deck is unwieldy, so each package is built as a deck
*tree* rather than fanned out into separate files: one `.apkg`
containing one sub-deck per first-level catalog topic (the 11
subsections under the Prüfungsteil, e.g. "Sender und Empfänger",
"Antennen und Übertragungsleitungen") plus an anchoring `…::E` / `…::A`
parent deck. Each card is filed under its topic via the `did` column;
grouping is by the first section title below the Prüfungsteil
(`item.path[1]`). Anki sorts the sub-decks alphabetically on import (it
has no per-deck manual ordering), not in catalog order. N (195) is
small enough to stay one flat deck. The split classes are listed in
`TOPIC_SPLIT_CLASSES`. These multi-deck packages list every sub-deck in
the `col.decks` blob (see `build_apkg_for_category` and
`insert_collection_metadata`).
Note that re-importing a reworked package does not move cards that
already exist in a collection — Anki only files *new* cards by the
package's decks and leaves existing cards in their current deck.
The Technische deck names use Anki's `::` hierarchy separator The Technische deck names use Anki's `::` hierarchy separator
(`Amateurfunk::Technische Kenntnisse::N`) so the three decks render (`Amateurfunk::Technische Kenntnisse::N`, and one level deeper for E/A —
as children of a shared parent in Anki's deck browser. The `Amateurfunk::Technische Kenntnisse::A::Sender und Empfänger`) so the
decks render as a nested tree in Anki's deck browser. The
`klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every `klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every
note — redundant within each Technische deck but useful in Betr/Vor note — redundant within each Technische deck but useful in Betr/Vor
for in-Anki filtering, and harmless besides. for in-Anki filtering, and harmless besides.
@@ -565,8 +586,10 @@ for in-Anki filtering, and harmless besides.
### Determinism ### Determinism
The contract is: same catalog in → same `.apkg` bytes out. The default CLI intentionally is not byte-deterministic because it
Determinism rests on three things: generates a fresh shuffle seed each run. The reproducible-build
contract is: same catalog + same `--seed` + same timestamp inputs →
same `.apkg` bytes out. Determinism rests on three things:
1. **Stable IDs.** `stable_id(namespace, text)` hashes a 1. **Stable IDs.** `stable_id(namespace, text)` hashes a
namespaced key with SHA-1 and squashes into the standard namespaced key with SHA-1 and squashes into the standard
@@ -587,8 +610,9 @@ Determinism rests on three things:
last step, the inner SQLite would be identical but the last step, the inner SQLite would be identical but the
archive's per-entry mtimes would still vary between runs. archive's per-entry mtimes would still vary between runs.
The combined effect: two runs with the same `data/` produce The combined effect: two runs with the same `data/`, `--seed`, and
byte-identical sha256 on each `.apkg`. Verified during review. timestamp inputs produce byte-identical sha256 on each `.apkg`.
Verified during review.
### Rendering decisions worth knowing ### Rendering decisions worth knowing
@@ -636,7 +660,7 @@ byte-identical sha256 on each `.apkg`. Verified during review.
`explanations.json` (CLI: `--explanations`) and, for any `explanations.json` (CLI: `--explanations`) and, for any
question number found there, appends an English explanation question number found there, appends an English explanation
block to the back of the card. The block is styled distinctly block to the back of the card. The block is styled distinctly
(serif italic body, sans-serif metadata, top border) and shows (serif body, sans-serif metadata, top border) and shows
a small "low confidence" badge when the entry's `confidence` a small "low confidence" badge when the entry's `confidence`
field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and
the raw `confidence` number are editorial-only and never the raw `confidence` number are editorial-only and never
+209 -16
View File
@@ -27,21 +27,31 @@ entry is purely additive — no regenerate ceremony beyond
### Per-entry schema ### Per-entry schema
Every entry MUST have exactly these four fields: Every entry MUST have these four required fields, and MAY carry the one
optional field below:
| Field | Type | Constraint | | Field | Type | Constraint |
|---------------|---------|--------------------------------------------| |---------------|---------|--------------------------------------------|
| `revision` | integer | `>= 1`. Starts at `1`, bumps on improvement | | `revision` | integer | `>= 1`. Starts at `1`, bumps on improvement |
| `explanation` | string | Non-empty. **English.** Terse. WHY-focused | | `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` | | `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
| `confidence` | integer | `1..10` inclusive. See scale in §5 | | `confidence` | integer | `1..10` inclusive. See scale in §5 |
| `provenance` | string | *Optional.* Only allowed value: `"50ohm-loesungsweg"` |
Extra keys are rejected by `load_explanations()` — the build fails `provenance` records **how the text was produced**, which `source` (a
with `unknown fields [...]` listing them. The loader is similarly citation) does not. Set it to `"50ohm-loesungsweg"` only on entries
strict about types: a JSON `true` will not satisfy the integer that are genuinely a translation/condensation of a 50ohm.de worked
contract for `revision` or `confidence`. If you need to track solution (`contents/solutions/<ID>.md` in `DARC-e-V/50ohm-contents-dl`).
editorial metadata that isn't shown on the card, propose a schema The build uses it — and *not* the `source` domain — to decide whether
change rather than smuggling fields in. to show the CC BY 4.0 derivative-work credit on the card (CC BY
requires naming the author team and indicating modification). Do **not**
add it just because an entry cites a 50ohm.de study page; merely citing
a page is not a derivative work. Any other value, or any other extra
field, is rejected by `load_explanations()` with
`unknown fields [...]` / `provenance must be one of [...]`. The loader
is also strict about types: a JSON `true` will not satisfy the integer
contract for `revision` or `confidence`. To track other editorial
metadata, propose a schema change rather than smuggling fields in.
Top-level keys (the question numbers) must also match the catalog Top-level keys (the question numbers) must also match the catalog
exactly. An entry keyed on a number that no live question carries exactly. An entry keyed on a number that no live question carries
@@ -82,7 +92,7 @@ modify Python code to ship a new explanation. End-to-end:
inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the inline `$...$` LaTeX rewritten to MathJax `\(...\)`, same as the
question text), and a "Source: ..." line. question text), and a "Source: ..." line.
3. The block lands inside `.af-back` at the very end, styled by the 3. The block lands inside `.af-back` at the very end, styled by the
`.af-explanation*` CSS rules — serif italic body, sans-serif `.af-explanation*` CSS rules — serif body, sans-serif
metadata, separated from the answer by a top border. When the metadata, separated from the answer by a top border. When the
entry's `confidence` is **below 7**, a small "low confidence" entry's `confidence` is **below 7**, a small "low confidence"
badge appears next to the **Explanation** header — a hint to the badge appears next to the **Explanation** header — a hint to the
@@ -141,23 +151,152 @@ source citation.
- **Language: English.** The questions and answers stay in German - **Language: English.** The questions and answers stay in German
on the card; the explanation is the one English element. Don't on the card; the explanation is the one English element. Don't
switch. switch.
- **Terse.** Aim for 13 sentences. The card already shows the - **Correct and helpful first; length follows the topic.** There is
question, the correct answer text, and the breadcrumb — the no sentence cap. Use as much room as the question genuinely needs —
explanation should add the missing *why*, not restate any of a one-line arithmetic restatement for a simple lookup, a full
those. worked derivation or a multi-step rule walkthrough where that is
what makes the answer *click*. Don't pad: every sentence should
earn its place by adding correctness, context, or a usable hook.
The card already shows the question, the correct answer text, and
the breadcrumb — the explanation adds the missing *why* and the
understanding needed to get there, not a restatement of those.
- **Lead with the reasoning.** Don't open with "The correct answer - **Lead with the reasoning.** Don't open with "The correct answer
is X because..." — the card already declares the correct answer is X because..." — the card already declares the correct answer
one line above. Go straight to the principle, the formula, the one line above. Go straight to the principle, the formula, the
rule of thumb. rule of thumb.
- **Math.** Use inline `$...$`; it's rewritten to MathJax on the - **Math.** Use inline `$...$`; it's rewritten to MathJax on the
card, same as for question text. Don't use display math `$$...$$` card, same as for question text. Don't use display math `$$...$$`
(the deck has no MathJax support for those). (the deck has no MathJax support for those). Getting the *inside* of
`$...$` right is its own discipline — see **§4a Math typesetting**
below. This is the single most error-prone part of this file.
- **Inline emphasis.** `<u>...</u>` is the only HTML you should - **Inline emphasis.** `<u>...</u>` is the only HTML you should
type by hand. Everything else gets HTML-escaped. type by hand. Everything else gets HTML-escaped.
- **No spoilers about other answers.** If a distractor is a common - **No spoilers about other answers.** If a distractor is a common
trap, naming the trap is fine; quoting the distractor's text is trap, naming the trap is fine; quoting the distractor's text is
noise. noise.
### 4a. Math typesetting (MathJax) — get this right
Everything between `$...$` is TeX. MathJax renders **bare multi-letter
runs as a product of italic variables**, so `ohm` shows as *o·h·m*,
`log10` as *l·o·g·10*, `P_{PEP}` as *P·(P·E·P)*. A whole multi-round
review on this repo was spent fixing exactly these. The rules below are
the checklist; wrong → right.
**Units** — never leave a unit as bare letters; wrap in `\text{}` (or use
the symbol) and put a thin space `\,` after the number:
| Wrong (in `$...$`) | Right |
|---|---|
| `0.01 ohm`, `10 kOhm`, `5 MOhm` | `0.01\,\Omega`, `10\,\text{k}\Omega`, `5\,\text{M}\Omega` |
| `3 microhenry`, `3 uH` | `3\,\mu\text{H}` |
| `1 uF`, `100 nF`, `47 pF` | `1\,\mu\text{F}`, `100\,\text{nF}`, `47\,\text{pF}` |
| `5 mA`, `2 mV`, `1 kHz`, `28 MHz` | `5\,\text{mA}`, `2\,\text{mV}`, `1\,\text{kHz}`, `28\,\text{MHz}` |
| `12 dB`, `8 dBi`, `46.8 bit/s` | `12\,\text{dB}`, `8\,\text{dBi}`, `46.8\,\text{bit/s}` |
| `5 mm2`, `2.5 A/mm2` | `5\,\text{mm}^2`, `2.5\,\text{A/mm}^2` |
| `360 degrees`, `45°` | `360^\circ`, `45^\circ` |
| `20 A`, `12 V`, `100 W`, `5 m`, `50 s` | `20\,\text{A}`, `12\,\text{V}`, `100\,\text{W}`, `5\,\text{m}`, `50\,\text{s}` |
**Operators, functions, Greek, powers:**
| Wrong | Right | Why |
|---|---|---|
| `log10(x)` | `\log_{10}(x)` | bare `log` is *l·o·g* |
| `10^(20/10)` | `10^{20/10}` | `^(` superscripts only the `(`; braces group |
| `lambda`, `pi`, `tau`, `omega`, `rho`, `mu` | `\lambda`, `\pi`, `\tau`, … | bare = italic letters |
| `sin`, `cos`, `ln` | `\sin`, `\cos`, `\ln` | |
| `2 x 3`, `R1 || R2` | `2 \cdot 3`, `R_1 \parallel R_2` | `x`/`||` are not operators |
| `62.5%` | `62.5\%` | **bare `%` is a TeX comment — it silently eats the rest of the math** |
| `4,200,000` | `4{,}200{,}000` | bare `,` gets TeX punctuation spacing |
**Subscripts.** Descriptive multi-letter labels/acronyms are roman; a
single-letter subscript stays italic:
| Wrong | Right |
|---|---|
| `P_{EIRP}`, `U_{peak}`, `f_{mod}`, `V_{BE}`, `f_{sum}` | `P_\mathrm{EIRP}`, `U_\mathrm{peak}`, `f_\mathrm{mod}`, `V_\mathrm{BE}`, `f_\mathrm{sum}` |
| `R1/R2 = R3/R4` | `R_1/R_2 = R_3/R_4` |
| keep italic: `U_F`, `f_c`, `X_C`, `R_g` | (single letter = variable, leave as-is) |
A standalone acronym used as a variable (e.g. `MUF` on a formula's LHS)
also goes roman: `\mathrm{MUF}`.
**Dimensional constants keep their unit.** The field-strength constant is
`30\,\Omega`, so write `\sqrt{30\,\Omega \cdot P_\mathrm{EIRP}}` and
`(E\cdot d)^2/(30\,\Omega)`, not a bare `30`. Same idea for the dB-level
exponent: `10^{g/(10\,\text{dB})}` (the `(...)` groups the denominator —
`10^{g/10\,\text{dB}}` parses as `(g/10)·dB`).
**Unit vs. variable — don't blindly unit-ize a single letter.** `A` is
amperes in `20 A` but *area* in `N^2 A/l`; `s`/`m` are seconds/metres in
`50 s` / `5 m` but could be variables elsewhere. The tell: a *free-standing
number* immediately before the letter (`20 A`) means a unit; an exponent
base (`N^2 A`) or a symbolic factor (`S \cdot A`) means a variable. When
unsure, read the sentence.
**What stays prose — do NOT force into `$...$`:**
- Band/wavelength designations: `5/8-wave`, `20/15/10 m trap dipole`,
`the 80 m band`.
- Conceptual word-equations: `1st overtone = 2nd harmonic`,
`Region 1 = Europe/Africa`.
- Units mentioned adjectivally in a sentence: "a 50 ohm antenna",
"the 28 V/m limit", "about 11.7 V/m".
But a **worked computation with an `=`** (e.g. `230 V / 20 = 11.5 V`)
belongs in `$...$`, fully typeset.
### Verifying it — the process lessons
These review rounds kept finding *peers* of an already-fixed defect.
Avoid the repeat:
1. **Fix the whole record, not a substring.** The same formula usually
appears in *both* the explanation body *and* the `Hilfsmittel:` note.
Search the entire entry and fix every occurrence.
2. **Fix the whole class, file-wide — not just the IDs someone listed.**
If `P_{EIRP}` is wrong in one card it is wrong in twenty; sweep the
whole file for the pattern.
3. **Verify with a positive sweep, not a blacklist.** A list of
known-bad tokens always misses a new category (it missed `degrees`,
`bit`, `||`, bare seconds…). Instead: strip `\text{}`/`\mathrm{}` and
the known macros, then flag *every remaining* ≥2-letter run inside
`$...$` — each survivor must be a deliberate variable product
(`LC`, `jX`, `RC`, `di/dt`) or it's a bug.
4. **Don't claim "0 / exhaustive" unless the check actually covers that
category.** Report what you checked, not a blanket adjective.
A ready-made sweep:
```sh
python3 -c '
import json, re
d = json.load(open("explanations.json"))
span = re.compile(r"\$[^$]*\$")
def strip(s):
s = re.sub(r"\\(?:text|mathrm|operatorname)\{[^{}]*\}", " ", s)
return re.sub(r"\\[A-Za-z]+", " ", s) # drop macros (\sqrt, \pi, \cdot, ...)
for k, e in d.items():
ex = e["explanation"] if isinstance(e, dict) else ""
if ex.count("$") % 2 or ex.count("{") != ex.count("}"):
print("BALANCE", k)
for m in span.finditer(ex):
s = m.group(0)
if re.search(r"(?<!\\)%", s) or "||" in s or "°" in s \
or re.search(r"(?<!\\)\blog10\b|10\^\(", s) \
or re.search(r"_\{[A-Za-z][A-Za-z,]*\}", s) \
or re.search(r"(?<=\d),(?=\d\d\d)", s):
print("ISSUE", k, m.group(0)[:60]); break
for tok in re.findall(r"[A-Za-z]{2,}", strip(s)):
# whitelist genuine products / kept tokens; investigate the rest
if tok not in {"LC","RC","RA","UI","CU","PR","jX","fL","fC","di","dt"}:
print("TOKEN?", k, tok, "|", m.group(0)[:60])
'
```
Anything it prints is either a real defect or a new legitimate product to
add to the whitelist — look, don't assume.
### What "WHY-focused" means in practice ### What "WHY-focused" means in practice
| Less useful | More useful | | Less useful | More useful |
@@ -171,6 +310,43 @@ band-plan lookup), say that plainly and cite the band plan in
`source` — confidence stays low (34) until someone finds a deeper `source` — confidence stays low (34) until someone finds a deeper
hook. hook.
### The Hilfsmittel note
Candidates may use the official exam aid (`Hilfsmittel_12062024.pdf`)
during the exam. Its complete contents — every formula and table, with
**printed page numbers** — are catalogued in
[`references/Hilfsmittel.md`](references/Hilfsmittel.md). When a
question's answer is a lookup or a direct application of something in
that sheet, append a short note to the end of the `explanation` body:
```
... <u>Hilfsmittel:</u> <pointer>
```
Rules — this is the part that was historically done badly (generic
boilerplate stamped on everything), so be strict:
- **Only cite what is actually in the sheet.** Verify against
`references/Hilfsmittel.md`. If the fact the question turns on is a
memory item — diode forward voltages (~0.6 V / ~0.3 V), `tan δ = 1/Q`,
S-meter step = 6 dB, harmonics = n × fundamental, ppm/percent
arithmetic, semiconductor behaviour, definitions, antenna
length/shortening factors — **do not add a Hilfsmittel note at all.**
- **Name the specific formula(s) or table, and the page.** Not "the
formula is in the Formelsammlung" but e.g. `P = U²/R (Leistung,
S.12)` or `the Widerstands-Farbcode table (S.11)`.
- **For multi-step calculations, give the order:** "first
`U_eff = Û/√2` (Wechselspannung, S.12), then `P = U_eff²/R`
(Leistung, S.12)". State plainly when a value comes from outside the
sheet (e.g. a diode drop) versus from a sheet formula.
- **For a pure table lookup**, say it is a lookup and cite the table +
page (e.g. "a table lookup, not a memory item — the band limits …
are in the Frequenzbereichszuweisung (Anlage 1, Tabellarische
Übersicht, S. 23)").
- **Page numbering:** the printed page number = PDF page 2 (the cover
and the "Hinweis" page are unnumbered). Always cite the printed
number, as `references/Hilfsmittel.md` does.
--- ---
## 5. Confidence scale ## 5. Confidence scale
@@ -236,6 +412,11 @@ The full priority order, top (most preferred) to bottom:
official explanatory notes. official explanatory notes.
- Useful when primary law is too terse to convey the *why* on - Useful when primary law is too terse to convey the *why* on
its own — these are the regulator's own gloss on the law. its own — these are the regulator's own gloss on the law.
- Do not cite the question catalog PDF/ZIP itself as an
explanation source. It proves what the official answer is, but
it usually does not explain why that answer is right. Use a
law, regulation, BNetzA notice, DARC/50ohm study page, standard,
or other source that actually supports the explanation.
4. **German amateur-radio organisations.** 4. **German amateur-radio organisations.**
- DARC publications and band plan (`darc.de`), Runder Tisch - DARC publications and band plan (`darc.de`), Runder Tisch
@@ -401,13 +582,25 @@ Before saving, confirm:
- [ ] Key is the exact catalog `number` (case-sensitive). - [ ] Key is the exact catalog `number` (case-sensitive).
- [ ] All four fields present, correct types, in range. - [ ] All four fields present, correct types, in range.
- [ ] `explanation` is English, terse, WHY-focused. - [ ] `explanation` is English, correct, helpful, WHY-focused — as
long as the topic needs, no longer.
- [ ] `source` is non-empty and as primary as possible. - [ ] `source` is non-empty and as primary as possible.
- [ ] `revision` is correct (1 for new, bumped for improvement). - [ ] `revision` is correct (1 for new, bumped for improvement).
- [ ] `confidence` honestly reflects how well the source supports - [ ] `confidence` honestly reflects how well the source supports
the explanation. the explanation.
- [ ] Keys remain in alphabetical order in the file. - [ ] Keys remain in alphabetical order in the file.
- [ ] `python3 -m unittest test_amateurfunk_anki` passes. - [ ] **Math typesetting (§4a):** every formula is in `$...$`; units are
`\text{}`/symbols with a leading `\,`; no bare `log10`, `10^(`,
Greek words, `%`, `||`, multi-letter `_{...}` subscripts, or raw
thousands commas; dimensional constants keep their unit. Fix every
occurrence in **both** the body and the `Hilfsmittel:` note.
- [ ] The §4a verification sweep prints nothing unexpected (run it after
any batch of math edits — don't trust a per-ID fix to have caught
the peers).
- [ ] `python3 -m unittest test_amateurfunk_anki` passes. (The full deck
suite is `test_amateurfunk_fetch test_amateurfunk_anki
test_amateurfunk_shorthand test_amateurfunk_technical` — 95 tests;
the two-module subset that validates explanation schema is 65.)
--- ---
+10 -4
View File
@@ -1,6 +1,6 @@
.PHONY: all fetch anki test clean .PHONY: all fetch anki shorthand technical test clean
all: fetch anki all: fetch anki shorthand technical
fetch: fetch:
python3 amateurfunk_fetch.py python3 amateurfunk_fetch.py
@@ -8,8 +8,14 @@ fetch:
anki: anki:
python3 amateurfunk_anki.py python3 amateurfunk_anki.py
shorthand:
python3 amateurfunk_shorthand.py
technical:
python3 amateurfunk_technical.py
test: test:
python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki test_amateurfunk_shorthand test_amateurfunk_technical
clean: clean:
rm -rf data anki rm -rf data anki __pycache__
+131 -15
View File
@@ -6,19 +6,33 @@ Bundesnetzagentur and turn it into Anki decks.
## Quick start ## Quick start
```sh ```sh
make # fetch + build (default) make # fetch + build everything (default)
make fetch # download + extract the catalog only → data/ make fetch # download + extract the catalog only → data/
make anki # rebuild .apkg files from data/ → anki/ make anki # rebuild the five exam decks from data/ → anki/
make test # run both test suites make shorthand # rebuild the Q-group / operating-abbreviation deck → anki/
make technical # rebuild the technical / HAM-abbreviation deck → anki/
make test # run all test suites
make clean # remove data/ and anki/ make clean # remove data/ and anki/
``` ```
Output: five `.apkg` files under `anki/`. Betriebliche and Output: seven `.apkg` files under `anki/`.
**Five exam decks** built from the catalog: Betriebliche and
Vorschriften get one deck each (shared across all license classes); Vorschriften get one deck each (shared across all license classes);
Technische is split per class into three decks (N / E / A) following Technische is split per class into three decks (N / E / A) following
the catalog's `class` field. A class-A candidate who wants every the catalog's `class` field. A class-A candidate who wants every
Technische question imports all three Technische decks. Re-importing Technische question imports all three Technische decks. The E (463)
a newer build preserves your review history. and A (716) decks are large pools, so each ships as a deck *tree*
one sub-deck per first-level exam topic under `Technische Kenntnisse::E`
/ `::A` — so you can study them topic by topic instead of as one giant
deck. Each is still a single `.apkg`; N (195) stays flat (small enough
that sub-decks would add clutter without helping).
**Two glossary decks** of radio shorthand — Q-groups and operating
abbreviations, and technical/HAM abbreviations — built from curated
data rather than the catalog (see [Glossary decks](#glossary-decks)).
Re-importing a newer build preserves your review history.
## Exam sections ## Exam sections
@@ -36,18 +50,63 @@ belongs to:
**Technical (one deck per license class):** **Technical (one deck per license class):**
| Class | Section name | ID prefix | Questions | | Class | Section name | ID prefix | Questions | Layout |
|-------|---------------------------|-----------|----------:| |-------|---------------------------|-----------|----------:|--------|
| N | Technische Kenntnisse (N) | `N*` | 195 | | N | Technische Kenntnisse (N) | `N*` | 195 | one flat deck |
| E | Technische Kenntnisse (E) | `E*` | 463 | | E | Technische Kenntnisse (E) | `E*` | 463 | 11 topic sub-decks |
| A | Technische Kenntnisse (A) | `A*` | 716 | | A | Technische Kenntnisse (A) | `A*` | 716 | 11 topic sub-decks |
Counts are from the current edition (3. Auflage, März 2024; ~1750 Counts are from the current edition (3. Auflage, März 2024; ~1750
questions total). The license tiers are cumulative for the exam: questions total). The class-E and class-A decks are each split into one
sub-deck per first-level exam topic (the 11 catalog subsections —
*Bauteile*, *Sender und Empfänger*, *Antennen …*, etc.) because those
pools are unwieldy as a single deck; each is still one `.apkg`
containing a deck tree. Anki sorts those sub-decks alphabetically on
import, not in exam order. The license tiers are cumulative for the exam:
a class-E candidate is responsible for `N*` + `E*` + `B*` + `V*`; a class-E candidate is responsible for `N*` + `E*` + `B*` + `V*`;
a class-A candidate is responsible for everything. Filter inside a class-A candidate is responsible for everything. Filter inside
Anki by deck, by the `klasse-N|E|A` tag, or by the `Number` field Anki by deck, by the `klasse-N|E|A` tag, or — for the topic within a
prefix. deck — by the `pfad-*` tag, or by the `Number` field prefix.
## Glossary decks
Two extra decks teach the radio shorthand a candidate actually needs —
the codes used in the exam **plus** the most common ones used on the
air that the exam never tests (real operating knowledge, not just the
test). They are built from hand-curated JSON, independent of the
catalog, so they build even without `make fetch`:
| Deck | Source | Builder |
|-----------------------------------------------|------------------|----------------------------|
| `amateurfunk-abkuerzungen-q-gruppen.apkg` | `shorthand.json` | `amateurfunk_shorthand.py` |
| `amateurfunk-technische-abkuerzungen.apkg` | `technical.json` | `amateurfunk_technical.py` |
- **Q-groups & operating abbreviations** — Q-codes (QRM, QSO, QSY…),
CW/voice shorthand (CQ, DE, 73, RST…), prosigns, and the
distress/urgency signals (MAYDAY, SOS…).
- **Technical & HAM abbreviations** — modulation and 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).
Each code is a single Anki **note** with two cards: one prompts for the
meaning given the code, the reverse prompts for the code given the
meaning. A Q-group means one thing as a statement (`QSO`) and another
as a question (`QSO?`), so each becomes two notes (four cards).
Filter inside Anki by tag: `pruefung` marks codes that appear in the
exam catalog; `q-gruppe` / `abkuerzung` and (technical deck)
`kategorie-*` mark the kind; `prosign` and `notsignal` mark prosigns
and the non-amateur distress signals. The `pruefung` flags and the
meanings were cross-checked against the BNetzA catalog — `pruefung`
means "this code's meaning is tested", not merely "the string appears
somewhere".
> **⚠ Important: AI-generated content.** `shorthand.json` and
> `technical.json` are compiled with AI assistance. As with the
> explanations and references, verify anything that looks off against a
> primary source (the catalog, the ARRL/DARC Q-code lists, or the
> resources under [See also](#see-also)) before relying on it.
## Exam question source ## Exam question source
@@ -68,6 +127,27 @@ BNetzA replaces the file in place across editions, so the URL is
stable; the fetcher detects updates via the HTTP `Last-Modified` stable; the fetcher detects updates via the HTTP `Last-Modified`
header. header.
## Exam aids (Hilfsmittel)
BNetzA publishes an official aid sheet that candidates **are allowed to
use during the exam** — you do not have to memorize anything it
contains:
- [`Hilfsmittel_12062024.pdf`](https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/AntraegeFormulare/Hilfsmittel_12062024.pdf?__blob=publicationFile&v=3)
It bundles the BNetzA frequency-allocation table (band limits, usage
parameters, and maximum power per class), the IARU band plans, the
German *Rufzeichenplan* (call-sign series → class plus the
international suffixes like `/m`, `/mm`, `/p`), and the *Formelsammlung*
(formula collection) together with constants and material tables. It
does **not** contain foreign-country prefixes (*Landeskenner*),
Q-codes, or the phonetic alphabet — those remain memory items.
Questions whose answer can be read or derived from this sheet carry a
**Hilfsmittel** note in their explanation (see below), so you can tell
when something is a lookup in the exam rather than something to learn
by heart.
## See also ## See also
- [**50ohm.de**](https://50ohm.de/) — community-maintained - [**50ohm.de**](https://50ohm.de/) — community-maintained
@@ -109,17 +189,53 @@ on this: questions without an entry just show no explanation block.
Entries with `confidence < 7` render a small "low confidence" badge Entries with `confidence < 7` render a small "low confidence" badge
so learners know the reasoning is provisional. so learners know the reasoning is provisional.
Where a question's answer is available in the official exam aid sheet
(see [Exam aids (Hilfsmittel)](#exam-aids-hilfsmittel)), the explanation
appends a short **Hilfsmittel** note — for example pointing at the
frequency-allocation table, the IARU band plan, the Rufzeichenplan, or
the Formelsammlung — to flag that it is a lookup you may consult during
the exam rather than a memory item.
`EXPLANATIONS.md` is the editorial contract: schema, sourcing `EXPLANATIONS.md` is the editorial contract: schema, sourcing
guidance, confidence scale, and the workflows an AI agent should guidance, confidence scale, and the workflows an AI agent should
follow when asked to add or improve entries. follow when asked to add or improve entries.
## References
> **⚠ Important: AI-generated content.** The files under `references/`
> are compiled by an AI agent from the exam catalog and public sources.
> AI agents make mistakes — they mis-transcribe figures, miscopy table
> boundaries, and confidently state things that are subtly wrong. **Do
> not treat these tables as authoritative.** Verify against the primary
> sources cited in each file (the official IARU Region 1 band plans, the
> AFuV/AFuG on
> [gesetze-im-internet.de](https://www.gesetze-im-internet.de/), or the
> BNetzA catalog itself) before relying on them.
The `references/` directory holds study aids that span many questions —
lookup tables cross-referenced to the catalog. They are reading material,
not part of the build, and are **not** bundled into the `.apkg` decks.
- `references/Frequencies.md` — IARU Region 1 band-plan frequency
recommendations (HF through microwave), cross-referenced to the exam
questions that test them.
- `references/Call-Signs.md` — call-sign patterns, suffixes, and country
prefixes that appear in the catalog.
- `references/Q-Codes.md` — Q-codes and operating shorthand used in the
questions. This reference is the catalogue the
[Q-group glossary deck](#glossary-decks) (`shorthand.json`) was built
from.
## More ## More
- `CLAUDE.md` — project orientation, pipeline overview. - `CLAUDE.md` — project orientation, pipeline overview (including the
two [glossary decks](#glossary-decks)).
- `DESIGN.md` — source-discovery notes, JSON schema, per-stage - `DESIGN.md` — source-discovery notes, JSON schema, per-stage
design contracts. design contracts.
- `EXPLANATIONS.md` — schema + workflows for the explanations - `EXPLANATIONS.md` — schema + workflows for the explanations
database. database.
- `shorthand.json` / `technical.json` — curated source data for the
two [glossary decks](#glossary-decks).
## License ## License
+348 -35
View File
@@ -33,6 +33,7 @@ import json
import os import os
import random import random
import re import re
import secrets
import shutil import shutil
import sqlite3 import sqlite3
import sys import sys
@@ -62,6 +63,17 @@ DEFAULT_EXPLANATIONS_PATH = Path("explanations.json")
# EXPLANATIONS.md for the full editorial contract. # EXPLANATIONS.md for the full editorial contract.
EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence") EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
# Optional, validated provenance marker. A `source` URL is only a
# citation; it does NOT establish that the explanation is a derivative
# work. `provenance` records *how the text was produced* so the CC BY
# derivative-work credit is shown only for records that genuinely are
# one. The single recognised value marks an explanation translated and
# condensed from a 50ohm.de worked solution
# (contents/solutions/<ID>.md in DARC-e-V/50ohm-contents-dl).
PROVENANCE_50OHM_SOLUTION = "50ohm-loesungsweg"
ALLOWED_PROVENANCE = frozenset({PROVENANCE_50OHM_SOLUTION})
OPTIONAL_EXPLANATION_FIELDS = ("provenance",)
# Confidence values strictly below this threshold are surfaced on # Confidence values strictly below this threshold are surfaced on
# the card as a "low confidence" badge — a learner-facing warning # the card as a "low confidence" badge — a learner-facing warning
# that the explanation may be incomplete or weakly sourced. Matches # that the explanation may be incomplete or weakly sourced. Matches
@@ -69,6 +81,49 @@ EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
# below 7 is review-worthy"). # below 7 is review-worthy").
LOW_CONFIDENCE_THRESHOLD = 7 LOW_CONFIDENCE_THRESHOLD = 7
# ---- Attribution (licensing) ----------------------------------------------
# Two sources are redistributed in these decks and each carries an
# attribution obligation:
# * the questions/figures come from the BNetzA question catalog under
# Datenlizenz Deutschland Namensnennung 2.0 (DL-DE→BY-2.0); and
# * the English explanations are translated and condensed from the
# worked solutions on 50ohm.de, which is CC BY 4.0 and requires both
# naming the author team and indicating that changes were made.
# DECK_ATTRIBUTION goes in every deck's description (the whole-deck
# notice); EXPLANATION_CC_BY_CREDIT is the per-card line appended to
# each 50ohm-derived explanation so the "modified" indication travels
# with the modified text itself.
EXPLANATION_CC_BY_CREDIT = (
"Explanation translated &amp; condensed from the worked solution on "
'<a href="https://50ohm.de/">50ohm.de</a> — '
"© 50ohm.de-Autorenteam (AJW-Referat, DARC e.&nbsp;V.), "
'<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. '
"Question © Bundesnetzagentur, "
'<a href="https://www.govdata.de/dl-de/by-2-0">DL-DE→BY-2.0</a>.'
)
DECK_ATTRIBUTION = (
"Amateurfunk-Lernkarten / amateur-radio exam flashcards.<br><br>"
"<b>Questions &amp; figures:</b> official question catalog "
"&quot;Prüfungsfragen zum Erwerb von "
"Amateurfunkprüfungsbescheinigungen&quot;, Bundesnetzagentur, "
"3. Auflage, März 2024 "
"(https://www.bundesnetzagentur.de/amateurfunk). Licensed under "
"Datenlizenz Deutschland Namensnennung Version 2.0 "
"(https://www.govdata.de/dl-de/by-2-0).<br><br>"
"<b>Explanations:</b> written for this project. Where an explanation "
"is marked as derived from a 50ohm.de worked solution "
"(&quot;Lösungsweg&quot;), that text is © 50ohm.de-Autorenteam, "
"koordiniert durch das AJW-Referat des DARC e. V., licensed under "
"CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), and has "
"been modified — translated from German and shortened. Other "
"explanations cite their own sources.<br><br>"
"<b>Exam-aid references</b> cite the BNetzA &quot;Hilfsmittel&quot; "
"formula sheet.<br><br>"
"This deck is an independent project and is not endorsed by the "
"Bundesnetzagentur or by the DARC e. V."
)
# Exit codes. The builder is much simpler than the fetcher — there is # Exit codes. The builder is much simpler than the fetcher — there is
# no "operator must resolve local state" case here, so two codes are # no "operator must resolve local state" case here, so two codes are
# enough. # enough.
@@ -102,6 +157,31 @@ CLASS_ORDER = [("1", "N"), ("2", "E"), ("3", "A")]
# shared across all candidates and stay as a single deck each. # shared across all candidates and stay as a single deck each.
TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse" TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
# License classes whose Technische package is built with one sub-deck
# per first-level catalog topic (the 11 subsections under the
# Prüfungsteil) instead of a single flat deck. The E (463) and A (716)
# pools are large enough that a single deck is unwieldy; N (195) stays
# a single flat deck. Still one `.apkg` per class — the sub-decks live
# inside the package as a deck tree.
TOPIC_SPLIT_CLASSES = frozenset({"E", "A"})
# The catalog's figures come in two kinds: vector SVGs (the bulk; mostly
# small — well under ~200 px) and a handful of raster PNGs (all 1024×685). We
# enlarge only the SVGs: they scale crisply, they're the ones that read tiny
# on a wide desktop card, and the PNGs are already at roughly the size we're
# aiming for. The enlargement is `MEDIA_SCALE`× the native size, with
# `MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` acting as an *enlargement ceiling* —
# it bounds how far a below-cap figure may grow (a figure already at/above a
# cap is left at native size; we never shrink). The ceiling is the PNGs'
# ballpark (1024×768) so the largest SVG schematics (up to 635×481 px in the
# 3rd edition: the NG20x block diagrams, EB309/EB310) grow to a comparable
# size rather than staying cramped, while folding in native *height* still
# stops tall-but-narrow figures from growing off the bottom of the card.
# See `MediaRegistry.image_html`.
MEDIA_SCALE = 2
MEDIA_MAX_WIDTH = 1024
MEDIA_MAX_HEIGHT = 768
# Fallback build epoch if neither the manifest nor `--epoch` supplies # Fallback build epoch if neither the manifest nor `--epoch` supplies
# one. Picked as 0 so missing-metadata builds are still deterministic # one. Picked as 0 so missing-metadata builds are still deterministic
# and obviously wrong (timestamps would all show 1970). # and obviously wrong (timestamps would all show 1970).
@@ -144,13 +224,17 @@ class Category:
`title` is the original German title (with the Prüfungsteil `title` is the original German title (with the Prüfungsteil
prefix); `short_title` has the prefix stripped (used in the deck prefix); `short_title` has the prefix stripped (used in the deck
name and the slug); `questions` is the flat list of every question name and the slug); `questions` is the flat list of every question
that lives anywhere under this category. that lives anywhere under this category. When `subdeck_by_topic`
is set, the package is built with one sub-deck per first-level
catalog topic instead of a single flat deck (see
`build_apkg_for_category`).
""" """
title: str title: str
short_title: str short_title: str
slug: str slug: str
questions: list questions: list
subdeck_by_topic: bool = False
# ============================================================================ # ============================================================================
@@ -204,7 +288,7 @@ def load_explanations(path):
{ {
"NA101": { "NA101": {
"revision": 1, "revision": 1,
"explanation": "Terse English text explaining why ...", "explanation": "English text explaining why ...",
"source": "https://... or AFuV §16(2)", "source": "https://... or AFuV §16(2)",
"confidence": 7 "confidence": 7
}, },
@@ -233,12 +317,15 @@ def load_explanations(path):
def _validate_explanation(key, value): def _validate_explanation(key, value):
"""Raise `AnkiBuildError` if one explanation entry is malformed. """Raise `AnkiBuildError` if one explanation entry is malformed.
Strict shape check: the four documented fields are required, no Strict shape check: the four documented fields are required and the
extras allowed, and integer fields reject `bool` (which would optional `provenance` field is allowed (and, if present, must be a
pass `isinstance(..., int)` because `bool` subclasses `int` in string from `ALLOWED_PROVENANCE`); any *other* extra field is
Python). Extras are rejected so a stray `"note"` or `"author"` rejected, so a stray `"note"` or `"author"` field can't accumulate
field can't accumulate silently and drift from the documented silently and drift from the documented schema in EXPLANATIONS.md.
schema in EXPLANATIONS.md. Integer fields reject `bool` (which would pass `isinstance(..., int)`
because `bool` subclasses `int` in Python), and every malformed
value — including an unhashable `provenance` — surfaces as an
`AnkiBuildError`, never a raw `TypeError`.
""" """
if not isinstance(value, dict): if not isinstance(value, dict):
raise AnkiBuildError( raise AnkiBuildError(
@@ -249,11 +336,24 @@ def _validate_explanation(key, value):
raise AnkiBuildError( raise AnkiBuildError(
f"explanation {key!r} missing required fields: {missing}" f"explanation {key!r} missing required fields: {missing}"
) )
extra = sorted(set(value) - set(EXPLANATION_FIELDS)) extra = sorted(
set(value) - set(EXPLANATION_FIELDS) - set(OPTIONAL_EXPLANATION_FIELDS)
)
if extra: if extra:
raise AnkiBuildError( raise AnkiBuildError(
f"explanation {key!r}: unknown fields {extra}" f"explanation {key!r}: unknown fields {extra}"
) )
if "provenance" in value and (
not isinstance(value["provenance"], str)
or value["provenance"] not in ALLOWED_PROVENANCE
):
# The isinstance check must come first: an unhashable JSON value
# (a list or object) would make the `not in` set test raise a raw
# TypeError instead of the documented AnkiBuildError.
raise AnkiBuildError(
f"explanation {key!r}: provenance must be one of "
f"{sorted(ALLOWED_PROVENANCE)}"
)
if type(value["revision"]) is not int or value["revision"] < 1: if type(value["revision"]) is not int or value["revision"] < 1:
raise AnkiBuildError( raise AnkiBuildError(
f"explanation {key!r}: revision must be a positive integer" f"explanation {key!r}: revision must be a positive integer"
@@ -354,7 +454,10 @@ def _split_by_class(title, short_title, questions):
The short title uses Anki's `::` deck-hierarchy separator so the The short title uses Anki's `::` deck-hierarchy separator so the
three decks render as children of a shared parent in Anki's deck three decks render as children of a shared parent in Anki's deck
browser (e.g. `Amateurfunk::Technische Kenntnisse::N`). browser (e.g. `Amateurfunk::Technische Kenntnisse::N`). The classes
in `TOPIC_SPLIT_CLASSES` (E, A) stay a single package each but are
built with one sub-deck per first-level topic — see
`build_apkg_for_category` and the `subdeck_by_topic` flag.
""" """
base_slug = slugify(short_title) base_slug = slugify(short_title)
for digit, letter in CLASS_ORDER: for digit, letter in CLASS_ORDER:
@@ -367,6 +470,7 @@ def _split_by_class(title, short_title, questions):
short_title=f"{short_title}::{letter}", short_title=f"{short_title}::{letter}",
slug=f"{base_slug}-{letter.lower()}", slug=f"{base_slug}-{letter.lower()}",
questions=subset, questions=subset,
subdeck_by_topic=(letter in TOPIC_SPLIT_CLASSES),
) )
@@ -602,11 +706,23 @@ def render_explanation(explanation):
'title="This explanation is weakly sourced or incomplete">' 'title="This explanation is weakly sourced or incomplete">'
'low confidence</span>' 'low confidence</span>'
) )
# CC BY 4.0 requires naming the author and indicating modifications.
# Only explanations actually derived from a 50ohm.de worked solution
# carry this credit — gated on the explicit `provenance` marker, NOT
# on the `source` URL (a citation is not provenance; many records
# merely cite a 50ohm study page without being a derivative work).
credit = ""
if explanation.get("provenance") == PROVENANCE_50OHM_SOLUTION:
credit = (
'<div class="af-explanation-credit">'
f'{EXPLANATION_CC_BY_CREDIT}</div>'
)
return ( return (
'<div class="af-explanation">' '<div class="af-explanation">'
f'<div class="af-explanation-header">Explanation{badge}</div>' f'<div class="af-explanation-header">Explanation{badge}</div>'
f'<div class="af-explanation-body">{body}</div>' f'<div class="af-explanation-body">{body}</div>'
f'<div class="af-explanation-source">Source: {source_html}</div>' f'<div class="af-explanation-source">Source: {source_html}</div>'
f'{credit}'
'</div>' '</div>'
) )
@@ -738,6 +854,8 @@ class MediaRegistry:
self.used_paths = set() self.used_paths = set()
# references that didn't resolve (recorded for diagnostics) # references that didn't resolve (recorded for diagnostics)
self.missing = [] self.missing = []
# Path → intrinsic (w, h) in px (memoised; None when unreadable)
self._size_cache = {}
self._index_media_dir() self._index_media_dir()
def _index_media_dir(self): def _index_media_dir(self):
@@ -779,12 +897,43 @@ class MediaRegistry:
return None return None
def image_html(self, reference): def image_html(self, reference):
"""Return an `<img>` tag for `reference`, or `""` if unresolved.""" """Return an `<img>` tag for `reference`, or `""` if unresolved.
Most catalog SVGs carry small intrinsic dimensions, so on a wide
desktop card they render barely legible (a small fixed-px image
occupies only a sliver of the column; `max-width: 100%` only
ever shrinks, never enlarges). We raise the *preferred* width
via an explicit `width` attribute so the figure grows on every
viewport until the card's `max-width: 100%` cap engages.
Only SVGs are scaled — the catalog's PNGs are already large
(1024×685), so `_intrinsic_svg_size` returns `None` for them and
they fall through to native size. The enlargement factor is
`scale_for_size` — `MEDIA_SCALE`×, clamped at the
`MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` enlargement ceiling.
`height: auto` in the CSS keeps the aspect ratio. When the size
can't be read (non-SVG, or an unparseable SVG) we fall back to
native.
"""
path = self.resolve(reference) path = self.resolve(reference)
if path is None: if path is None:
return "" return ""
filename = html.escape(path.name, quote=True) filename = html.escape(path.name, quote=True)
return f'<img src="{filename}" class="af-media">' size = self._intrinsic_svg_size(path)
if size is None:
return f'<img src="{filename}" class="af-media">'
width, height = size
scale = scale_for_size(width, height)
if scale <= 1.0:
return f'<img src="{filename}" class="af-media">'
scaled = round(width * scale)
return f'<img src="{filename}" class="af-media" width="{scaled}">'
def _intrinsic_svg_size(self, path):
"""Return the SVG's `(width, height)` in px (memoised; SVG only)."""
if path not in self._size_cache:
self._size_cache[path] = intrinsic_svg_size(path)
return self._size_cache[path]
# ============================================================================ # ============================================================================
@@ -842,8 +991,12 @@ def build_apkg_for_category(
media = MediaRegistry(edition_dir / "svgs") media = MediaRegistry(edition_dir / "svgs")
deck_name = f"Amateurfunk::{category.short_title}" # The category's own deck is always present (even empty, it anchors
deck_id = stable_id("deck", deck_name) # the tree). Sub-decks discovered per note are added on first use,
# in catalog order, so the package's deck list is deterministic.
root_deck_name = f"Amateurfunk::{category.short_title}"
root_deck_id = stable_id("deck", root_deck_name)
decks = {root_deck_name: root_deck_id}
model_id = stable_id("model", "Amateurfunk Multiple Choice") model_id = stable_id("model", "Amateurfunk Multiple Choice")
try: try:
@@ -857,11 +1010,14 @@ def build_apkg_for_category(
number = str(question.get("number", f"q{ordinal}")) number = str(question.get("number", f"q{ordinal}"))
if number in explanations: if number in explanations:
applied_explanations += 1 applied_explanations += 1
deck_short = _deck_short_title_for_item(category, item)
deck_name = f"Amateurfunk::{deck_short}"
deck_id = decks.setdefault(deck_name, stable_id("deck", deck_name))
note_id = stable_id("note", f"{category.slug}:{number}") note_id = stable_id("note", f"{category.slug}:{number}")
card_id = stable_id("card", f"{category.slug}:{number}") card_id = stable_id("card", f"{category.slug}:{number}")
fields = [ fields = [
number, number,
category.short_title, deck_short,
display_path(item.path), display_path(item.path),
front, front,
back, back,
@@ -870,6 +1026,7 @@ def build_apkg_for_category(
notes.append({ notes.append({
"note_id": note_id, "note_id": note_id,
"card_id": card_id, "card_id": card_id,
"deck_id": deck_id,
"guid": stable_guid(f"{category.slug}:{number}"), "guid": stable_guid(f"{category.slug}:{number}"),
"fields": FIELD_SEP.join(fields), "fields": FIELD_SEP.join(fields),
"sort": number, "sort": number,
@@ -881,8 +1038,8 @@ def build_apkg_for_category(
create_collection_db( create_collection_db(
db_path, db_path,
deck_id=deck_id, decks=decks,
deck_name=deck_name, cur_deck_id=root_deck_id,
model_id=model_id, model_id=model_id,
notes=notes, notes=notes,
now=build_epoch, now=build_epoch,
@@ -895,7 +1052,8 @@ def build_apkg_for_category(
return { return {
"path": out_path, "path": out_path,
"deck": deck_name, "deck": root_deck_name,
"subdecks": len(decks) - 1,
"questions": len(category.questions), "questions": len(category.questions),
"media": len(media_paths), "media": len(media_paths),
"missing_media": sorted(set(media.missing)), "missing_media": sorted(set(media.missing)),
@@ -903,6 +1061,21 @@ def build_apkg_for_category(
} }
def _deck_short_title_for_item(category, item):
"""Return the `::`-joined deck short title a card lands in.
Normally the category's own short title. When the category has
`subdeck_by_topic` set, the first-level catalog topic (the section
title just below the Prüfungsteil, `item.path[1]`) is appended so
the card lands in a sub-deck such as
`Technische Kenntnisse::A::Sender und Empfänger`. Items with no
topic level fall back to the category deck so nothing is dropped.
"""
if category.subdeck_by_topic and len(item.path) > 1:
return f"{category.short_title}::{item.path[1]}"
return category.short_title
def write_apkg(out_path, db_path, media_paths, build_epoch): def write_apkg(out_path, db_path, media_paths, build_epoch):
"""Write the Anki package ZIP atomically. """Write the Anki package ZIP atomically.
@@ -987,6 +1160,74 @@ def media_bytes_for_package(path):
return svg_with_white_background(text).encode("utf-8") return svg_with_white_background(text).encode("utf-8")
def scale_for_size(width, height):
"""Return the display scale for a figure of native `width`×`height`.
`MEDIA_SCALE`× for legibility, but never so much that the enlarged
figure would grow past the `MEDIA_MAX_WIDTH` / `MEDIA_MAX_HEIGHT`
ceiling, and never below 1.0 (we only ever enlarge, never shrink —
shrinking is the CSS `max-width`'s job). A figure already at/above
either cap yields exactly 1.0, i.e. "render at native size" — so a
native figure larger than the ceiling is left as-is, not shrunk.
"""
if width <= 0 or height <= 0:
return 1.0
scale = min(
MEDIA_SCALE,
MEDIA_MAX_WIDTH / width,
MEDIA_MAX_HEIGHT / height,
)
return max(scale, 1.0)
def _svg_dimension(svg_tag, name):
"""Parse a `width`/`height` length (px or unitless) off an `<svg>` tag.
The numeric pattern is a well-formed decimal (`12`, `12.7`, `.5`) so
malformed values like `1.2.3` or a lone `.` simply don't match and
yield `None` rather than reaching `float()`. We still guard the
conversion with `try/except` so no parse can ever abort the build.
"""
attr = re.search(
rf'\b{name}\s*=\s*"(\d+(?:\.\d+)?|\.\d+)\s*(?:px)?"',
svg_tag,
flags=re.IGNORECASE,
)
if not attr:
return None
try:
return float(attr.group(1))
except ValueError:
return None
def intrinsic_svg_size(path):
"""Return an SVG's intrinsic `(width, height)` in CSS px, or `None`.
Read from the `width`/`height` attributes on the root `<svg>` tag
(the catalog's SVGs use unitless user units, which map 1:1 to px).
Only `.svg` files are handled — we deliberately don't size raster
figures, because the catalog's PNGs are already large and only the
SVGs need enlarging. Anything we can't parse confidently — a non-SVG
file, a percentage dimension, a missing attribute — returns `None`
so the caller falls back to the image's native size.
"""
if path.suffix.lower() != ".svg":
return None
try:
text = path.read_bytes().decode("utf-8-sig", errors="replace")
except OSError:
return None
match = re.search(r"<svg\b[^>]*>", text, flags=re.IGNORECASE)
if not match:
return None
width = _svg_dimension(match.group(0), "width")
height = _svg_dimension(match.group(0), "height")
if width is None or height is None:
return None
return (width, height)
def svg_with_white_background(svg_text): def svg_with_white_background(svg_text):
"""Inject a white background `<rect>` after the opening `<svg>`. """Inject a white background `<rect>` after the opening `<svg>`.
@@ -1012,21 +1253,27 @@ def svg_with_white_background(svg_text):
# ============================================================================ # ============================================================================
def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now): def create_collection_db(db_path, decks, cur_deck_id, model_id, notes, now):
"""Create the `collection.anki2` SQLite database for one package. """Create the `collection.anki2` SQLite database for one package.
Uses the v11 schema, which modern Anki still understands (it Uses the v11 schema, which modern Anki still understands (it
upgrades the collection on first open). We pre-write a single upgrades the collection on first open). We pre-write a single
`col` row with the JSON config blobs, then insert one `notes` `col` row with the JSON config blobs, then insert one `notes`
row and one `cards` row per question. row and one `cards` row per question.
`decks` maps every deck name in this package to its id; a package
is usually a single deck, but the per-topic Technische package
carries one entry per sub-deck plus the anchoring parent.
`cur_deck_id` is the deck selected on open. Each note carries its
own `deck_id`, so cards land in the right sub-deck.
""" """
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
try: try:
create_schema(conn) create_schema(conn)
insert_collection_metadata( insert_collection_metadata(
conn, conn,
deck_id=deck_id, decks=decks,
deck_name=deck_name, cur_deck_id=cur_deck_id,
model_id=model_id, model_id=model_id,
now=now, now=now,
) )
@@ -1061,7 +1308,7 @@ def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now):
( (
note["card_id"], note["card_id"],
note["note_id"], note["note_id"],
deck_id, note["deck_id"],
0, 0,
now, now,
-1, -1,
@@ -1162,12 +1409,26 @@ def create_schema(conn):
) )
def insert_collection_metadata(conn, deck_id, deck_name, model_id, now): def insert_collection_metadata(conn, decks, cur_deck_id, model_id, now):
"""Write the single `col` row that carries the JSON config blobs. """Write the single `col` row that carries the JSON config blobs.
`crt` is a seconds-epoch creation time; `mod` and `scm` are in `crt` is a seconds-epoch creation time; `mod` and `scm` are in
milliseconds (Anki's mixed convention, not ours to fix). milliseconds (Anki's mixed convention, not ours to fix). `decks`
maps deck name → id; every entry is written to `col.decks` so a
package can ship a deck tree, not just one deck. `cur_deck_id` is
the deck made current in `col.conf`.
""" """
# The attribution notice goes on the root (current) deck only, so it
# shows once for the package rather than on every sub-deck.
decks_json = {
str(deck_id): deck_json(
deck_id,
deck_name,
now,
desc=DECK_ATTRIBUTION if deck_id == cur_deck_id else "",
)
for deck_name, deck_id in decks.items()
}
conn.execute( conn.execute(
""" """
INSERT INTO col INSERT INTO col
@@ -1183,15 +1444,12 @@ def insert_collection_metadata(conn, deck_id, deck_name, model_id, now):
0, 0,
0, 0,
0, 0,
json.dumps(collection_conf(deck_id), separators=(",", ":")), json.dumps(collection_conf(cur_deck_id), separators=(",", ":")),
json.dumps( json.dumps(
{str(model_id): model_json(model_id, now)}, {str(model_id): model_json(model_id, now)},
separators=(",", ":"), separators=(",", ":"),
), ),
json.dumps( json.dumps(decks_json, separators=(",", ":")),
{str(deck_id): deck_json(deck_id, deck_name, now)},
separators=(",", ":"),
),
json.dumps(default_deck_conf(now), separators=(",", ":")), json.dumps(default_deck_conf(now), separators=(",", ":")),
"{}", "{}",
), ),
@@ -1227,11 +1485,14 @@ def collection_conf(deck_id):
} }
def deck_json(deck_id, deck_name, now): def deck_json(deck_id, deck_name, now, desc=""):
"""Return one deck entry for `col.decks`. """Return one deck entry for `col.decks`.
The `Amateurfunk::Foo` naming gives the user a single top-level The `Amateurfunk::Foo` naming gives the user a single top-level
Amateurfunk node in Anki's deck browser with three child decks. Amateurfunk node in Anki's deck browser with three child decks.
`desc` is the deck description shown in Anki's deck overview; we use
it on the root deck to carry the source-attribution notice (see
DECK_ATTRIBUTION).
""" """
return { return {
"id": deck_id, "id": deck_id,
@@ -1246,7 +1507,7 @@ def deck_json(deck_id, deck_name, now):
"browserCollapsed": False, "browserCollapsed": False,
"dyn": 0, "dyn": 0,
"conf": 1, "conf": 1,
"desc": "", "desc": desc,
"extendNew": 0, "extendNew": 0,
"extendRev": 0, "extendRev": 0,
} }
@@ -1415,7 +1676,7 @@ CARD_CSS = """
font-size: 15px; font-size: 15px;
line-height: 1.5; line-height: 1.5;
color: #333; color: #333;
font-style: italic; font-style: normal;
} }
.af-explanation-header { .af-explanation-header {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -1451,6 +1712,47 @@ CARD_CSS = """
color: #1a73e8; color: #1a73e8;
text-decoration: none; text-decoration: none;
} }
.af-explanation-credit {
font-family: Arial, sans-serif;
font-style: normal;
font-size: 11px;
color: #999;
margin-top: 0.35rem;
}
.af-explanation-credit a {
color: #1a73e8;
text-decoration: none;
}
.nightMode .af-explanation,
.card.nightMode .af-explanation {
border-top-color: #555;
color: #ddd;
}
.nightMode .af-explanation-header,
.card.nightMode .af-explanation-header {
color: #aaa;
}
.nightMode .af-explanation-source,
.card.nightMode .af-explanation-source {
color: #bbb;
}
.nightMode .af-explanation-source a,
.card.nightMode .af-explanation-source a {
color: #8ab4f8;
}
.nightMode .af-explanation-credit,
.card.nightMode .af-explanation-credit {
color: #999;
}
.nightMode .af-explanation-credit a,
.card.nightMode .af-explanation-credit a {
color: #8ab4f8;
}
.nightMode .af-explanation-low-confidence,
.card.nightMode .af-explanation-low-confidence {
background: #4a3510;
color: #ffd77a;
}
""" """
@@ -1481,6 +1783,13 @@ def build_epoch_from_manifest(manifest, override_epoch=None):
) from e ) from e
def resolve_shuffle_seed(seed):
"""Return the explicit shuffle seed or a fresh one for this build."""
if seed is not None:
return seed
return secrets.token_hex(16)
def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=None): def build_all(data_dir, out_dir, seed, override_epoch=None, explanations_path=None):
"""Build every category's `.apkg` and return their result dicts. """Build every category's `.apkg` and return their result dicts.
@@ -1546,8 +1855,11 @@ def _parse_args(argv):
) )
parser.add_argument( parser.add_argument(
"--seed", "--seed",
default="amateurfunk-anki-v1", default=None,
help="deterministic seed for answer shuffling", help=(
"deterministic seed for answer shuffling; omit to reshuffle "
"answers on every build"
),
) )
parser.add_argument( parser.add_argument(
"--epoch", "--epoch",
@@ -1573,11 +1885,12 @@ def _parse_args(argv):
def main(argv=None): def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises.""" """Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv) args = _parse_args(argv)
seed = resolve_shuffle_seed(args.seed)
try: try:
results = build_all( results = build_all(
args.data, args.data,
args.out, args.out,
seed=args.seed, seed=seed,
override_epoch=args.epoch, override_epoch=args.epoch,
explanations_path=args.explanations, explanations_path=args.explanations,
) )
+747
View File
@@ -0,0 +1,747 @@
#!/usr/bin/env python3
"""Build an Anki deck of Q-groups and operating abbreviations.
This is a sibling of `amateurfunk_anki.py`. Where that script turns the
BNetzA question catalog into multiple-choice decks, this one builds a
single reference deck from a hand-curated database (`shorthand.json`):
the Q-groups and operating abbreviations a candidate meets in the exam,
plus the most common ones used on the air that the exam never mentions
(so the deck teaches real operating knowledge, not just the test).
Design follows the four rules the deck was specified with:
1. Stable IDs. Every deck/model/note/card id and note GUID is hashed
from the displayed code form, so re-importing updates in place
instead of duplicating.
2. Two cards per code. Each note carries two templates — one prompts
for the meaning given the code, the reverse prompts for the code
given the meaning. Modelling them as a single *note* (rule 4) is
what lets one record drive both directions without us emitting two
hand-built cards.
3. Q-groups get one note per usage. A Q-group means one thing as a
statement (`QSO`) and another as a question (`QSO?`), so each
yields two notes — and therefore two forward/reverse pairs.
Plain abbreviations have only one form and yield one note.
Everything is stdlib only. The heavy lifting (SQLite schema, the apkg
ZIP writer, the deterministic timestamps, the ID hashing) is shared
with `amateurfunk_anki` by import, so the two stay in lockstep.
"""
import argparse
import datetime as dt
import html
import json
import re
import shutil
import sqlite3
import sys
from pathlib import Path
# Reuse the low-level Anki package machinery rather than copying it.
# These helpers are pure and model-agnostic; only the note type, the
# card templates, and the card-per-note fan-out are specific to this
# deck and are defined below.
from amateurfunk_anki import (
AnkiBuildError,
DEFAULT_BUILD_EPOCH,
FIELD_SEP,
build_epoch_from_manifest,
checksum_sort_field,
collection_conf,
create_schema,
deck_json,
default_deck_conf,
field_json,
load_latest_catalog,
slugify,
stable_guid,
stable_id,
text_html,
write_apkg,
)
# ============================================================================
# Constants
# ============================================================================
# Curated source database. Editorial content tracked in git, not a
# build artifact — same status as `explanations.json` in the sibling
# script.
DEFAULT_SHORTHAND_PATH = Path("shorthand.json")
# Default location of the fetcher's output. Only consulted to borrow a
# deterministic build epoch from the catalog manifest; the deck content
# itself does not depend on the catalog, so a missing data dir is not
# fatal here (unlike in `amateurfunk_anki`).
DEFAULT_DATA_DIR = Path("data")
# Default destination for the generated `.apkg` (shared with the
# multiple-choice decks).
DEFAULT_OUT_DIR = Path("anki")
# Human-facing deck name. The `Amateurfunk::` prefix nests it under the
# same top-level node as the catalog decks in Anki's browser.
DECK_NAME = "Amateurfunk::Abkürzungen & Q-Gruppen"
# Note-type name. Distinct from the multiple-choice model so importing
# both decks never merges the two note types.
MODEL_NAME = "Amateurfunk Q-Gruppe / Abkürzung"
# User-facing kind labels, shown on the card and used to build the
# `q-gruppe` / `abkuerzung` filter tags.
KIND_QGROUP = "Q-Gruppe"
KIND_ABBREV = "Abkürzung"
# Usage-form labels for Q-groups. A Q-group statement (`QSO`) and the
# matching question (`QSO?`) become two separate notes (rule 3).
FORM_STATEMENT = "Aussageform"
FORM_QUESTION = "Frageform"
# Exit codes (mirrors the sibling script's two-state convention).
EXIT_OK = 0
EXIT_ERROR = 1
# Fields allowed on a Q-group entry / an abbreviation entry in
# `shorthand.json`. Extras are rejected so a typo can't silently shift
# meaning onto a card that never renders it.
QGROUP_REQUIRED = ("code", "question", "statement")
QGROUP_OPTIONAL = ("exam", "explanation", "example", "tags")
ABBREV_REQUIRED = ("code", "meaning")
ABBREV_OPTIONAL = ("exam", "explanation", "example", "tags")
# Inline `` `code` `` spans in the curated examples become <code> tags.
BACKTICK_SPAN_RE = re.compile(r"`([^`]+)`")
# ============================================================================
# Loading and validating the curated database
# ============================================================================
def load_shorthand(path):
"""Return the validated `(q_codes, abbreviations)` from a JSON file.
The file is editorial content with a strict shape (see the field
allow-lists above). Unlike the explanations database in the sibling
script, this file is mandatory: there is no deck without it, so a
missing or malformed file is a hard error.
"""
try:
raw = json.loads(path.read_text("utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise AnkiBuildError(
f"could not read shorthand file {path}: {e}"
) from e
if not isinstance(raw, dict):
raise AnkiBuildError(
f"shorthand file {path} must contain a JSON object at the top level"
)
q_codes = raw.get("q_codes", [])
abbreviations = raw.get("abbreviations", [])
if not isinstance(q_codes, list) or not isinstance(abbreviations, list):
raise AnkiBuildError(
f"shorthand file {path}: 'q_codes' and 'abbreviations' must be lists"
)
for entry in q_codes:
validate_entry(entry, QGROUP_REQUIRED, QGROUP_OPTIONAL, "q_codes")
for entry in abbreviations:
validate_entry(entry, ABBREV_REQUIRED, ABBREV_OPTIONAL, "abbreviations")
_check_no_duplicate_codes(q_codes, abbreviations)
return q_codes, abbreviations
def validate_entry(entry, required, optional, where):
"""Raise `AnkiBuildError` if one database entry is malformed."""
if not isinstance(entry, dict):
raise AnkiBuildError(f"{where}: every entry must be a JSON object")
label = entry.get("code", "<no code>")
missing = [f for f in required if f not in entry]
if missing:
raise AnkiBuildError(
f"{where} entry {label!r} missing required fields: {missing}"
)
extra = sorted(set(entry) - set(required) - set(optional))
if extra:
raise AnkiBuildError(f"{where} entry {label!r}: unknown fields {extra}")
for field in required + ("explanation", "example"):
if field in entry and (
not isinstance(entry[field], str) or not entry[field].strip()
):
raise AnkiBuildError(
f"{where} entry {label!r}: {field!r} must be a non-empty string"
)
if "exam" in entry and not isinstance(entry["exam"], bool):
raise AnkiBuildError(
f"{where} entry {label!r}: 'exam' must be a boolean"
)
if "tags" in entry:
tags = entry["tags"]
if not isinstance(tags, list) or not all(
isinstance(t, str) and t.strip() for t in tags
):
raise AnkiBuildError(
f"{where} entry {label!r}: 'tags' must be a list of non-empty strings"
)
def _check_no_duplicate_codes(q_codes, abbreviations):
"""Raise if any displayed code form would collide.
Displayed forms are the per-note keys that every stable ID and GUID
is hashed from, so a duplicate would silently overwrite another
note on import. Q-groups expand to `CODE` and `CODE?`; abbreviations
stay as `CODE`.
"""
seen = {}
forms = [entry["code"] for entry in q_codes]
forms += [entry["code"] + "?" for entry in q_codes]
forms += [entry["code"] for entry in abbreviations]
duplicates = sorted({form for form in forms if forms.count(form) > 1})
if duplicates:
raise AnkiBuildError(
"duplicate code form(s) in shorthand database: "
+ ", ".join(duplicates)
)
return seen
# ============================================================================
# Note expansion
# ============================================================================
def build_notes(q_codes, abbreviations):
"""Expand the curated entries into the flat list of notes to emit.
Each Q-group becomes two notes (statement form `QSO`, question form
`QSO?`); each abbreviation becomes one. Order is stable: Q-groups in
file order first (statement then question for each), abbreviations
after, so the deck's `due` ordering is deterministic.
"""
notes = []
for entry in q_codes:
notes.append(
_note_record(
code_display=entry["code"],
meaning=entry["statement"],
kind=KIND_QGROUP,
form=FORM_STATEMENT,
entry=entry,
)
)
notes.append(
_note_record(
code_display=entry["code"] + "?",
meaning=entry["question"],
kind=KIND_QGROUP,
form=FORM_QUESTION,
entry=entry,
)
)
for entry in abbreviations:
notes.append(
_note_record(
code_display=entry["code"],
meaning=entry["meaning"],
kind=KIND_ABBREV,
form="",
entry=entry,
)
)
return notes
def make_note(code_display, meaning, kind, form, explanation, example, tags, namespace):
"""Render one two-card note (forward + reverse) for a glossary deck.
Shared by every glossary-style deck (operating shorthand here, the
technical-abbreviation deck in `amateurfunk_technical.py`). All
identity (`note_id`, `guid`, and the per-template card ids) is
hashed from `code_display` under `namespace`, so the two decks get
independent, stable IDs that never collide on import. The fields are
stored pre-escaped; the card templates only lay them out.
"""
fields = [
html.escape(code_display),
text_html(meaning),
kind,
form,
text_html(explanation) if explanation else "",
render_example(example) if example else "",
]
return {
"note_id": stable_id(f"{namespace}-note", code_display),
"guid": stable_guid(f"{namespace}:{code_display}"),
"fields": FIELD_SEP.join(fields),
"sort": code_display,
"tags": tags,
"card_ids": [
stable_id(f"{namespace}-card", f"{code_display}:{ord_}")
for ord_ in range(2)
],
}
def _note_record(code_display, meaning, kind, form, entry):
"""Build one operating-deck note under the `shorthand` ID namespace.
`code_display` is unique across the database (enforced by
`_check_no_duplicate_codes`), so it's a safe identity key.
"""
return make_note(
code_display,
meaning,
kind,
form,
entry.get("explanation"),
entry.get("example"),
tags_for_entry(kind, entry),
namespace="shorthand",
)
def render_example(example):
"""Render an example string: HTML-escape, then `code` → <code>.
The curated examples use Markdown-style backticks around the bits
that are literally keyed on the air (`QRL?`, `de DL1ABC k`). We
escape first via `text_html` (so the surrounding prose is safe and
newlines survive) and only then turn backtick spans into <code>.
"""
return BACKTICK_SPAN_RE.sub(
lambda m: f"<code>{m.group(1)}</code>", text_html(example)
)
def tags_for_entry(kind, entry):
"""Return the Anki tags field (space-padded) for one note.
A `q-gruppe`/`abkuerzung` kind tag, a `pruefung` tag when the code
appears in the exam catalog, and any extra editorial tags (e.g.
`prosign`, `notsignal`) carried on the entry.
"""
tags = ["q-gruppe" if kind == KIND_QGROUP else "abkuerzung"]
if entry.get("exam"):
tags.append("pruefung")
tags.extend(slugify(tag) for tag in entry.get("tags", []))
return " " + " ".join(tags) + " "
# ============================================================================
# Anki note type (two templates: forward + reverse)
# ============================================================================
def model_json(model_id, model_name, now):
"""Return the note-type entry for `col.models`.
Five fields and two templates. `Bedeutung` prompts for the meaning
given the code; `Kürzel` is the reverse. Modelling both directions
on one note type (rather than two single-template notes) is what
rule 4 asks for: one record, two cards. `req` records which field
each template needs so Anki never generates an empty card. The
technical deck reuses this same shape under its own `model_name`.
"""
fields = [
field_json("Code", 0),
field_json("Meaning", 1),
field_json("Kind", 2),
field_json("Form", 3),
field_json("Explanation", 4),
field_json("Example", 5),
]
extras = (
"{{#Explanation}}<div class=\"af-sh-explanation\">"
"{{Explanation}}</div>{{/Explanation}}\n"
"{{#Example}}<div class=\"af-sh-example\">{{Example}}</div>{{/Example}}"
)
front_meta = (
'<div class="af-sh-kind">{{Kind}}{{#Form}} · {{Form}}{{/Form}}</div>'
)
return {
"id": model_id,
"name": model_name,
"type": 0,
"mod": now,
"usn": -1,
"sortf": 0,
"did": None,
"tmpls": [
{
"name": "Bedeutung",
"ord": 0,
"qfmt": (
'<div class="af-sh">' + front_meta
+ '<div class="af-sh-code">{{Code}}</div>'
+ '<div class="af-sh-prompt">Bedeutung?</div></div>'
),
"afmt": (
"{{FrontSide}}\n<hr id=answer>\n"
'<div class="af-sh-meaning">{{Meaning}}</div>\n' + extras
),
"did": None,
"bqfmt": "",
"bafmt": "",
},
{
"name": "Kürzel",
"ord": 1,
"qfmt": (
'<div class="af-sh">' + front_meta
+ '<div class="af-sh-meaning">{{Meaning}}</div>'
+ '<div class="af-sh-prompt">Kürzel?</div></div>'
),
"afmt": (
"{{FrontSide}}\n<hr id=answer>\n"
'<div class="af-sh-code">{{Code}}</div>\n' + extras
),
"did": None,
"bqfmt": "",
"bafmt": "",
},
],
"flds": fields,
"css": CARD_CSS,
"latexPre": "",
"latexPost": "",
# Template 0 needs Code (field 0); template 1 needs Meaning (1).
"req": [[0, "all", [0]], [1, "all", [1]]],
"vers": [],
}
# ============================================================================
# SQLite collection writer (two cards per note)
# ============================================================================
def create_collection_db(db_path, deck_id, deck_name, model_id, model_name, notes, now):
"""Create the `collection.anki2` database for a glossary deck.
Same v11 schema as the sibling script, but each note fans out into
two cards (one per template `ord`). `due` increments across every
card so the deck has a stable introduction order. `deck_name` and
`model_name` are passed through so the technical deck can reuse this
with its own names.
"""
conn = sqlite3.connect(db_path)
try:
create_schema(conn)
_insert_collection_metadata(
conn, deck_id, deck_name, model_id, model_name, now,
)
due = 0
for note in notes:
conn.execute(
"""
INSERT INTO notes
(id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
note["note_id"],
note["guid"],
model_id,
now,
-1,
note["tags"],
note["fields"],
note["sort"],
checksum_sort_field(note["sort"]),
0,
"",
),
)
for ord_, card_id in enumerate(note["card_ids"]):
due += 1
conn.execute(
"""
INSERT INTO cards
(id, nid, did, ord, mod, usn, type, queue, due, ivl,
factor, reps, lapses, left, odue, odid, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card_id,
note["note_id"],
deck_id,
ord_,
now,
-1,
0,
0,
due,
0,
0,
0,
0,
0,
0,
0,
0,
"",
),
)
conn.commit()
finally:
conn.close()
def _insert_collection_metadata(conn, deck_id, deck_name, model_id, model_name, now):
"""Write the single `col` row carrying the JSON config blobs.
A trimmed copy of the sibling script's metadata writer, differing
only in the model (two templates) and the single deck.
"""
conn.execute(
"""
INSERT INTO col
(id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
now,
now * 1000,
now * 1000,
11,
0,
0,
0,
json.dumps(collection_conf(deck_id), separators=(",", ":")),
json.dumps(
{str(model_id): model_json(model_id, model_name, now)},
separators=(",", ":"),
),
json.dumps(
{str(deck_id): deck_json(deck_id, deck_name, now)},
separators=(",", ":"),
),
json.dumps(default_deck_conf(now), separators=(",", ":")),
"{}",
),
)
# ============================================================================
# Card styling
# ============================================================================
CARD_CSS = """
.card {
font-family: Arial, sans-serif;
font-size: 18px;
line-height: 1.45;
color: #111;
background: #fff;
text-align: center;
}
.af-sh-kind {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 11px;
color: #777;
margin-bottom: 0.6rem;
}
.af-sh-code {
font-size: 40px;
font-weight: bold;
font-family: "Courier New", monospace;
margin: 0.4rem 0;
color: #0b3a6b;
}
.af-sh-meaning {
font-size: 22px;
margin: 0.4rem 0;
}
.af-sh-prompt {
margin-top: 0.6rem;
font-size: 14px;
color: #999;
font-style: italic;
}
.af-sh-explanation {
margin-top: 1rem;
padding-top: 0.6rem;
border-top: 1px solid #ccc;
font-size: 15px;
color: #444;
}
.af-sh-example {
margin-top: 0.6rem;
font-size: 14px;
color: #555;
}
.af-sh-example code,
.af-sh-meaning code {
font-family: "Courier New", monospace;
background: #f0f0f0;
padding: 0 3px;
border-radius: 3px;
}
.nightMode .af-sh-code,
.card.nightMode .af-sh-code {
color: #8ab4f8;
}
.nightMode .af-sh-explanation,
.card.nightMode .af-sh-explanation {
border-top-color: #555;
color: #ccc;
}
.nightMode .af-sh-example,
.card.nightMode .af-sh-example {
color: #bbb;
}
.nightMode .af-sh-example code,
.nightMode .af-sh-meaning code,
.card.nightMode .af-sh-example code,
.card.nightMode .af-sh-meaning code {
background: #333;
color: #eee;
}
"""
# ============================================================================
# Build orchestration
# ============================================================================
def resolve_build_epoch(data_dir, override_epoch):
"""Pick the package timestamp epoch.
Prefers `--epoch`, then the catalog manifest's `fetched_at` (so a
`make all` build stamps the shorthand deck consistently with the
catalog decks), and finally falls back to `DEFAULT_BUILD_EPOCH`.
The deck content is catalog-independent, so an absent data dir just
means "no manifest epoch available" rather than an error.
"""
if override_epoch is not None:
return int(override_epoch)
try:
_edition_dir, manifest, _catalog = load_latest_catalog(data_dir)
except AnkiBuildError:
return DEFAULT_BUILD_EPOCH
return build_epoch_from_manifest(manifest)
def write_glossary_deck(notes, deck_name, model_name, out_dir, build_epoch):
"""Write `notes` as one two-template `.apkg` and return its path.
Shared by both glossary decks (operating shorthand here, technical
abbreviations in `amateurfunk_technical.py`). The deck carries no
media — these cards are pure text. The output filename is derived
from the leaf of `deck_name` (`Amateurfunk::Foo` → `amateurfunk-foo`).
"""
slug = slugify(deck_name.split("::")[-1])
out_path = out_dir / f"amateurfunk-{slug}.apkg"
deck_id = stable_id("deck", deck_name)
model_id = stable_id("model", model_name)
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
db_path = tmp_dir / "collection.anki2"
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True)
try:
create_collection_db(
db_path, deck_id, deck_name, model_id, model_name, notes, build_epoch,
)
write_apkg(out_path, db_path, {}, build_epoch=build_epoch)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
return out_path
def build_deck(shorthand_path, out_dir, data_dir, override_epoch=None):
"""Build the shorthand `.apkg` and return a small result dict."""
q_codes, abbreviations = load_shorthand(shorthand_path)
notes = build_notes(q_codes, abbreviations)
if not notes:
raise AnkiBuildError("shorthand database produced no notes")
build_epoch = resolve_build_epoch(data_dir, override_epoch)
out_path = write_glossary_deck(
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
)
return {
"path": out_path,
"deck": DECK_NAME,
"notes": len(notes),
"cards": sum(len(note["card_ids"]) for note in notes),
"q_codes": len(q_codes),
"abbreviations": len(abbreviations),
}
# ============================================================================
# Main entry point
# ============================================================================
def _parse_args(argv):
"""Build the argparse object and parse `argv`."""
parser = argparse.ArgumentParser(
description=(
"Build an Anki deck of amateur-radio Q-groups and operating "
"abbreviations from shorthand.json."
),
)
parser.add_argument(
"--shorthand",
type=Path,
default=DEFAULT_SHORTHAND_PATH,
help="curated source database (default: ./shorthand.json)",
)
parser.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT_DIR,
help="output directory for the .apkg file (default: ./anki)",
)
parser.add_argument(
"--data",
type=Path,
default=DEFAULT_DATA_DIR,
help=(
"fetch output dir; only used to borrow a deterministic build "
"epoch from the catalog manifest (default: ./data)"
),
)
parser.add_argument(
"--epoch",
type=int,
default=None,
help="override the package timestamp epoch (default: from manifest)",
)
return parser.parse_args(argv)
def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv)
try:
result = build_deck(
args.shorthand,
args.out,
args.data,
override_epoch=args.epoch,
)
except AnkiBuildError as e:
print(f"error: {e}", file=sys.stderr)
return EXIT_ERROR
except Exception as e: # noqa: BLE001 (main() promises not to raise)
print(f"error: failed to build shorthand deck: {e}", file=sys.stderr)
return EXIT_ERROR
print(
f"wrote {result['path']} "
f"({result['notes']} notes, {result['cards']} cards: "
f"{result['q_codes']} Q-groups, {result['abbreviations']} abbreviations)"
)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Build an Anki deck of technical and HAM abbreviations.
A third glossary deck, sibling to `amateurfunk_shorthand.py`. Where that
script covers *operating* shorthand (Q-groups, CQ, 73), this one covers
the *technical* vocabulary: modulation and 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 a candidate meets — the exam terms plus the
common HAM abbreviations beyond it (real knowledge, not just the test).
The card mechanics are identical to the operating deck — one note per
abbreviation with two templates (code → meaning and the reverse) — so
this script is deliberately thin: it loads and validates
`technical.json`, turns each entry into a note, and hands the rest to
the shared glossary machinery in `amateurfunk_shorthand`. Each entry
also carries a German `category` (Betriebsart, Bauteil, …) shown on the
card and used as a filter tag.
Stdlib only, like its siblings.
"""
import argparse
import json
import sys
from pathlib import Path
from amateurfunk_anki import AnkiBuildError, slugify
# The glossary deck machinery is shared with the operating-shorthand
# script: the two-template note type, the two-cards-per-note collection
# writer, the deck packager, the build-epoch resolver, and the per-entry
# schema validator. Only the data shape, the deck/model names, and the
# tag scheme are specific to the technical deck.
from amateurfunk_shorthand import (
make_note,
resolve_build_epoch,
validate_entry,
write_glossary_deck,
)
# ============================================================================
# Constants
# ============================================================================
# Curated source database (editorial content tracked in git).
DEFAULT_TECHNICAL_PATH = Path("technical.json")
# Catalog output dir, consulted only to borrow a deterministic build
# epoch from the manifest (the deck content is catalog-independent).
DEFAULT_DATA_DIR = Path("data")
# Shared output dir for every .apkg.
DEFAULT_OUT_DIR = Path("anki")
# Deck and note-type names. Both distinct from the operating deck so the
# two never merge on import, and the IDs live in their own namespace.
DECK_NAME = "Amateurfunk::Technische Abkürzungen"
MODEL_NAME = "Amateurfunk Technische Abkürzung"
# ID/GUID namespace for this deck (kept separate from the operating
# deck's "shorthand" namespace).
ID_NAMESPACE = "technical"
# Schema for one entry in `technical.json`. `category` is the German
# kind label shown on the card; it is required so nothing ships
# uncategorised.
TERM_REQUIRED = ("code", "category", "meaning")
TERM_OPTIONAL = ("exam", "explanation", "example", "tags")
EXIT_OK = 0
EXIT_ERROR = 1
# ============================================================================
# Loading and validating the curated database
# ============================================================================
def load_technical(path):
"""Return the validated list of term entries from a JSON file.
Mandatory file with a strict shape (same contract as the operating
deck): a missing or malformed file is a hard error, and unknown
fields are rejected so a typo can't silently drop content.
"""
try:
data = json.loads(path.read_text("utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise AnkiBuildError(f"could not read technical file {path}: {e}") from e
if not isinstance(data, dict):
raise AnkiBuildError(
f"technical file {path} must contain a JSON object at the top level"
)
terms = data.get("terms", [])
if not isinstance(terms, list):
raise AnkiBuildError(f"technical file {path}: 'terms' must be a list")
for entry in terms:
validate_entry(entry, TERM_REQUIRED, TERM_OPTIONAL, "terms")
_check_no_duplicate_codes(terms)
return terms
def _check_no_duplicate_codes(terms):
"""Raise if two terms share a code — that would overwrite a note.
Every stable ID and GUID is hashed from the code, so a duplicate
would silently clobber the first note on import.
"""
codes = [entry["code"] for entry in terms]
duplicates = sorted({code for code in codes if codes.count(code) > 1})
if duplicates:
raise AnkiBuildError(
"duplicate code(s) in technical database: " + ", ".join(duplicates)
)
# ============================================================================
# Note expansion
# ============================================================================
def build_notes(terms):
"""Turn each term into one two-card note (forward + reverse).
The German `category` becomes the note's `Kind` (shown on the card);
there is no usage form, so `Form` is empty. Identity is hashed from
the code under this deck's own ID namespace.
"""
notes = []
for entry in terms:
notes.append(
make_note(
code_display=entry["code"],
meaning=entry["meaning"],
kind=entry["category"],
form="",
explanation=entry.get("explanation"),
example=entry.get("example"),
tags=tags_for_term(entry),
namespace=ID_NAMESPACE,
)
)
return notes
def tags_for_term(entry):
"""Return the Anki tags field (space-padded) for one term.
A base `technik` tag, a `kategorie-<slug>` tag from the German
category, a `pruefung` tag when the term appears in the exam
catalog, and any extra editorial tags carried on the entry.
"""
tags = ["technik", f"kategorie-{slugify(entry['category'])}"]
if entry.get("exam"):
tags.append("pruefung")
tags.extend(slugify(tag) for tag in entry.get("tags", []))
return " " + " ".join(tags) + " "
# ============================================================================
# Build orchestration
# ============================================================================
def build_deck(technical_path, out_dir, data_dir, override_epoch=None):
"""Build the technical `.apkg` and return a small result dict."""
terms = load_technical(technical_path)
notes = build_notes(terms)
if not notes:
raise AnkiBuildError("technical database produced no notes")
build_epoch = resolve_build_epoch(data_dir, override_epoch)
out_path = write_glossary_deck(
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
)
return {
"path": out_path,
"deck": DECK_NAME,
"notes": len(notes),
"cards": sum(len(note["card_ids"]) for note in notes),
"terms": len(terms),
}
# ============================================================================
# Main entry point
# ============================================================================
def _parse_args(argv):
"""Build the argparse object and parse `argv`."""
parser = argparse.ArgumentParser(
description=(
"Build an Anki deck of amateur-radio technical and HAM "
"abbreviations from technical.json."
),
)
parser.add_argument(
"--technical",
type=Path,
default=DEFAULT_TECHNICAL_PATH,
help="curated source database (default: ./technical.json)",
)
parser.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT_DIR,
help="output directory for the .apkg file (default: ./anki)",
)
parser.add_argument(
"--data",
type=Path,
default=DEFAULT_DATA_DIR,
help=(
"fetch output dir; only used to borrow a deterministic build "
"epoch from the catalog manifest (default: ./data)"
),
)
parser.add_argument(
"--epoch",
type=int,
default=None,
help="override the package timestamp epoch (default: from manifest)",
)
return parser.parse_args(argv)
def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv)
try:
result = build_deck(
args.technical,
args.out,
args.data,
override_epoch=args.epoch,
)
except AnkiBuildError as e:
print(f"error: {e}", file=sys.stderr)
return EXIT_ERROR
except Exception as e: # noqa: BLE001 (main() promises not to raise)
print(f"error: failed to build technical deck: {e}", file=sys.stderr)
return EXIT_ERROR
print(
f"wrote {result['path']} "
f"({result['notes']} notes, {result['cards']} cards: "
f"{result['terms']} technical abbreviations)"
)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())
+4847 -4677
View File
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
# Call Signs and Country Prefixes
This reference summarizes amateur radio call-sign patterns, suffixes,
country prefixes, and full call signs that appear in the BNetzA 2024 exam
question catalog in `data/2024-03-20-3-auflage/fragenkatalog3b.json`.
Reference counts are the number of exam question records where the item
appears in a call-sign or country-prefix context. Ambiguous short items
such as `K`, `N`, `W`, `/F`, and class letters are counted only when they
are used as call-sign prefixes, call-sign suffixes, or operating suffixes,
not as ordinary words, units, or unrelated answer text.
## German Call-Sign Patterns
| Code or pattern | What it identifies | What the exam tests | Explanation, when actually used | Exam references | Question IDs |
|---|---|---|---|---:|---|
| `DA0...` | German club station example. | `DA0ABC` is a club station. | Used as a shared station call sign assigned to a club or group. | 1 | `BD101` |
| `DA5...` | German special experimental-study example. | `DA5XX` marks operation for special experimental studies under AFuV. | Used for specific authorized experimental amateur radio work. | 1 | `BD102` |
| `DL0...` | German class A club station example. | `DL0XK` is a class A club-station call sign. | Used by a club station rather than a person-bound station. | 1 | `BD103` |
| `DL1` to `DL9` | German person-bound class A call signs with two- or three-letter suffix. | `DL1` to `DL9` plus suffix belongs to class A. | Used as a German full-privilege personal amateur radio call-sign pattern. | 1 | `BD104` |
| `DN9...` | German person-bound class N call sign pattern. | `DN9` belongs to class N. | Used for German class N personal call signs. | 1 | `BD105` |
| `DO1` to `DO9` | German person-bound class E call signs with two- or three-letter suffix. | `DO1` to `DO9` plus suffix belongs to class E. | Used as a German novice/intermediate personal call-sign pattern. | 1 | `BD106` |
| `DP0...` | German exterritorial class A station examples. | `DP0GVN` and `DP0POL` are exterritorial class A amateur stations. | Used for German amateur stations operated at exterritorial locations. | 2 | `BD107`, `BD108` |
| German personal format | Two-letter prefix, one digit, two- or three-letter suffix. | Person-bound German call signs are built from prefix, digit, and suffix. | Used to recognize the structure of ordinary German amateur call signs. | 1 | `VD203` |
| `DA` to `DZ` | German country-prefix range with exceptions in distractors. | Correct mapping: Germany `DA-DR`, South Korea `DS-DT`, Philippines `DU-DZ`. | Used to identify the country allocation at the start of a call sign. | 1 | `BD302` |
| `MO`, `MOE`, `MOI`, `MOS`, `MOH`, `MO5` | Direction-finding (foxhunt/ARDF) transmitter identifiers, not call signs. | Low-power beacons used for radio direction finding send `MO`, `MOE`, `MOI`, `MOS`, `MOH` or `MO5`. | Used in telegraphy by ARDF/foxhunt transmitters to identify themselves while being located. | 1 | `BD109` |
## Operating Suffixes and Portable Prefixing
| Code or pattern | What it identifies | What the exam tests | Explanation, when actually used | Exam references | Question IDs |
|---|---|---|---|---:|---|
| `HB9/DL9MJ` | German class A station operating temporarily in Switzerland under CEPT. | For CEPT operation abroad, use the visited country's prefix before the home call sign. | Used when a German class A operator operates temporarily in Switzerland. | 1 | `BD214` |
| `HB3/DO7PR` | German CEPT-Novice station operating temporarily in Switzerland. | For Swiss novice CEPT operation, `HB3/` is prefixed before the German `DO` call. | Used when a German novice operator operates temporarily in Switzerland under the novice arrangement. | 1 | `BD213` |
| `DL/G3MM` | Foreign station temporarily operating in Germany under CEPT. | A UK station may temporarily operate in Germany by prefixing `DL/`. | Used when a foreign operator identifies temporary operation from Germany. | 1 | `BD212` |
| `DL/` or `DO/` | German prefix added before a foreign home call sign, depending on class. | A CEPT visitor in Germany prefixes the home call with `DL/` or `DO/`. | Used by foreign CEPT operators while temporarily operating from Germany. | 1 | `VB110` |
| `/T` or `/Trainee` | Training operation suffix. | Trainees use the trainer or club call with `/T` or `/Trainee`, depending on the case. | Used to identify supervised training operation. | 3 | `BD209`, `BD210`, `BD211` |
| `/R` or `/Remote` | Remote-operation suffix. | `/R` or `/Remote` can mark remote operation. | Used as optional information that the station is being operated remotely. | 1 | `BD208` |
| `/p` | Portable or temporarily fixed station. | `/p` may be used in Germany, but is not mandatory for portable or temporarily fixed operation. | Used to tell others the station is portable or temporarily away from the registered fixed site. | 3 | `BA107`, `BD206`, `BD207` |
| `/m` | Mobile station or station on inland waterways. | `/m` can mean mobile in a land vehicle or aboard a vessel on inland waters. | Used to identify mobile operation. | 2 | `BD203`, `BD204` |
| `/mm` | Maritime mobile on a vessel at sea. | `DC4LW/mm` means the station is aboard a watercraft at sea. | Used for maritime mobile amateur operation outside inland-waterway cases. | 1 | `BD205` |
| `/am` | Aeronautical mobile. | `VE8ZZ/am` is a Canadian amateur station operated aboard an aircraft. | Used when an amateur station is operated from an aircraft. | 2 | `BD201`, `BD202` |
## Country Prefixes
| Code or prefix | Country or area | What the exam tests | Explanation, when actually used | Exam references | Question IDs |
|---|---|---|---|---:|---|
| `4X` | Israel. | `4X`, `F`, `OZ` map to Israel, France, Denmark. | Used as the country prefix at the start of Israeli call signs. | 2 | `BD307`, `BD311` |
| `AL` | United States prefix range item. | Appears as a distractor in the USA, New Zealand, Argentina prefix question. | Used as part of the US `AA-AL` prefix allocation, though not the main exam answer. | 1 | `BD312` |
| `BY` | China. | China, Canada, Australia are `BY`, `VE`, `VK`. | Used as a Chinese amateur radio prefix. | 4 | `BD313`, `BD316`, `BD317`, `BD318` |
| `CE` | Chile. | South American examples include `PY`, `CE`, `LU`; also appears as a distractor. | Used as a Chilean amateur radio prefix. | 2 | `BD312`, `BD317` |
| `CT` | Portugal. | Appears as a distractor among countries bordering Germany. | Used as a Portuguese amateur radio prefix. | 1 | `BD314` |
| `DS-DT` | South Korea. | In the `DA-DZ` allocation question, `DS-DT` belongs to South Korea. | Used to identify South Korean call signs in that prefix range. | 3 | `BD302`, `BD316`, `BD318` |
| `DU-DZ` | Philippines. | In the `DA-DZ` allocation question, `DU-DZ` belongs to the Philippines. | Used to identify Philippine call signs in that prefix range. | 1 | `BD302` |
| `EA` | Spain. | Spain appears as `EA`; Switzerland, Spain, Belgium are `HB9`, `EA`, `ON`; `EA6VQ` is a Spanish station calling CQ. | Used as a Spanish amateur radio prefix. | 5 | `BD308`, `BD310`, `BD311`, `BD314`, `BE104` |
| `EI` | Ireland. | `EA`, `EI`, `EK`, `EM`, `ES` map to Spain, Ireland, Armenia, Ukraine, Estonia. | Used as an Irish amateur radio prefix. | 2 | `BD308`, `BD311` |
| `EK` | Armenia. | `EK` maps to Armenia in the five-prefix sequence. | Used as an Armenian amateur radio prefix. | 1 | `BD308` |
| `EM` | Ukraine. | `EM` maps to Ukraine in the correct sequence and appears as a distractor elsewhere. | Used as a Ukrainian amateur radio prefix. | 2 | `BD308`, `BD311` |
| `ES` | Estonia. | `ES` maps to Estonia in the five-prefix sequence. | Used as an Estonian amateur radio prefix. | 1 | `BD308` |
| `EU` | Belarus. | Appears as a distractor in the Spain, Luxembourg, Poland question. | Used as a Belarusian amateur radio prefix. | 1 | `BD311` |
| `F` | France. | France appears in border-country and prefix-mapping questions. | Used as a French amateur radio prefix. | 3 | `BD305`, `BD307`, `BD314` |
| `GM` | Scotland. | Appears as a distractor among countries bordering Germany. | Used for Scotland within the UK call-sign system. | 1 | `BD314` |
| `HB0` | Liechtenstein. | Appears as a distractor among countries bordering Germany. | Used as a Liechtenstein amateur radio prefix. | 1 | `BD314` |
| `HB3` | Switzerland novice CEPT prefix. | German novice CEPT operation in Switzerland uses `HB3/DO7PR`. | Used before a German novice home call when operating temporarily in Switzerland under the relevant arrangement. | 2 | `BD213`, `BD214` |
| `HB9` | Switzerland. | Switzerland appears as `HB9`; used in CEPT and DX examples. | Used as a Swiss amateur radio prefix. | 5 | `BD213`, `BD214`, `BD310`, `BD314`, `BE114` |
| `I` | Italy. | Appears as a distractor among countries bordering Germany. | Used as an Italian amateur radio prefix. | 1 | `BD314` |
| `JA` | Japan. | Asian-prefix distractors and China, Canada, Australia choices include `JA`. | Used as a Japanese amateur radio prefix. | 4 | `BD313`, `BD316`, `BD317`, `BD318` |
| `K` | United States. | USA call signs can start with `K`; also appears in country-prefix distractors. | Used as a US amateur radio prefix. | 4 | `BD312`, `BD315`, `BD316`, `BD318` |
| `LA` | Norway. | Appears as a distractor in Europe-prefix questions. | Used as a Norwegian amateur radio prefix. | 2 | `BD311`, `BD314` |
| `LU` | Argentina. | USA, New Zealand, Argentina are `W`, `ZL`, `LU`; also a South America distractor. | Used as an Argentine amateur radio prefix. | 4 | `BD311`, `BD312`, `BD317`, `BD318` |
| `LX` | Luxembourg. | Spain, Luxembourg, Poland are `EA`, `LX`, `SP`. | Used as a Luxembourg amateur radio prefix. | 2 | `BD311`, `BD314` |
| `LZ` | Bulgaria. | Appears as a distractor in European-prefix questions. | Used as a Bulgarian amateur radio prefix. | 2 | `BD311`, `BD314` |
| `N` | United States. | USA call signs can start with `N`; also appears in distractors. | Used as a US amateur radio prefix. | 4 | `BD312`, `BD313`, `BD315`, `BD316` |
| `OE` | Austria. | Austria, Netherlands, Sweden are `OE`, `PA`, `SM`; Austria, Belgium, Czechia are `OE`, `ON`, `OK`. | Used as an Austrian amateur radio prefix. | 3 | `BD303`, `BD304`, `BD314` |
| `OK` | Czechia. | `OE`, `ON`, `OK` map to Austria, Belgium, Czechia. | Used as a Czech amateur radio prefix. | 2 | `BD303`, `BD314` |
| `ON` | Belgium. | Switzerland, Spain, Belgium are `HB9`, `EA`, `ON`; `OE`, `ON`, `OK` includes Belgium. | Used as a Belgian amateur radio prefix. | 3 | `BD303`, `BD310`, `BD314` |
| `OZ` | Denmark. | `4X`, `F`, `OZ` map to Israel, France, Denmark; also border-country question. | Used as a Danish amateur radio prefix. | 2 | `BD307`, `BD314` |
| `PA` | Netherlands. | France, Netherlands, Poland are `F`, `PA`, `SP`; Austria, Netherlands, Sweden are `OE`, `PA`, `SM`. | Used as a Dutch amateur radio prefix. | 2 | `BD304`, `BD305` |
| `PY` | Brazil. | `VE`, `VK`, `PY` map to Canada, Australia, Brazil; `PY` also appears in long-path context. | Used as a Brazilian amateur radio prefix. | 5 | `AH216`, `BD309`, `BD312`, `BD317`, `BD318` |
| `SM` | Sweden. | Sweden, Poland, South Africa are `SM`, `SP`, `ZS`; `OE`, `PA`, `SM` includes Sweden. | Used as a Swedish amateur radio prefix. | 4 | `BD304`, `BD306`, `BD311`, `BD314` |
| `SP` | Poland. | Poland appears in border-country and mapping questions. | Used as a Polish amateur radio prefix. | 5 | `BD305`, `BD306`, `BD310`, `BD311`, `BD314` |
| `S0` | Western Sahara. | Appears as a distractor in the Spain, Luxembourg, Poland question. | Used as a Western Sahara prefix in prefix lists. | 1 | `BD311` |
| `SZ` | Greece prefix-series item. | Appears as a distractor in the Switzerland, Spain, Belgium question. | Used within Greek amateur radio prefix series. | 1 | `BD310` |
| `UA` | Russia. | Appears in `UA3RUS`, a distractor in the USA-only call-sign question. | Used as a Russian amateur radio prefix. | 1 | `BD315` |
| `US` | Ukraine. | Appears in `US2ABC`, a distractor in the USA-only call-sign question. | Used as a Ukrainian amateur radio prefix series. | 1 | `BD315` |
| `VE` | Canada. | Canada appears as `VE`; `VE8ZZ/am` is Canadian aeronautical mobile. | Used as a Canadian amateur radio prefix. | 7 | `BD202`, `BD309`, `BD312`, `BD313`, `BD315`, `BD316`, `BD317` |
| `VK` | Australia. | Australia appears as `VK`; `CQ VK/ZL` asks for Australia or New Zealand. | Used as an Australian amateur radio prefix. | 4 | `BD309`, `BD313`, `BE110`, `EH217` |
| `VU` | India. | `BY`, `JA`, `VU` are Asian prefixes in the correct answer. | Used as an Indian amateur radio prefix. | 1 | `BD318` |
| `W` | United States. | USA, New Zealand, Argentina are `W`, `ZL`, `LU`; `W`, `VE`, `XE` share one continent; USA calls also include `W...`. | Used as a US amateur radio prefix. | 3 | `BD312`, `BD315`, `BD316` |
| `XE` | Mexico. | North American-prefix distractor with `W`, `VE`, and `N`. | Used as a Mexican amateur radio prefix. | 1 | `BD316` |
| `ZL` | New Zealand. | USA, New Zealand, Argentina are `W`, `ZL`, `LU`; `CQ VK/ZL` asks for Australia or New Zealand. | Used as a New Zealand amateur radio prefix. | 2 | `BD312`, `BE110` |
| `ZS` | South Africa. | Sweden, Poland, South Africa are `SM`, `SP`, `ZS`; also appears as a distractor. | Used as a South African amateur radio prefix. | 2 | `BD306`, `BD312` |
## Full Call Signs Mentioned
| Call sign | What it identifies | What the exam tests | Explanation, when actually used | Exam references | Question IDs |
|---|---|---|---|---:|---|
| `DD4UQ` | German call-sign example. | Phonetic spelling and Fieldday contest example. | Used as a normal station call sign; with `/P` it is portable. | 2 | `BA101`, `BE116` |
| `DK1KC` | German phonetic spelling example. | Spell as Delta Kilo 1 Kilo Charlie. | Used as a station call sign that must be identified clearly on air. | 1 | `BA102` |
| `DK5WP` | German phonetic spelling example. | Spell as Delta Kilo 5 Whiskey Papa. | Used as a station call sign that must be identified clearly on air. | 1 | `BA103` |
| `DL1FLO` | German phonetic spelling example. | Spell as Delta Lima 1 Foxtrot Lima Oscar. | Used as a station call sign that must be identified clearly on air. | 1 | `BA104` |
| `DL4YBZ` | German phonetic spelling example. | Spell as Delta Lima 4 Yankee Bravo Zulu. | Used as a station call sign that must be identified clearly on air. | 1 | `BA105` |
| `DM4EAX` | German phonetic spelling example. | Spell as Delta Mike 4 Echo Alfa X-ray. | Used as a station call sign that must be identified clearly on air. | 1 | `BA106` |
| `DN9RO/p` | German portable phonetic spelling example. | Spell the call and `/p` as portable. | Used when a class N station is portable or temporarily fixed. | 1 | `BA107` |
| `DN9STV` | German class N phonetic spelling example. | Spell as Delta November 9 Sierra Tango Victor. | Used as a class N station call sign. | 1 | `BA108` |
| `DO9XJZ` | German class E phonetic spelling example. | Spell as Delta Oscar 9 X-ray Juliett Zulu. | Used as a class E station call sign. | 1 | `BA109` |
| `IG9/DL4HR` | German station operating with `IG9/` prefix in the spelling example. | Spell the slash as stroke. | Used when a home call is prefixed for operation from another country or area. | 1 | `BA110` |
| `DA0ABC` | German club station example. | Recognize it as a club station. | Used as a shared club-station call sign. | 1 | `BD101` |
| `DA5XX` | German special experimental-study example. | Recognize special experimental-study operation. | Used for authorized experimental amateur radio studies. | 1 | `BD102` |
| `DL0XK` | German class A club station example. | Recognize a class A club station. | Used by a club station in normal or contest operation. | 1 | `BD103` |
| `DP0GVN` | German exterritorial class A station example. | Recognize exterritorial class A operation. | Used for German amateur operation from an exterritorial site. | 1 | `BD107` |
| `DP0POL` | German exterritorial class A station example. | Recognize exterritorial class A operation. | Used for German amateur operation from an exterritorial site. | 1 | `BD108` |
| `DG2RON/T` | German training-operation example. | Trainee uses `DG2RON/T` for Morse or digital training. | Used when a trainee operates under supervision. | 1 | `BD211` |
| `DL0MOL/T` | Club-station training-operation example. | Trainee may use `DL0MOL/T` or `DL0MOL/Trainee`. | Used for supervised training at a club station. | 1 | `BD210` |
| `DL1PZ/Trainee` | Speech training-operation example. | Trainee may use `DL1PZ/Trainee`. | Used for supervised speech-radio training. | 1 | `BD209` |
| `DC4LW/mm` | German maritime-mobile station example. | Recognize operation aboard a vessel at sea. | Used when operating from a vessel at sea. | 1 | `BD205` |
| `VE8ZZ/am` | Canadian aeronautical-mobile station example. | Recognize Canadian aircraft operation. | Used when operating an amateur station from an aircraft. | 1 | `BD202` |
| `DL/G3MM` | UK station temporarily operating in Germany. | CEPT visitor prefixes home call with German prefix. | Used by a foreign operator temporarily in Germany. | 1 | `BD212` |
| `HB9/DL9MJ` | German class A station temporarily in Switzerland. | Correct CEPT call-sign form in Switzerland. | Used by a German operator temporarily operating from Switzerland. | 1 | `BD214` |
| `HB3/DO7PR` | German CEPT-Novice station temporarily in Switzerland. | Correct novice CEPT call-sign form in Switzerland. | Used by a German novice operator temporarily operating from Switzerland. | 1 | `BD213` |
| `EA6VQ` and `DF1KW` | Spanish caller and German answering station. | Correct way to answer a CQ call in English. | Used when replying directly to a station that called CQ. | 1 | `BE104` |
| `4U1ITU` | ITU station example calling specific prefixes. | `CQ VK/ZL` asks only Australia or New Zealand. | Used as a calling station that restricts replies to wanted areas. | 1 | `BE110` |
| `N4EAX` | US station calling Germany. | `CQ DL` means the station seeks a German amateur. | Used when a station calls for a particular country prefix. | 1 | `BE113` |
| `HB9AFN` | Swiss station calling DX. | `CQ DX` on 20 m seeks another continent in the exam example. | Used when a station requests long-distance or intercontinental replies. | 1 | `BE114` |
| `HZ1HZ` and `K8PYD` | Station and QSL manager example. | `QSL via K8PYD` means send the card through the QSL manager. | Used when a station delegates QSL-card handling to another call sign. | 1 | `BG109` |
| `K3LR`, `W3DZZ`, `K4EAX` | Correct USA-only answer set. | All three are US call signs. | Used as examples of US call-sign prefixes `K` and `W`. | 1 | `BD315` |
| `W0FKK`, `N6CAL`, `VE5VK` | Mixed USA and Canada distractor set. | `VE5VK` is Canadian, so the set is not USA-only. | Used to distinguish US prefixes from Canadian `VE`. | 1 | `BD315` |
| `US2ABC`, `AB0GC`, `W4EAX` | Mixed distractor set. | `US2ABC` is not a US call sign in this question context. | Used to avoid assuming every call starting with `US` means USA. | 1 | `BD315` |
| `K1TTT`, `KA7KLE`, `UA3RUS` | Mixed USA and Russia distractor set. | `UA3RUS` is not a US call sign. | Used to distinguish US `K` or `KA` calls from Russian `UA`. | 1 | `BD315` |
## Lookup and Identification Rules
| Topic | What it identifies | What the exam tests | Explanation, when actually used | Exam references | Question IDs |
|---|---|---|---|---:|---|
| International prefix regulation | Prefixes for amateur radio call signs. | Prefixes are internationally regulated in the Radio Regulations. | Used as the legal basis for recognizing country allocations. | 1 | `VA406` |
| Unknown country prefix lookup | Where to find an unknown country prefix. | Use the ITU country-prefix list, amateur radio handbooks, and call-sign lists. | Used when a heard call sign has an unfamiliar prefix. | 1 | `BD301` |
| International callbook | Address lookup for foreign amateurs. | Foreign addresses can be found in an international callbook or internet sources. | Used when sending a direct QSL card. | 2 | `BG109`, `BG110` |
| QSL-card call signs | Minimum QSL-card call-sign data. | QSL cards should include own call sign and the other station's call sign. | Used to document a completed QSO correctly. | 1 | `BG105` |
| Listening without a call sign | Receiving amateur transmissions. | Receiving amateur radio transmissions does not require amateur service admission. | Used to distinguish SWL reception from transmitting. | 1 | `VD102` |
+466
View File
@@ -0,0 +1,466 @@
# IARU Region 1 Frequency Recommendations
This reference summarizes the IARU Region 1 band-plan frequency
recommendations. IARU band plans are operating recommendations; German
legal privileges, power limits, bandwidth limits, and allocations still
come from AFuV Anlage 1 and BNetzA notices.
Sources checked May 26, 2026:
- IARU Region 1 Band Plans: <https://www.iaru-r1.org/on-the-air/band-plans/>
- HF Band Plan PDF: <https://www.iaru-r1.org/wp-content/uploads/2021/06/hf_r1_bandplan.pdf>
- VHF and up Bandplanning: <https://www.iaru-r1.org/about-us/committees-and-working-groups/vhf-uhf-shf-committee-c5/vhf-up-bandplanning/>
- VHF PDF: <https://www.iaru-r1.org/wp-content/uploads/2020/12/VHF-Bandplan.pdf>
- UHF PDF: <https://www.iaru-r1.org/wp-content/uploads/2021/03/UHF-Bandplan.pdf>
- SHF PDF: <https://www.iaru-r1.org/wp-content/uploads/2020/12/SHF-Bandplan.pdf>
- Microwave PDF: <https://www.iaru-r1.org/wp-content/uploads/2020/12/%C2%B5W-Bandplan.pdf>
Bold entries are directly referenced by exam questions in the BNetzA
catalog, mainly `BC201` to `BC222`, `BE410`, and `BF109`.
## General Recommendations
| Recommendation | Details | Questions |
|---|---|---|
| **Band plans are recommendations** | They are intended to help all radio amateurs share spectrum efficiently. | `BC201` |
| **SSB sideband below 10 MHz** | Lower sideband (LSB) is recommended below 10 MHz. | `BC202` |
| **SSB sideband above 10 MHz** | Upper sideband (USB) is recommended above 10 MHz. The 5 MHz band is an exception where USB is recommended. | `BC203` |
| **CW usage** | **CW is accepted across all bands except beacon segments. IARU tables still place the usual CW/preferred narrowband ranges near the beginning of each band.** | `BC204` |
| Contest-free HF bands | Contest activity should not take place on 5, 10, 18, and 24 MHz. | |
| Unmanned transmitting stations | Unmanned stations and operation involving unmanned stations must follow the band-plan frequency and bandwidth limits. | |
## Study Patterns
| Pattern | How to use it in questions |
|---|---|
| CW and very narrow modes live low in the band | When an answer asks about Morsetelegrafie, MGM, or narrow digital modes, first check the lower edge of the band: 144.025-144.150 MHz on 2 m, 432.000-432.100 MHz on 70 cm, and the first HF segments. |
| SSB is above the CW/narrow area, but still below FM | On 2 m, SSB sits around 144.150-144.400 MHz and the centre is 144.300 MHz. On 70 cm, SSB is around 432.100-432.400 MHz and the centre is 432.200 MHz. |
| Beacon areas are protected and not for normal QSOs | On 2 m, 144.400-144.490 MHz is beacons. On 70 cm, 432.400-432.490 MHz is beacons. HF has tiny International Beacon Project windows around 14, 18, 21, 24, and 28 MHz. |
| FM calling frequencies are easy anchors | Analog FM calling is 145.500 MHz on 2 m and 433.500 MHz on 70 cm. Digital voice calling is 145.375 MHz on 2 m and 433.450 MHz on 70 cm. |
| Repeater outputs are higher than simplex on 2 m and high on 70 cm | 2 m repeater outputs start at 145.575 MHz, so the exam's 145.600 MHz example is already in the repeater-output block. The exam's 439.200 MHz example is in the 70 cm repeater-output block. |
| Satellite and space ranges are high in the VHF/UHF examples | The top of 2 m is space/satellite: 145.794-145.806 MHz is space communication and 145.806-146.000 MHz is satellite exclusive. 435-438 MHz is satellite service on 70 cm. |
| SSB sideband rule is split at 10 MHz | Below 10 MHz use LSB; above 10 MHz use USB. Memorize: 80 m is LSB, 20 m is USB. |
| Emergency centres are memorable roundish HF numbers | 3760, 7110, 14300, 18160, and 21360 kHz are the IARU Region 1/global emergency centres used in the question catalog. |
## HF Band Plan
### 135.7-137.8 kHz (2200 m, LF, Langwelle)
This is a very narrow experimental long-wave band. Activity is mostly CW, QRSS, WSPR-style weak-signal work, and careful propagation experiments. Antennas are physically difficult at this wavelength, so efficient stations are rare and signals are often weak; ground-wave and night-enhanced propagation matter more than ordinary shortwave-style operation.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 135.7-137.8 kHz | 200 Hz | CW, QRSS, and narrow-band digital modes | |
### 472-479 kHz (630 m, MF, Mittelwelle)
This medium-wave band is mainly used for CW and narrow digital modes. Daytime contacts tend to rely on ground-wave coverage, while night-time conditions can open longer paths. Like 2200 m, it rewards patient weak-signal operating and efficient antenna systems.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 472-475 kHz | 200 Hz | CW | |
| 475-479 kHz | 500 Hz suggested | CW and digital modes | |
### 1.8 MHz (160 m, MF, Mittelwelle)
The 160 m band is often called top band. It behaves partly like medium wave and partly like HF: local or regional coverage is possible, but serious DX is usually a night-time project. Noise levels can be high, so CW and narrow modes at the low end are especially important.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 1810-1838 kHz | 200 Hz | CW; 1836 kHz CW QRP centre of activity | |
| 1838-1840 kHz | 500 Hz | Narrow-band modes | |
| 1840-1843 kHz | 2700 Hz | All modes; digital modes | |
| 1843-2000 kHz | 2700 Hz | All modes | |
### 3.5 MHz (80 m, HF, Kurzwelle)
The 80 m band is a workhorse for regional HF communication, especially in the evening and at night. It is useful for nets, local-to-regional contacts, and emergency practice. For exam purposes, remember that SSB voice normally uses LSB here, CW and digital modes sit low in the band, and 3760 kHz is the Region 1 emergency centre.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 3500-3510 kHz | 200 Hz | CW; priority for intercontinental operation | |
| 3510-3560 kHz | 200 Hz | CW; 3555 kHz CW QRS centre | |
| 3560-3570 kHz | 200 Hz | CW; 3560 kHz CW QRP centre | |
| 3570-3580 kHz | 200 Hz | Narrow-band modes; digital modes | |
| 3580-3590 kHz | 500 Hz | Narrow-band modes; digital modes | |
| 3590-3600 kHz | 500 Hz | Narrow-band modes; digital modes and automatically controlled data stations | |
| 3600-3620 kHz | 2700 Hz | All modes; digital modes and automatically controlled data stations | |
| 3600-3650 kHz | 2700 Hz | All modes; 3630 kHz digital voice centre of activity | |
| 3650-3700 kHz | 2700 Hz | All modes; 3690 kHz SSB QRP centre | |
| 3700-3775 kHz | 2700 Hz | All modes; 3735 kHz image centre; **3760 kHz Region 1 emergency centre of activity** | `BF109` |
| 3775-3800 kHz | 2700 Hz | All modes; SSB contest preferred, priority for intercontinental operation | |
### 5 MHz (60 m, HF, Kurzwelle)
The 60 m band is a small, channel-like HF allocation with a reputation for reliable regional and NVIS-style communication. It can bridge the gap when 80 m is too low and 40 m is too long. Unlike the normal below-10-MHz sideband rule, USB is recommended for voice here.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 5351.5-5354.0 kHz | 200 Hz | CW and narrow-band modes | |
| 5354.0-5366.0 kHz | 2700 Hz | All modes; USB recommended for voice operation | |
| 5366.0-5366.5 kHz | 20 Hz | Weak-signal narrow-band modes | |
### 7 MHz (40 m, HF, Kurzwelle)
The 40 m band is one of the most useful all-round HF bands. It often gives regional coverage during the day and longer-distance contacts after dark. CW and digital modes are low in the band, SSB is higher, and 7110 kHz is the Region 1 emergency centre.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 7000-7040 kHz | 200 Hz | CW; 7030 kHz CW QRP centre | |
| 7040-7047 kHz | 500 Hz | Narrow-band modes; digital modes | |
| 7047-7050 kHz | 500 Hz | Narrow-band modes; digital modes and automatically controlled data stations | |
| 7050-7053 kHz | 2700 Hz | All modes; digital modes and automatically controlled data stations | |
| 7053-7060 kHz | 2700 Hz | All modes; digital modes | |
| 7060-7100 kHz | 2700 Hz | All modes; 7070 kHz digital voice centre; 7090 kHz SSB QRP centre | |
| 7100-7130 kHz | 2700 Hz | All modes; **7110 kHz Region 1 emergency centre of activity** | `BF109` |
| 7130-7175 kHz | 2700 Hz | All modes; 7165 kHz image centre | |
| 7175-7200 kHz | 2700 Hz | All modes; SSB contest preferred, priority for intercontinental activity | |
### 10 MHz (30 m, HF, Kurzwelle)
The 30 m band is a narrow-band-focused WARC band. It is especially attractive for CW and digital DX because it is relatively quiet and contest activity is not recommended. Think of it as a precise, low-bandwidth band rather than a general voice band.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 10100-10130 kHz | 200 Hz | CW; 10116 kHz CW QRP centre | |
| 10130-10150 kHz | 500 Hz | Narrow-band modes; digital modes | |
### 14 MHz (20 m, HF, Kurzwelle)
The 20 m band is the classic daytime DX band and one of the first places many operators check for international contacts. SSB voice normally uses USB. The International Beacon Project sits near 14.100 MHz, and 14.300 MHz is a global emergency centre of activity.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 14000-14060 kHz | 200 Hz | CW; 14055 kHz QRS centre | |
| 14060-14070 kHz | 200 Hz | CW; 14060 kHz CW QRP centre | |
| 14070-14089 kHz | 500 Hz | Narrow-band modes; digital modes | |
| 14089-14099 kHz | 500 Hz | Narrow-band modes; digital modes and automatically controlled data stations | |
| **14099-14101 kHz** | - | **International Beacon Project; beacons exclusively** | `BE410` |
| 14101-14112 kHz | 2700 Hz | All modes; digital modes and automatically controlled data stations | |
| 14112-14125 kHz | 2700 Hz | All modes | |
| 14125-14300 kHz | 2700 Hz | All modes; 14130 kHz digital voice centre; 14195 +/- 5 kHz priority for DXpeditions; 14230 kHz image centre; 14285 kHz SSB QRP centre | |
| 14300-14350 kHz | 2700 Hz | All modes; **14300 kHz global emergency centre of activity** | `BF109` |
### 18 MHz (17 m, HF, Kurzwelle)
The 17 m band is a WARC band between 20 m and 15 m. It often feels less crowded than the contest-heavy bands and can be excellent for DX when the ionosphere supports it. Expect CW and digital activity lower down and SSB higher up.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 18068-18095 kHz | 200 Hz | CW; 18086 kHz CW QRP centre | |
| 18095-18105 kHz | 500 Hz | Narrow-band modes; digital modes | |
| 18105-18109 kHz | 500 Hz | Narrow-band modes; digital modes and automatically controlled data stations | |
| **18109-18111 kHz** | - | **International Beacon Project; beacons exclusively** | `BE410` |
| 18111-18120 kHz | 2700 Hz | All modes; digital modes and automatically controlled data stations | |
| 18120-18168 kHz | 2700 Hz | All modes; 18130 kHz SSB QRP centre; 18150 kHz digital voice centre; **18160 kHz emergency centre of activity** | `BF109` |
### 21 MHz (15 m, HF, Kurzwelle)
The 15 m band is a daylight DX band that becomes much more lively when solar activity is good. When open, it can support strong worldwide signals with modest antennas. CW and digital modes sit low, SSB is higher, and 21.360 MHz is an emergency centre.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 21000-21070 kHz | 200 Hz | CW; 21055 kHz QRS centre; 21060 kHz QRP centre | |
| 21070-21090 kHz | 500 Hz | Narrow-band modes; digital modes | |
| 21090-21110 kHz | 500 Hz | Narrow-band modes; digital modes and automatically controlled data stations | |
| 21110-21120 kHz | 2700 Hz | All modes; digital modes and automatically controlled data stations, not SSB | |
| 21120-21149 kHz | 500 Hz | Narrow-band modes | |
| **21149-21151 kHz** | - | **International Beacon Project; beacons exclusively** | `BE410` |
| 21151-21450 kHz | 2700 Hz | All modes; 21180 kHz digital voice centre; 21285 kHz SSB QRP centre; 21340 kHz image centre; **21360 kHz global emergency centre of activity** | `BF109` |
### 24 MHz (12 m, HF, Kurzwelle)
The 12 m band is an upper-HF WARC band. It can be quiet for long periods and then suddenly open for excellent DX, especially in better solar conditions. The small beacon window near 24.930 MHz is useful for checking whether the band is alive.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 24890-24915 kHz | 200 Hz | CW; 24906 kHz CW QRP centre | |
| 24915-24925 kHz | 500 Hz | Narrow-band modes; digital modes | |
| 24925-24929 kHz | 500 Hz | Narrow-band modes; digital modes and automatically controlled data stations | |
| **24929-24931 kHz** | - | **International Beacon Project; beacons exclusively** | `BE410` |
| 24931-24940 kHz | 2700 Hz | All modes; digital modes and automatically controlled data stations | |
| 24940-24990 kHz | 2700 Hz | All modes; 24950 kHz SSB QRP centre; 24960 kHz digital voice centre | |
### 28 MHz (10 m, HF, Kurzwelle)
The 10 m band is the top end of HF and can feel almost like VHF when closed, yet support worldwide ionospheric DX when solar activity is strong. It also has room for FM, repeaters, and satellite links, so it is more varied than most lower HF bands.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 28000-28070 kHz | 200 Hz | CW; 28055 kHz QRS centre; 28060 kHz QRP centre | |
| 28070-28120 kHz | 500 Hz | Narrow-band modes; digital modes | |
| 28120-28150 kHz | 500 Hz | Narrow-band modes; digital modes and automatically controlled data stations | |
| 28150-28190 kHz | 500 Hz | Narrow-band modes | |
| **28190-28199 kHz** | - | **International Beacon Project; regional time-shared beacons exclusively** | `BE410` |
| **28199-28201 kHz** | - | **International Beacon Project; worldwide time-shared beacons exclusively** | `BE410` |
| **28201-28225 kHz** | - | **International Beacon Project; continuous-duty beacons exclusively** | `BE410` |
| 28225-28300 kHz | 2700 Hz | All modes; beacons | |
| 28300-28320 kHz | 2700 Hz | All modes; digital modes and automatically controlled data stations | |
| 28320-29000 kHz | 2700 Hz | All modes; 28330 kHz digital voice centre; 28360 kHz SSB QRP centre; 28680 kHz image centre | |
| 29000-29100 kHz | unrestricted | All modes | |
| 29100-29200 kHz | unrestricted | All modes; FM simplex, 10 kHz channels | |
| 29200-29300 kHz | unrestricted | All modes; digital modes and automatically controlled data stations | |
| 29300-29510 kHz | unrestricted | Satellite links | |
| 29510-29520 kHz | - | Guard channel | |
| 29520-29590 kHz | 6000 Hz | All modes; FM repeater input RH1-RH8 | |
| 29600 kHz | 6000 Hz | All modes; FM calling channel | |
| 29610 kHz | 6000 Hz | All modes; FM simplex repeater/parrot input and output | |
| 29620-29700 kHz | 6000 Hz | All modes; FM repeater output RH1-RH8 | |
## VHF Band Plan
### 50 MHz (6 m, VHF, UKW)
The 6 m band is known as the magic band because it usually behaves like VHF, then suddenly produces surprising long-distance openings. Most everyday paths are line-of-sight, but sporadic-E, meteor scatter, aurora, and occasional F-layer propagation make it a favorite for propagation watchers.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 50.000-50.100 MHz | 500 Hz | Coordinated Beacon Project; telegraphy; 50.050 MHz centre of activity; 50.090 MHz intercontinental centre of activity | |
| 50.100-50.200 MHz | 2700 Hz | SSB and telegraphy; 50.100-50.130 MHz intercontinental; 50.110 MHz centre of activity; 50.130-50.200 MHz international; 50.150 MHz centre of activity | |
| 50.200-50.300 MHz | 2700 Hz | SSB and telegraphy; general use; 50.285 MHz crossband | |
| 50.300-50.400 MHz | 2700 Hz | Narrow-band modes and MGM; 50.305 MHz PSK centre; 50.310-50.320 MHz EME centre; 50.320-50.380 MHz MS centre | |
| 50.400-50.500 MHz | 1000 Hz | MGM and telegraphy; beacons exclusive; 50.401 MHz +/- 500 Hz WSPR beacons | |
| 50.500-52.000 MHz | 12 kHz or local subplan | All modes; SSTV, image, RTTY, digital communications, DV, FM/DV repeaters, FM/DV simplex, 51.510 MHz FM calling | |
| 52.000-54.000 MHz | 500 kHz | All modes | |
### 70 MHz (4 m, VHF, UKW)
The 4 m band is a VHF band with a mix of ordinary line-of-sight operation and interesting propagation such as sporadic-E and meteor scatter. Availability differs by country, so the band is less universal than 2 m or 70 cm.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 70.000-70.090 MHz | 1000 Hz | MGM and telegraphy; coordinated beacons | |
| 70.090-70.100 MHz | 1000 Hz | MGM and telegraphy; temporary and personal beacons; 70.091 MHz personal WSPR beacons | |
| 70.100-70.250 MHz | 2700 Hz | SSB, telegraphy, MGM; 70.185 MHz crossband centre; 70.200 MHz CW/SSB calling centre; 70.250 MHz MS centre | |
| 70.250-70.294 MHz | 12 kHz | AM and FM; 70.260 MHz AM/FM calling; 70.270 MHz MGM centre | |
| 70.294-70.500 MHz | 12.5 kHz spacing | FM channels; 70.3125 MHz digital communications; 70.3250 MHz digital communications; 70.4500 MHz FM calling; 70.4875 MHz digital communications | |
### 144 MHz (2 m, VHF, UKW)
The 2 m band is the everyday VHF band for many amateurs. The lower part is where weak-signal work, CW, narrow digital modes, and SSB live; FM simplex and repeaters are higher up; space and satellite activity sit at the top. This layout is heavily tested in the exam catalog.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 144.000-144.025 MHz | 2700 Hz | All modes; satellite downlink only | |
| **144.025-144.100 MHz** | **500 Hz** | **MGM and telegraphy; 144.050 MHz telegraphy centre of activity** | `BC213` |
| **144.100-144.150 MHz** | **500 Hz** | **MGM and telegraphy; EME activity around 144.110-144.160 MHz** | `BC214` |
| **144.150-144.400 MHz** | **2700 Hz** | **MGM, telegraphy, and SSB; 144.195-144.205 MHz MS centre** | `BC210` |
| **144.300 MHz** | 2700 Hz | **SSB centre of activity / calling frequency** | `BC211` |
| **144.400-144.490 MHz** | **500 Hz** | **MGM and telegraphy; beacons exclusive** | `BC215` |
| 144.491-144.493 MHz | - | Personal weak-signal MGM / experimental MGM | |
| 144.500-144.794 MHz | 20 kHz | All modes; 144.500 MHz image; 144.600 MHz data; 144.750 MHz ATV | |
| 144.794-144.9625 MHz | 12 kHz | Digital communications | |
| 144.975-145.194 MHz | 12 kHz | FM/DV repeater input channels | |
| 145.194-145.206 MHz | 12 kHz | Space communication | |
| **145.206-145.5625 MHz** | **12 kHz** | **FM/DV simplex channels; 145.2375, 145.2875, and 145.3375 MHz FM internet voice gateways** | `BC209` |
| **145.375 MHz** | 12 kHz | **Digital voice calling** | `BC207` |
| **145.500 MHz** | 12 kHz | **FM calling frequency** | `BC205` |
| **145.525 MHz** | 12 kHz | **Narrow FM area; do not occupy more than about 12 kHz bandwidth** | `BC216` |
| **145.575-145.7935 MHz** | **12 kHz** | **FM/DV repeater output channels** | `BC217` |
| **145.794-145.806 MHz** | **12 kHz** | **Space communication** | `BC218` |
| 145.806-146.000 MHz | - | Satellite exclusive | |
## UHF Band Plan
### 430-440 MHz (70 cm, UHF, Dezimeterwelle)
The 70 cm band is the everyday UHF companion to 2 m. It is more line-of-sight and more affected by buildings and terrain, but antennas are smaller and repeaters, digital voice, and satellite work are common. Weak-signal activity is near 432 MHz, while 435-438 MHz is satellite territory.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 430.000-431.975 MHz | 20 kHz | All modes; subregional planning; FM repeater outputs, digital communications, multimode channels, repeater inputs | |
| **432.000-432.100 MHz** | **500 Hz** | **MGM and telegraphy; 432.050 MHz telegraphy centre of activity** | `BC219` |
| 432.100-432.400 MHz | 2700 Hz | MGM, telegraphy, and SSB; **432.200 MHz SSB centre of activity**; 432.350 MHz microwave talkback; 432.370 MHz meteor scatter | `BC212` |
| **432.400-432.490 MHz** | **500 Hz** | **MGM and telegraphy; beacons exclusive** | `BC220` |
| 432.191-432.193 MHz | 500 Hz | Experimental MGM | |
| 432.500-432.975 MHz | 12 kHz | All modes; 432.500 MHz new APRS frequency; 432.600-432.9875 MHz repeater input, Region 1 standard, 25 kHz spacing, 2 MHz shift | |
| 433.000-433.375 MHz | 12 kHz | FM / digital voice repeaters; repeater input, Region 1 standard, 25 kHz spacing, 1.6 MHz shift | |
| 433.400-433.575 MHz | 12 kHz | FM / digital voice; 433.400 MHz SSTV FM/AFSK | |
| **433.450 MHz** | 12 kHz | **Digital voice calling** | `BC208` |
| **433.500 MHz** | 12 kHz | **FM calling** | `BC206` |
| 433.600-434.000 MHz | none | All modes; 433.625-433.775 MHz digital communication channels; 434.000 MHz centre frequency of digital experiments | |
| 434.000-434.594 MHz | 12 kHz | All modes / ATV; 434.450-434.575 MHz digital communication channels | |
| 434.594-434.981 MHz | 12 kHz | All modes; 434.600-434.9875 MHz repeater output, 12.5 kHz spacing, 1.6 or 2 MHz shift | |
| **435.000-436.000 MHz** | none | **Satellite service** | `BC221` |
| **436.000-438.000 MHz** | none | **Satellite service and DATV/data** | `BC221` |
| 438.000-440.000 MHz | none | All modes; digital communication channels, repeater channels, multimode, links | |
| **438.650-439.425 MHz** | none | **Repeater output channels, 7.6 MHz shift** | `BC222` |
### 1240-1300 MHz (23 cm, UHF, Dezimeterwelle)
The 23 cm band starts to feel like microwave operating while still being accessible to many stations. It is used for weak-signal work around 1296 MHz, repeaters, ATV/DATV, data links, and satellite segments. Directional antennas become small enough to be practical.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 1240.000-1240.500 MHz | 2700 Hz | All modes; reserved for the future | |
| 1240.500-1240.750 MHz | 500 Hz | MGM and telegraphy; beacons, reserved for the future | |
| 1240.750-1241.000 MHz | 20 kHz | FM / digital voice; reserved for the future | |
| 1241.000-1243.250 MHz | 20 kHz | All modes; 1242.025-1242.250 MHz repeater output; 1242.275-1242.700 MHz repeater output; 1242.725-1243.250 MHz digital communications | |
| 1243.250-1260.000 MHz | national bandwidth limits | (D)ATV; 1258.150-1259.350 MHz repeater output | |
| 1260.000-1270.000 MHz | national bandwidth limits | Satellite service | |
| 1270.000-1272.000 MHz | 20 kHz | All modes; 1270.025-1270.700 MHz repeater input; 1270.725-1271.250 MHz digital communication | |
| 1272.000-1290.994 MHz | national bandwidth limits | (D)ATV | |
| 1290.994-1291.481 MHz | 20 kHz | FM / digital voice; repeater input, 25 kHz spacing | |
| 1291.494-1296.000 MHz | national bandwidth limits | All modes; 1293.150-1294.350 MHz repeater input R20-R68 | |
| 1296.000-1296.150 MHz | 500 Hz | MGM and telegraphy; 1296.000-1296.025 MHz moonbounce; 1296.138 MHz PSK31 centre of activity | |
| 1296.150-1296.800 MHz | 2700 Hz | MGM, telegraphy, and SSB; 1296.200 MHz narrow-band centre; 1296.400-1296.600 MHz linear transponder input; 1296.500 MHz fax; 1296.600 MHz narrow-band data centre; 1296.600-1296.700 MHz linear transponder output; 1296.741-1296.743 MHz experimental MGM; 1296.750-1296.800 MHz local beacons | |
| 1296.800-1296.994 MHz | 500 Hz | MGM and telegraphy; beacons exclusive | |
| 1296.994-1297.481 MHz | 20 kHz | FM / digital voice; repeater output, 25 kHz spacing | |
| 1297.494-1297.981 MHz | 20 kHz | FM / digital voice; 1297.500 MHz SM20 and FM activity centre; 1297.725 MHz digital voice calling; 1297.900-1297.975 MHz simplex FM internet gateways; 1297.975 MHz SM39 | |
| 1298.000-1299.000 MHz | 20 kHz | All modes; mixed analogue/digital use, 25 kHz spacing channels; 1298.025 MHz RS1 to 1298.975 MHz RS39 | |
| 1299.000-1299.750 MHz | 150 kHz | All modes; five high-speed digital data channels centred on 1299.075, 1299.225, 1299.375, 1299.525, and 1299.675 MHz | |
| 1299.750-1300.000 MHz | 20 kHz | All modes; eight 25 kHz channels available for FM/DV use, centres 1299.775-1299.975 MHz | |
### 2300-2450 MHz (13 cm, UHF, Dezimeterwelle)
The 13 cm band is mostly line-of-sight and has a microwave operating style. It supports narrowband work, ATV/data segments, and amateur satellite use near 2400 MHz. Antenna aiming and local obstacles matter much more than on HF.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 2300.000-2320.000 MHz | 20 kHz | All modes; 2304-2306 MHz narrow-band segment where 2320-2322 MHz is unavailable; 2308-2310 MHz narrow-band segment in HB | |
| 2320.000-2320.800 MHz | none | All modes; 2320.000-2320.025 MHz EME; 2320.200 MHz SSB centre; 2320.750-2320.800 MHz local beacons, 10 W ERP maximum | |
| 2320.800-2321.000 MHz | - | MGM and telegraphy; beacons exclusive | |
| 2321.000-2322.000 MHz | 20 kHz | FM / digital voice; voice simplex and repeaters | |
| 2322.000-2400.000 MHz | none | All modes; 2322.000-2355.000 MHz ATV; 2355.000-2365.000 MHz digital communications; 2365.000-2370.000 MHz repeaters; 2370.000-2392.000 MHz ATV; 2392.000-2400.000 MHz digital communications | |
| 2400.000-2450.000 MHz | - | Amateur satellite service; 2400-2402 MHz narrow-band segment where 2320-2322 MHz is unavailable; 2427.000-2443.000 MHz ATV if no satellite uses the segment | |
## SHF Band Plan
### 3400-3475 MHz (9 cm, SHF, Zentimeterwelle)
The 9 cm band is an SHF microwave band where line-of-sight paths, beam antennas, and beacons become central. It is a band for experimenters: small antennas can have useful gain, but accurate pointing and low-loss feedlines matter.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 3400.000-3400.800 MHz | 500 Hz | MGM and telegraphy; 3400.100 MHz EME centre; 3400.750-3400.800 MHz local beacons | |
| 3400.800-3400.995 MHz | 500 Hz | MGM and telegraphy; beacons only | |
| 3401.000-3402.000 MHz | 2700 Hz | All modes | |
| 3402.000-3410.000 MHz | none | All modes; satellite downlinks | |
| 3410.000-3475.000 MHz | none | All modes | |
### 5650-5850 MHz (6 cm, SHF, Zentimeterwelle)
The 6 cm band is used for microwave experiments, data, ATV, and satellite uplink/downlink segments. Antennas are compact and directional, so even portable stations can use meaningful gain. Propagation is mostly optical-path style with occasional enhancements.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 5650.000-5668.000 MHz | 2700 Hz | All modes; amateur satellite service uplink | |
| 5668.000-5670.000 MHz | 2700 Hz | All modes; 5668.200 MHz narrow-band centre; amateur satellite service uplink | |
| 5670.000-5700.000 MHz | none | MGM | |
| 5720.000-5760.000 MHz | none | All modes | |
| 5760.000-5760.800 MHz | 2700 Hz | All modes; 5760.200 MHz narrow-band centre; 5760.750-5760.800 MHz local beacon | |
| 5760.800-5760.990 MHz | none | MGM and telegraphy; beacons only | |
| 5761.000-5762.000 MHz | 2700 Hz | All modes | |
| 5762.000-5790.000 MHz | none | All modes | |
| 5790.000-5850.000 MHz | none | All modes; amateur satellite service downlink | |
### 10-10.500 GHz (3 cm, SHF, Zentimeterwelle)
The 3 cm band is one of the classic amateur microwave bands. The narrowband activity centre around 10.368 GHz is a key anchor. Operation is strongly line-of-sight, and terrain, rain, equipment stability, and dish alignment all become part of the skill.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 10000.000-10150.000 MHz | none | MGM | |
| 10150.000-10250.000 MHz | none | All modes | |
| 10250.000-10350.000 MHz | none | MGM | |
| 10350.000-10368.000 MHz | none | All modes | |
| 10368.000-10368.800 MHz | 2700 Hz | All modes; 10368.200 MHz narrow-band centre; 10368.750-10368.800 MHz local beacon | |
| 10368.800-10368.990 MHz | - | Beacons only | |
| 10369.000-10370.000 MHz | 2700 Hz | All modes | |
| 10370.000-10450.000 MHz | - | All modes | |
| 10450.000-10500.000 MHz | - | All modes; 10450-10452 MHz narrow-band modes where 10368-10370 MHz is unavailable; amateur satellite service | |
### 24-24.250 GHz (1.2 cm, SHF, Zentimeterwelle)
The 1.2 cm band is high microwave territory. Contacts are usually short-path or carefully planned longer line-of-sight attempts, and accurate antenna pointing is critical. The lower edge carries narrowband and satellite-related activity.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 24000.000-24048.000 MHz | - | All modes; 24.025 GHz wideband centre; amateur satellite service | |
| 24048.000-24048.800 MHz | 2700 Hz | All modes; 24.0482 GHz narrow-band centre; amateur satellite service narrow-band modes; 24048.750-24048.800 MHz local beacon | |
| 24048.800-24048.995 MHz | - | All modes; beacons only | |
| 24049.000-24050.000 MHz | 2700 Hz | All modes; amateur satellite service; narrow-band modes | |
| 24050.000-24250.000 MHz | - | All modes | |
## Microwave Band Plan
### 47.0-47.2 GHz (6 mm, EHF, Millimeterwelle)
The 6 mm band is a millimeter-wave experimental band. Signals are highly directional, paths are usually short, and weather plus equipment stability become major factors. This is specialist territory rather than routine communication.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 47.000-47.088 GHz | none | All modes | |
| 47.088-47.090 GHz | 2700 Hz | All modes | |
| 47.090-47.200 GHz | none | All modes | |
### 75.5-81.5 GHz (4 mm, EHF, Millimeterwelle)
The 4 mm band is for specialist millimeter-wave experiments. Antennas are very directional, atmospheric absorption and weather are important, and successful contacts often depend on careful path planning and precise alignment.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 75.500-76.000 GHz | 2700 Hz | All modes; amateur satellite service preferred; 75.976200 GHz preferred narrow-band centre | |
| 76.000-77.500 GHz | none | All modes; 76.032200 GHz narrow-band centre in some countries, not preferred | |
| 77.500-77.501 GHz | 2700 Hz | All modes; 77.500200 GHz preferred narrow-band centre outside CEPT area; amateur satellite service | |
| 77.501-78.000 GHz | none | All modes; preferred segment | |
| 78.000-81.500 GHz | none | All modes; not preferred segment | |
### 122-123 GHz (2.5 mm, EHF, Millimeterwelle)
The 2.5 mm band is a very high millimeter-wave experimental band. Most activity is short line-of-sight work with narrowband techniques, where building stable equipment and proving the path are the main challenges.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 122.250-122.251 GHz | 2700 Hz | All modes; narrow-band modes | |
| 122.251-123.000 GHz | none | All modes | |
### 134-141 GHz (2 mm, EHF, Millimeterwelle)
The 2 mm band is a specialist millimeter-wave range. Short-distance experiments, precise antenna alignment, frequency stability, and low-loss construction dominate the operating experience.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 134.000-134.928 GHz | none | All modes; amateur satellite service | |
| 134.928-134.930 GHz | 2700 Hz | All modes; 134.930 GHz narrow-band centre | |
| 134.930-136.000 GHz | none | All modes | |
| 136.000-141.000 GHz | none | All modes; not preferred segment | |
### 241-250 GHz (1.2 mm, EHF, Millimeterwelle)
The 1.2 mm band is at the extreme end of amateur millimeter-wave operation. It is almost entirely experimental, with short paths, severe atmospheric effects, and demanding equipment constraints. Contacts here are engineering achievements as much as radio QSOs.
| Frequency segment | Max bandwidth | Recommendation | Questions |
|---|---|---|---|
| 241.000-248.000 GHz | none | All modes; not preferred segment | |
| 248.000-248.001 GHz | none | All modes; amateur satellite service and narrow-band modes | |
| 248.001-250.000 GHz | none | All modes; preferred segment | |
## Exam-Highlighted Entries
| Question | What is being asked | Correct answer | How to memorize |
|---|---|---|---|
| `BC201` | Whether IARU band plans are legally binding or recommendations. | They are recommendations whose observance benefits all radio amateurs. | IARU = coordination, not law. Law is AFuV/BNetzA; IARU is "please use the band this way". |
| `BC202` | Which sideband is normally used for SSB voice on 80 m. | LSB. | 80 m is below 10 MHz, so use the "low" sideband: LSB. |
| `BC203` | Which sideband is normally used for SSB voice on 20 m. | USB. | 20 m is above 10 MHz, so use the "upper" sideband: USB. |
| `BC204` | Where IARU usually puts Morse telegraphy inside amateur bands. | At the beginning / lower end of the band. | Morse is narrow and goes first: low edge = CW edge. |
| `BC205` | General analog FM calling frequency on 2 m. | 145.500 MHz. | 2 m FM calling is the classic "145 five hundred". |
| `BC206` | General analog FM calling frequency on 70 cm. | 433.500 MHz. | Same pattern as 2 m: analog FM calling ends in .500, here 433.500. |
| `BC207` | General digital voice calling frequency on 2 m. | 145.375 MHz. | Digital voice is below analog FM calling: 145.375 comes before 145.500. |
| `BC208` | General digital voice calling frequency on 70 cm. | 433.450 MHz. | Digital voice is just below analog FM calling: 433.450 before 433.500. |
| `BC209` | Which listed 2 m frequency is suitable for an FM voice QSO. | 145.450 MHz. | 145.206-145.5625 is the 2 m FM/DV simplex area; 145.450 sits safely inside it. |
| `BC210` | Which listed 2 m frequency is suitable for an SSB voice QSO. | 144.310 MHz. | SSB on 2 m clusters around 144.300; 144.310 is just next to the SSB centre. |
| `BC211` | 2 m SSB voice activity/calling centre. | 144.300 MHz. | "Two-meter SSB is 144.300" is the main anchor for the whole 2 m weak-signal area. |
| `BC212` | 70 cm SSB voice activity/calling centre. | 432.200 MHz. | 70 cm SSB mirrors 2 m weak-signal style: 432.200 is the SSB centre. |
| `BC213` | Why not use RTTY/PSK31/FT8 on 144.075 MHz. | That range is preferred for Morse telegraphy. | 144.075 is very low in the 2 m band, inside the CW/narrow area. |
| `BC214` | Why not use FM direct voice on 144.125 MHz. | The range is for Morse telegraphy and narrow-band digital modes. | 144.125 is still below the 144.150 SSB boundary: too low for FM. |
| `BC215` | Why not use FM direct voice on 144.450 MHz. | The range is reserved for beacons. | After the 2 m SSB area, 144.400-144.490 is the beacon block. |
| `BC216` | Why use narrow FM around 145.525 MHz. | IARU recommends not occupying more than about 12 kHz bandwidth there. | 145 MHz FM channels are narrow FM; think 12.5 kHz channel spacing. |
| `BC217` | Why not use 145.600 MHz for a local FM direct QSO. | It is in the repeater-output range. | 2 m repeater outputs begin at 145.575 MHz, so 145.600 is already in that block. |
| `BC218` | Why not use 145.800 MHz for a local FM direct QSO. | It is in the space communication range. | The top of 2 m is space/satellite: 145.800 is in the narrow space-communication slice just before the satellite-exclusive range. |
| `BC219` | Why not use 432.040 MHz for local FM voice. | It is in the MGM/telegraphy range. | 432.040 is near the bottom of 70 cm; low edge means CW/narrow. |
| `BC220` | Why not use 432.450 MHz for local FM voice. | It is in the beacon-exclusive range. | 70 cm beacon block is 432.400-432.490, just above SSB. |
| `BC221` | Why not use 435.500 MHz for local FM voice. | It is in the satellite service range. | On 70 cm, the middle block 435-438 MHz is satellite territory. |
| `BC222` | Why not use 439.200 MHz for local FM voice. | It is in the repeater-output range. | High 70 cm around 438.650-439.425 is repeater output; 439.200 is inside it. |
| `BE410` | Why specific small HF windows around 14, 18, 21, 24, and 28 MHz should be kept free. | They are International Beacon Project ranges and reserved for beacon observation. | The IBP windows are tiny slices near the upper end of the CW/narrow part of each HF band: 14099-14101, 18109-18111, 21149-21151, 24929-24931, 28190-28225 kHz. |
| `BF109` | What is special about 3760, 7110, 14300, 18160, and 21360 kHz. | They are IARU Region 1/global emergency centres of activity and should be kept available for emergency traffic. | Learn them as the emergency ladder: 3.760, 7.110, 14.300, 18.160, 21.360 MHz. |
+214
View File
@@ -0,0 +1,214 @@
# Hilfsmittel — official exam aid (Formelsammlung + tables)
Catalog of everything in the BNetzA exam aid sheet that candidates are
**allowed to use during the exam**, with the **printed page numbers** as
they appear on each page. This file is the source of truth for the
`<u>Hilfsmittel:</u>` notes appended to explanations (see
`EXPLANATIONS.md`): a note may only cite a formula/table that is listed
here, and should name it and its page.
- Source PDF: `Hilfsmittel_12062024.pdf`
(https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/AntraegeFormulare/Hilfsmittel_12062024.pdf?__blob=publicationFile&v=3)
- **Page numbering:** the printed page number = PDF page 2 (the cover
and the "Hinweis" page are unnumbered). All "S. N" below are the
printed numbers.
- Errata (per the PDF "Hinweis" page): on S. 15 the parabolic-dish gain
formula, on S. 17 the SWR formula, and on S. 21 the specific-resistance
table (Zink → Zinn) were corrected against the 3rd-edition catalog.
If the fact a question needs is **not** in this list (e.g. diode forward
voltages ~0.6 V / ~0.3 V, tan δ = 1/Q, semiconductor behaviour,
definitions), it is a memory item — **do not** add a Hilfsmittel note.
---
## S. 13 — Frequenzbereichszuweisung (Anlage 1, Amateurfunkverordnung)
- **S. 1 — Nutzungsbedingungen (¶14):** general usage rules (incl. the
**50 W ERP** cap for remote/automatic terrestrial stations above 30 MHz,
with a possible **1000 W ERP** exception for links) and, in ¶3, the
**definitions** of a *primärer* and *sekundärer Funkdienst* and their
protection/priority rights. This is a *text* page, not a table. (No
750 W figure appears here — 750 W PEP is a class-A limit in the S. 2
table.)
- **S. 2 — Tabellarische Übersicht:** per frequency band: status
(primary/secondary), the allowed frequency ranges, max. **power per
licence class (A/E/N) — in PEP or ERP exactly as printed** (not EIRP),
and additional-usage references.
Note the units: class N on **10 m is 10 W ERP**, but on **2 m/70 cm it
is 6,1 W ERP** (= ≈10 W EIRP after ×1,64, see S. 15) — the table does
*not* print "10 W EIRP". Power limits are in PEP/ERP, never EIRP.
- **S. 3 — Zusätzliche Nutzungsbestimmungen (numbered conditions):**
max. occupied **bandwidth** per band (800 Hz, 2,7 kHz, 7 kHz, 40 kHz,
2 MHz …), and special power limits such as the 12471263 MHz
**3,05 W ERP** sub-band cap (= ≈5 W EIRP). The bandwidth values live in
these numbered conditions, not in the S. 2 table.
Not in Anlage 1: the general HF/VHF/UHF (KW/UKW) range *terminology*,
the ISM-band *definition*, and foreign-country prefixes (Landeskenner) —
those are memory items.
## S. 47 — Rufzeichenplan für den Amateurfunkdienst in Deutschland
- **S. 4** — intro: structure of German call signs (prefix DADR excl.
DE/DI, digit, 23-char suffix).
- **S. 5 — Tabelle "Rufzeichen mit 2- oder 3-buchstabigen Suffixen":**
the main **call-sign-series → class/use** table. DA0 = Klubstation;
DL1DL9 = person-bound class A; DO1DO9 = class E; DN9 = class N;
DP is intended for exterritorial-location call signs; DP0DP1 =
class A with use categories KS, RL/FB and SZ; DP2 = class E with the
same use categories; DP8 = class N with the same use categories.
- **S. 6** — club-station suffixes (1-char) table.
- **S. 7** — club-station suffixes (47-digit); **§ 5** Kurzzeitzulassungen
ausländischer Funkamateure (visitors in Germany sign **DL/** class A,
**DO/** class E); **§ 6** Kennungen zum Betrieb leistungsschwacher Sender
(beacon/peil identifiers **MO, MOE, MOI, MOS, MOH, MO5**); §§ 78 rules.
## S. 8 — International gebräuchliche Rufzeichenzusätze / Ausbildung / Remote
- **§ 9** operating suffixes appended with `/`: `/m` (mobile, incl.
inland-waterway), `/mm` (maritime mobile), `/am` (aeronautical mobile),
`/p` (portable/temporary fixed; optional in Germany), etc.
- **§ 10** Ausbildungsfunkbetrieb (`/Trainee` speech, `/T` CW/digital).
- **§ 11** Remotebetrieb (`Remote` speech, `/R` CW/digital).
Foreign-country prefixes (e.g. Swiss HB3/HB9 for a German operating
*abroad*) are **not** in this plan — memory item.
## S. 910 — IARU-Bandplan
- **S. 9 — 2 m** and **S. 10 — 70 cm**: per segment, max bandwidth,
preferred mode and usage (CW, SSB, FM, digital, beacons, satellite…).
## S. 11 — Formelsammlung: Grundlagen / Widerstände
- Zehnerpotenzen & SI-Präfixe (p…T) — table.
- Zweierpotenzen / Bit (2^0…2^12) — table.
- **Ohmsches Gesetz:** `U = R·I`, `R = U/I`, `I = U/R`.
- **Innenwiderstand:** `R_i = ΔU/ΔI`.
- **Widerstand von Drähten:** `R = ρ·l/A_Dr`, `A_Dr = d²·π/4 = r²·π`.
- **Widerstands-Farbcode** — table (Farbe / Wert / Multiplikator / Toleranz).
## S. 12 — Widerstandsnetzwerke / Leistung / Wechselspannung
- **Reihenschaltung:** `R_G = R1+R2+…+R_N`; 2 R: `R_G = R1+R2`.
- **Parallelschaltung:** `1/R_G = 1/R1+1/R2+…`; 2 R: `R_G = R1·R2/(R1+R2)`.
- **Spannungsteiler (unbelastet):** `U_G = U1+U2`, `U1/U2 = R1/R2`,
`U2/U_G = R2/(R1+R2)`.
- **Stromteiler:** `I_G = I1+I2`, `I2/I1 = R1/R2`.
- **Vorzugsreihen** E6/E12/E24 — table.
- **Leistung:** `P = U·I = U²/R = I²·R`; `U = P/I = √(P·R)`;
`I = P/U = √(P/R)`.
- **Arbeit/Energie:** `W = P·t`.
- **Wirkungsgrad:** `η = P_ab/P_zu (·100 %)`; `P_ab = P_zu P_V`.
- **Wechselspannung:** `Û = U_eff·√2`, `U_SS = 2·Û`, `ω = 2·π·f`,
`Z = √(R²+X²)`, `T = 1/f`, `f = 1/T`.
## S. 13 — Induktivität / Transformator / Kapazität
- **Induktiver Blindwiderstand:** `X_L = ω·L`.
- **L Reihe:** `L_G = L1+…+L_N`; **L Parallel:** `1/L_G = 1/L1+…`.
- **Ringspule / lange Zylinderspule:** `L = μ0·μr·N²·A_S / l(_m)`.
- **Ringkernspulen (auch mehrlagig):** `L = N²·A_L`.
- **Magnetische Feldstärke (Ringspule):** `H = I·N/l_m`.
- **Magnetische Flussdichte:** `B_m = μr·μ0·H`.
- **Transformator-Übersetzungsverhältnis:**
`ü = N_P/N_S = U_P/U_S = I_S/I_P = √(Z_P/Z_S)`.
- **Belastbarkeit von Wicklungen:** `I = S·A_Dr` mit `S ≈ 2,5 A/mm²`.
- **Kapazitiver Blindwiderstand:** `X_C = 1/(ω·C)`.
- **C Reihe:** `1/C_G = 1/C1+…`; **C Parallel:** `C_G = C1+…`.
- **E-Feld im homogenen Feld:** `E = U/d`.
- **Kapazität Kondensator:** `C = ε0·εr·A/d`.
## S. 14 — Filter / Schwingkreis / Transistor / ZF & Spiegelfrequenz
- **RC TP/HP:** `f_g = 1/(2π·R·C)`; **RL TP/HP:** `f_g = R/(2π·L)`
(`f_g` = 3 dB-Grenzfrequenz).
- **Schwingkreis:** `f0 = 1/(2π·√(L·C))`, Resonanz `X_C = X_L`.
- **Reihenschwingkreis:** `B = R_s/(2π·L)`, `Q = f0/B = X_L/R_s`.
- **Parallelschwingkreis:** `B = 1/(2π·R_p·C)`, `Q = f0/B = R_p/X_L`.
- **Transistor (DC):** `B = I_C/I_B`, `I_E = I_C+I_B`.
- **Transistor (AC):** `v_I = β = ΔI_C/ΔI_B`, `v_U = β = ΔU_CE/ΔU_BE`,
`v_P = β² = v_U·v_I`. *(Transcribed verbatim. The printed
`v_U = β` is physically dubious — voltage gain is not generally the
current gain — but the aid prints it this way; do not silently
"correct" it.)*
- **Zwischenfrequenz:** `f_ZF = |f_E f_OSZ|` (the sheet notes that the
`f_ZF = f_E + f_OSZ` case is not considered).
- **Spiegelfrequenz:** `f_S = 2·f_OSZ f_E`.
## S. 15 — Pegel / Antennen
- **Pegel:** `p = 10·log10(P/1mW)` dBm; `p = 10·log10(P/1W)` dBW;
`u = 20·log10(U/0,775V)` dBu.
- **Verstärkung/Gewinn:** `g = 10·log10(P2/P1)` dB;
`g = 20·log10(U2/U1)` dB.
- **Dämpfung/Verluste:** `a = 10·log10(P1/P2)`; `a = 20·log10(U1/U2)`.
- **dB ↔ Verhältnis-Tabelle** — exactly **11 rows** (Leistungs- /
Spannungsverhältnis): 20 (0,01/0,1), 10 (0,1/0,32), 6 (0,25/0,5),
3 (0,5/0,71), 1 (0,79/0,89), 0 (1/1), +1 (1,26/1,12), +3 (2/1,41),
+6 (4/2), +10 (10/3,16), +20 (100/10). **There is no 16 dB row** (or
any value off this list): combine rows, e.g. 16 dB = +10 dB (×10) +
+6 dB (×4) → ×40.
- **ERP:** `p_ERP = p_S a + g_d`; `P_ERP = P_S·10^((g_da)/10dB)`.
- **EIRP:** `p_EIRP = p_ERP + 2,15 dB`; `P_EIRP = P_ERP·1,64`.
- **Feldstärke Fernfeld:** `E = √(30Ω·P_A·G_i)/d = √(30Ω·P_EIRP)/d`
(ab `d > λ/(2π)`).
- **Gewinn:** `G_i = G_d·1,64`, `g_i = g_d + 2,15 dB`, `G = 10^(g/10dB)`.
- Halbwellendipol `G_i=1,64 / g_i=2,15 dB`; λ/4-Vertikal mit
Bodenreflexion `G_i=3,28 / g_i=5,15 dB`; Parabolspiegel
`g_i = 10·log10[(π·d/λ)²·η] dB`.
## S. 16 — Rauschen / Amplitudenmodulation / Frequenzmodulation
- **Thermisches Rauschen:** `P_R = k·T_K·B`; `Δp_R = 10·log10(B1/B2)`;
`U_R = 2·√(P_R·R)`.
- **Rauschzahl:** `F = (P_S/P_N)_Eingang/(P_S/P_N)_Ausgang`;
`a_F = 10·log10(F)`; `a_F = SNR_Eingang SNR_Ausgang`.
- **SNR:** `SNR = 10·log10(P_S/P_N) = 20·log10(U_S/U_N)`.
- **Shannon-Hartley:** `C = B/1Hz · log2(1 + P_S/P_N)` bit/s.
- **log2:** `log2(x) = log10(x)/log10(2)`.
- **AM Modulationsgrad:** `m = Û_mod/Û_T`; **AM Bandbreite:**
`B = 2·f_mod,max`.
- **FM Modulationsindex:** `m = Δf_T/f_mod`; **Carson-Bandbreite:**
`B ≈ 2·(Δf_T + f_mod,max)`.
## S. 17 — Wellenlänge & Frequenz / Reflexion / Wellenwiderstand
- **c = f·λ**, `f = c/λ`, `λ = c/f`; `c0 ≈ 3·10⁸ m/s`;
`f[MHz] ≈ 300/λ[m]`, `λ[m] ≈ 300/f[MHz]`.
- **Verkürzungsfaktor:** `k_v = l_G/l_E = 1/√εr = c/c0`.
- **Stehwellenverhältnis (SWR):**
`s = U_max/U_min = (U_v+U_r)/(U_vU_r) = (√P_v+√P_r)/(√P_v−√P_r) = (1+|r|)/(1|r|)`;
`s = R2/Z` (R2>Z) bzw. `s = Z/R2` (R2<Z).
- **Reflexionsfaktor:** `|r| = (s1)/(s+1) = |(R2Z)/(R2+Z)| = |U_r/U_v| = √(P_r/P_v)`.
- **Rücklaufende Leistung:** `P_r = P_v·|r|²`.
- **An R2 abgegebene Leistung:** `P_ab = P_v·(1|r|²)`.
- **Wellenwiderstand:** HF-Leitung `Z = √(L'/C')`; Koax
`Z = 60Ω/√εr·ln(D/d)`; symm. Zweidraht (a/d>2,5)
`Z = 120Ω/√εr·ln(2a/d)`; Viertelwellentransformator `Z = √(Z_E·Z_A)`.
## S. 18 — Weitere Formeln
- **MUF:** `MUF ≈ f_c/sin(α)`; `f_opt = MUF·0,85`.
- **Empfindlichkeit von Messsystemen:** `E_MESS = R_i/U_i = 1/I_i`.
- **Relativer maximaler Fehler:** `F_W = ±(G/100)·(W_E/W_M)`.
- **Abtasttheorem:** `f_abtast > 2·f_max`; für Nicht-Basisband-Signale
`f_abtast > 2·(f_max f_min)` **wenn `f_abtast < f_min` oder
`f_abtast > f_max`**.
- **Datenübertragungs-/Symbolrate:** `C = R_S·n`.
## S. 1922 — Konstanten und Tabellen
- **S. 1920** — symbol/quantity glossary and physical constants
(k, c0, μ0, ε0, e, …).
- **S. 21** — material tables: **relative permittivity ε_r** and
**specific resistance ρ** (per the S. 21 erratum, Zink → Zinn).
- **S. 22 — Kabeldämpfungsdiagramm Koaxialkabel:** a chart of cable
attenuation in **dB per 100 m vs. frequency** for common coax types
(RG58 ≈ 4,95 mm, RG174 ≈ 2,8 mm, PE-foam 10,3/12,7 mm, …). Read the
per-100 m loss at the operating frequency, then **scale linearly with
cable length**. This chart *is* part of the aid — cable-loss questions
are Hilfsmittel lookups.
</content>
</invoke>
+58
View File
@@ -0,0 +1,58 @@
# Q-Codes and Operating Shorthand
This reference summarizes Q-groups and similar short operating messages
that appear in the BNetzA 2024 exam question catalog in
`data/2024-03-20-3-auflage/fragenkatalog3b.json`.
Reference counts are the number of exam question records where the code
appears in the question text or answer choices. For very short ambiguous
codes such as `DE` and `K`, the count includes only procedural radio usage,
not unrelated uses such as country/prefix examples, units, or ordinary
words. Mode names and technical abbreviations such as `CW`, `SSB`, `FM`,
`QAM`, and `RTTY` are intentionally not included.
## Q-Groups
| Code | What it means when question | What it means when not in question | Explanation, when actually used | Usage example | Exam references | Question IDs |
|---|---|---|---|---|---|---|
| `QRM` | Is my transmission being interfered with? | Your transmission is being interfered with. | Formal Q-code perspective; amateurs also commonly say "I have QRM" for interference at their receiver. | DL1ABC: `QRM?` / DO2XYZ: `QRM, pse repeat report.` | 2 | `BB201`, `BB204` |
| `QRN` | Are you troubled by atmospheric noise? | I have atmospheric noise. | Used when static crashes or atmospheric noise make reception difficult. | DL1ABC: `QRN here, ur report?` / DO2XYZ: `RST 55 despite QRN.` | 1 | `BB201` |
| `QRO` | Shall I increase transmitter power? | Increase transmitter power; I am increasing power. | Used when more power may be needed, usually after trying normal operating improvements first. | DL1ABC: `QRO?` / DO2XYZ: `PSE QRO, ur weak.` | 1 | `BB202` |
| `QRP` | Shall I decrease transmitter power? | Decrease transmitter power. | Formal request to reduce power; in everyday amateur use it also describes low-power operation. | DL1ABC: `PSE QRP.` / DO2XYZ: `QRP 5 W now.` | 1 | `BB205` |
| `QRT` | Shall I stop transmitting? | Stop transmitting; I am stopping transmission. | Used to close down transmission or ask another station to stop transmitting. | DL1ABC: `I must QRT.` / DO2XYZ: `QSL, 73.` | 1 | `BB203` |
| `QRV` | Are you ready? | I am ready. | Used to ask if a station is ready, or to say that a station is available for traffic. | DL1ABC: `QRV?` / DO2XYZ: `QRV, go ahead.` | 2 | `BB204`, `BE107` |
| `QRX` | When will you call me again? | I will call you again at a stated time and frequency. | In normal amateur use, `QRX 5` also means to stand by for five minutes. | DL1ABC: `QRX 5 min.` / DO2XYZ: `QSL, standing by.` | 1 | `BB202` |
| `QRZ` | Who is calling me? | You are being called by ... | Used when a caller was not copied; DX and contest stations also use the question form to invite the next caller. | DL1ABC: `QRZ?` / DO2XYZ: `DO2XYZ calling.` | 6 | `BB203`, `BE101`, `BE104`, `BE107`, `BE112`, `BE115` |
| `QSB` | Is the strength of my signal fading? | Your signal strength is fading or fluctuating. | Used when signal strength rises and falls because of propagation. | DL1ABC: `QSB?` / DO2XYZ: `Yes, strong QSB, now 55.` | 2 | `BB201`, `BE107` |
| `QSL` | Can you acknowledge receipt? | I acknowledge receipt. | Confirms copied information; QSL cards and electronic QSLs are the later amateur contact-confirmation usage. | DL1ABC: `My QTH Berlin, QSL?` / DO2XYZ: `QSL, Berlin copied.` | 12 | `BB203`, `BE103`, `BE210`, `BE307`, `BG104`, `BG105`, `BG106`, `BG107`, `BG108`, `BG109`, `BG110`, `BG111` |
| `QSO` | Can you communicate with ... directly or by relay? | I can communicate with ... directly or by relay through ... | In everyday amateur speech and logging, a QSO means a radio contact. | DL1ABC: `Tnx for QSO.` / DO2XYZ: `Tnx also, 73.` | 9 | `BB202`, `BE108`, `BE110`, `BE303`, `BE308`, `BG107`, `BG109`, `NF112`, `NG110` |
| `QSY` | Shall I change to another frequency? | Change frequency; I am changing frequency. | Used to move a contact away from a calling frequency or crowded channel. | DL1ABC: `PSE QSY 145.525.` / DO2XYZ: `QSL, QSY now.` | 3 | `BB206`, `BE103`, `BE107` |
| `QTH` | What is your position or location? | My position or location is ... | Used to ask for or give the station location, often city, locator, or portable position. | DL1ABC: `QTH?` / DO2XYZ: `QTH Munich, JN58.` | 3 | `BB204`, `BE101`, `BE111` |
## Operating Abbreviations
| Code | What it means when question | What it means when not in question | Explanation, when actually used | Usage example | Exam references | Question IDs |
|---|---|---|---|---|---|---|
| `BK` | Not a Q-question form. | Break; signal to interrupt a running transmission, also used for informal handover. | Used in telegraphy to interrupt or quickly pass transmission without a formal over. | DL1ABC: `... running 10 W BK` / DO2XYZ: `BK, my PWR 5 W.` | 1 | `BB108` |
| `CQ` | Not a Q-question form. | General call to any station. With an added target, it limits the desired replies, for example `CQ DX` or `CQ DL`. | Used to start a contact when no specific station is being called. | DL1ABC: `CQ CQ CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k` | 18 | `BB102`, `BB104`, `BB105`, `BE101`, `BE102`, `BE104`, `BE105`, `BE108`, `BE109`, `BE110`, `BE112`, `BE113`, `BE114`, `BE116`, `BE304`, `BE309`, `BE310`, `BE311` |
| `DE` | Not a Q-question form. | From; this is. Used before the sending station's call sign in telegraphy. | Used between the called station and own call sign, especially in CW. | DL1ABC: `CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k` | 4 | `BE104`, `BE112`, `BE113`, `BE114` |
| `DX` | Not a Q-question form. | Long distance. In `CQ DX`, the requested distance depends on band and context: intercontinental on HF in the exam examples, several hundred km on VHF/UHF. | Used to seek or describe long-distance contacts. | DL1ABC: `CQ DX de DL1ABC k` / PY2XYZ: `DL1ABC de PY2XYZ k` | 14 | `AH105`, `AH106`, `AH107`, `BB103`, `BB104`, `BB105`, `BE109`, `BE114`, `BE312`, `BE410`, `EH102`, `EH104`, `EH201`, `VE301` |
| `FD` | Not a Q-question form. | Field Day; in `CQ FD ... TEST`, a Fieldday contest call. | Used during Fieldday contest operation to attract participating stations. | DL1ABC/P: `CQ FD de DL1ABC/P TEST` / DO2XYZ/P: `DL1ABC/P de DO2XYZ/P 59 001` | 1 | `BE116` |
| `K` | Not a Q-question form. | Invitation to transmit; over or go ahead in telegraphy. | Used at the end of a CW transmission to invite the other station to reply. | DL1ABC: `DO2XYZ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k` | 4 | `BB109`, `BE112`, `BE113`, `BE114` |
| `PSE` | Not a Q-question form. | Please. Used with another instruction, for example `PSE QRP` or `PSE QSY`. | Used as a polite request marker in short CW or mixed shorthand. | DL1ABC: `PSE QSY 145.525` / DO2XYZ: `QSL, QSY.` | 4 | `BB205`, `BB206`, `BE112`, `BE113` |
| `R` | Not a Q-question form. | Received; everything before it was copied correctly. Sent at the start of a transmission to acknowledge the previous one. | Used in telegraphy to confirm full receipt before continuing with own traffic. | DL1ABC: `R tnx report, ur RST 579` / DO2XYZ: `QSL, 73.` | 1 | `BB110` |
| `RST` | Not a Q-question form. | Reception report: readability, signal strength, and tone quality. In SSB only `R` and `S` are normally given. | Used to exchange signal reports during a QSO or contest. | DL1ABC: `ur RST 599` / DO2XYZ: `tnx, ur 579.` | 9 | `BE201`, `BE202`, `BE203`, `BE204`, `BE205`, `BE206`, `BE207`, `BE208`, `BE209` |
| `TEST` | Not a Q-question form. | Contest call indicator in the catalog example `CQ FD ... TEST`. | Used in contest calls to show the call is for contest contacts. | DL1ABC: `CQ TEST de DL1ABC` / DO2XYZ: `DL1ABC de DO2XYZ 59 012` | 1 | `BE116` |
| `UTC` | Not a Q-question form. | Coordinated Universal Time; koordinierte Weltzeit. | Used for logs, schedules and QSL confirmations so stations in different time zones record the same date and time. | Log: `2026-06-22 14:30 UTC` | 5 | `BF108`, `BG105`, `BG106`, `BG107`, `BG108` |
## Emergency and Safety Signals
These are not Q-groups. The catalog explicitly treats international
distress, urgency, and safety signals as outside normal amateur radio use.
| Code | What it means when question | What it means when not in question | Explanation, when actually used | Usage example | Exam references | Question IDs |
|---|---|---|---|---|---|---|
| `MAYDAY` | Not a Q-question form. | International distress signal outside amateur radio; the catalog says it must not be used within amateur radio traffic. | Used in maritime or aeronautical distress traffic, not in normal amateur radio traffic. | Vessel: `MAYDAY MAYDAY MAYDAY, this is DABC.` / Coast station: `DABC, this is Bremen Rescue, received MAYDAY.` | 5 | `BF101`, `BF102`, `BF103`, `BF104`, `VD105` |
| `SOS` | Not a Q-question form. | International distress signal outside amateur radio; the catalog says it must not be used within amateur radio traffic. | Used as an international distress signal, especially in Morse contexts outside amateur traffic. | Station: `SOS SOS SOS de DABC` / Coast station: `DABC de Rescue, SOS received.` | 2 | `BF101`, `BF102` |
| `PAN PAN` | Not a Q-question form. | International urgency signal; the catalog lists it among prohibited international emergency/urgency/safety signals in amateur radio traffic. | Used outside amateur radio for urgent situations that are serious but not yet distress. | Vessel: `PAN PAN, this is DABC, engine failure.` / Coast station: `DABC, report position.` | 1 | `VD105` |
| `SÉCURITÉ` | Not a Q-question form. | International safety signal; the catalog lists it among prohibited international emergency/urgency/safety signals in amateur radio traffic. | Used outside amateur radio for safety information such as navigation or weather warnings. | Coast station: `SECURITE, weather warning.` / Vessel: `Weather warning received.` | 1 | `VD105` |
+559
View File
@@ -0,0 +1,559 @@
{
"q_codes": [
{
"code": "QRG",
"question": "Will you tell me my exact frequency?",
"statement": "Your exact frequency is ... kHz or MHz.",
"exam": false,
"explanation": "Formal frequency-reporting Q-code. German-speaking amateurs also use 'QRG' informally as a noun meaning operating frequency, for example 'auf dieser QRG'.",
"example": "DL1ABC: `QRG?` / DO2XYZ: `Your QRG is 7.042 MHz.`"
},
{
"code": "QRL",
"question": "Are you busy?",
"statement": "I am busy; please do not interfere.",
"exam": false,
"explanation": "In amateur operation, QRL? is the standard short CW check meaning 'Is this frequency in use?' Listen first, send it once or twice, and wait before calling CQ.",
"example": "DL1ABC: `QRL?` / DO2XYZ: `QRL, in QSO.`"
},
{
"code": "QRM",
"question": "Is my transmission being interfered with?",
"statement": "Your transmission is being interfered with.",
"exam": true,
"explanation": "Interference from other stations or man-made sources, distinct from atmospheric static (QRN). In everyday amateur speech, 'I have QRM' is also common even though the formal statement addresses the other station's transmission.",
"example": "DL1ABC: `QRM?` / DO2XYZ: `QRM, pse repeat report.`"
},
{
"code": "QRN",
"question": "Are you troubled by atmospheric noise?",
"statement": "I am troubled by atmospheric noise (static).",
"exam": true,
"explanation": "Used when static crashes or atmospheric noise make reception difficult.",
"example": "DL1ABC: `QRN here, ur report?` / DO2XYZ: `RST 55 despite QRN.`"
},
{
"code": "QRO",
"question": "Shall I increase transmitter power?",
"statement": "Increase transmitter power.",
"exam": true,
"explanation": "Used when more power may be needed, usually after trying normal operating improvements first.",
"example": "DL1ABC: `QRO?` / DO2XYZ: `PSE QRO, ur weak.`"
},
{
"code": "QRP",
"question": "Shall I decrease transmitter power?",
"statement": "Decrease transmitter power.",
"exam": true,
"explanation": "Formal request to reduce power. In everyday amateur use, QRP also describes low-power operation—typically no more than 5 W output for CW/data or 10 W PEP for SSB under the common award convention.",
"example": "DL1ABC: `PSE QRP.` / DO2XYZ: `QRP 5 W now.`"
},
{
"code": "QRQ",
"question": "Shall I send faster?",
"statement": "Send faster (... words per minute).",
"exam": false,
"explanation": "Requests a faster Morse sending speed."
},
{
"code": "QRS",
"question": "Shall I send more slowly?",
"statement": "Send more slowly (... words per minute).",
"exam": false,
"explanation": "Requests a slower Morse sending speed; common courtesy to slower operators."
},
{
"code": "QRT",
"question": "Shall I stop sending?",
"statement": "Stop sending; I am closing down.",
"exam": true,
"explanation": "Used to close down transmission or ask another station to stop transmitting.",
"example": "DL1ABC: `I must QRT.` / DO2XYZ: `QSL, 73.`"
},
{
"code": "QRV",
"question": "Are you ready?",
"statement": "I am ready.",
"exam": true,
"explanation": "Used to ask if a station is ready, or to say that a station is available for traffic.",
"example": "DL1ABC: `QRV?` / DO2XYZ: `QRV, go ahead.`"
},
{
"code": "QRX",
"question": "When will you call me again?",
"statement": "I will call you again at ... hours on ... kHz or MHz.",
"exam": true,
"explanation": "The formal code schedules a later call. In normal amateur operation, QRX followed by a duration—such as 'QRX 5'—usually means 'stand by for five minutes'.",
"example": "DL1ABC: `QRX 5 min.` / DO2XYZ: `QSL, standing by.`"
},
{
"code": "QRZ",
"question": "Who is calling me?",
"statement": "You are being called by ...",
"exam": true,
"explanation": "Use QRZ? when a calling station was not copied. DX and contest stations also say QRZ? to invite the next caller; that widespread pile-up use is still the question form, not a separate statement meaning.",
"example": "DL1ABC: `QRZ?` / DO2XYZ: `DO2XYZ calling.`"
},
{
"code": "QSB",
"question": "Is the strength of my signal fading?",
"statement": "Your signal strength is fading or fluctuating.",
"exam": true,
"explanation": "Used when signal strength rises and falls because of propagation.",
"example": "DL1ABC: `QSB?` / DO2XYZ: `Yes, strong QSB, now 55.`"
},
{
"code": "QSK",
"question": "Can you hear me between your signals, and may I break in?",
"statement": "I can hear you between my signals; break in on my transmission.",
"exam": false,
"explanation": "Describes full break-in CW, where transmit/receive switching is fast enough to hear the band between one's own dots and dashes. Modern transceiver menus often label this feature QSK."
},
{
"code": "QSL",
"question": "Can you acknowledge receipt?",
"statement": "I acknowledge receipt.",
"exam": true,
"explanation": "On air it confirms that information was copied. A paper QSL card or electronic QSL is the later confirmation of a completed contact; that noun usage is practical amateur shorthand, not the formal statement text.",
"example": "DL1ABC: `My QTH Berlin, QSL?` / DO2XYZ: `QSL, Berlin copied.`"
},
{
"code": "QSO",
"question": "Can you communicate with ... directly or by relay?",
"statement": "I can communicate with ... directly or by relay through ...",
"exam": true,
"explanation": "The formal code concerns establishing communication. In everyday amateur speech and logging, a QSO simply means a completed or ongoing radio contact.",
"example": "DL1ABC: `Tnx for QSO.` / DO2XYZ: `Tnx also, 73.`"
},
{
"code": "QSY",
"question": "Shall I change to another frequency?",
"statement": "Change frequency; I am changing frequency.",
"exam": true,
"explanation": "Used to move a contact away from a calling frequency or crowded channel.",
"example": "DL1ABC: `PSE QSY 145.525.` / DO2XYZ: `QSL, QSY now.`"
},
{
"code": "QTC",
"question": "How many messages have you to send?",
"statement": "I have ... messages to send.",
"exam": false,
"explanation": "Used in traffic handling and contests to announce a count of formal messages."
},
{
"code": "QTH",
"question": "What is your position or location?",
"statement": "My position or location is ...",
"exam": true,
"explanation": "Used to ask for or give the station location, often city, locator, or portable position.",
"example": "DL1ABC: `QTH?` / DO2XYZ: `QTH Munich, JN58.`"
}
],
"abbreviations": [
{
"code": "73",
"meaning": "Best regards (friendly sign-off).",
"exam": false,
"explanation": "The standard friendly close of a contact. Always singular: 'best 73', never '73s'.",
"example": "DL1ABC: `tnx QSO, 73!` / DO2XYZ: `73 es cuagn.`"
},
{
"code": "88",
"meaning": "Love and kisses (affectionate sign-off).",
"exam": false,
"explanation": "An affectionate close, traditionally used towards a partner or in friendly mixed company."
},
{
"code": "AGN",
"meaning": "Again / please repeat.",
"exam": false,
"explanation": "Asks the other station to repeat something that was missed."
},
{
"code": "ANT",
"meaning": "Antenna.",
"exam": false,
"explanation": "Common CW and logging abbreviation, for example when exchanging antenna type, height or direction."
},
{
"code": "AR",
"meaning": "End of message (prosign, sent as one run-together character).",
"exam": false,
"tags": ["prosign"],
"explanation": "Marks the end of a complete transmission before the callsign exchange and K."
},
{
"code": "AS",
"meaning": "Wait / stand by (prosign).",
"exam": false,
"tags": ["prosign"],
"explanation": "Asks the other station to wait briefly without ending the transmission."
},
{
"code": "BK",
"meaning": "Break — interrupt or quickly hand over a running transmission.",
"exam": true,
"explanation": "Used in telegraphy to interrupt or quickly pass transmission without a formal over.",
"example": "DL1ABC: `... running 10 W BK` / DO2XYZ: `BK, my PWR 5 W.`"
},
{
"code": "BT",
"meaning": "Separator between parts of a message (prosign, written =).",
"exam": false,
"tags": ["prosign"],
"explanation": "Run-together dah-di-di-di-dah, used like a paragraph break inside a message."
},
{
"code": "BTU",
"meaning": "Back to you.",
"exam": false,
"explanation": "Informal voice and digital-text handover. In formal CW procedure, K or KN is clearer."
},
{
"code": "CFM",
"meaning": "Confirm / I confirm.",
"exam": false,
"explanation": "Confirms information such as a report, locator, or QSL."
},
{
"code": "CL",
"meaning": "Closing down / clear (going off the air).",
"exam": false,
"explanation": "Sent in CW to announce that the station is closing and will not remain available for another contact."
},
{
"code": "CQ",
"meaning": "General call to any station.",
"exam": true,
"explanation": "Used to start a contact when no specific station is being called. A target narrows it, e.g. CQ DX or CQ DL.",
"example": "DL1ABC: `CQ CQ CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`"
},
{
"code": "CPY",
"meaning": "Copy — receive or understand a signal.",
"exam": false,
"explanation": "Common operating shorthand in questions such as 'HW CPY?' and replies such as 'CPY 100%'. It refers to successful reception, not making a duplicate document.",
"example": "DL1ABC: `HW CPY?` / DO2XYZ: `FB CPY, no QRM.`"
},
{
"code": "CUAGN",
"meaning": "See you again.",
"exam": false,
"explanation": "Compact friendly sign-off used in CW and digital text, often immediately before 73."
},
{
"code": "CUL",
"meaning": "See you later.",
"exam": false,
"explanation": "Friendly informal closing used mainly in CW and text modes."
},
{
"code": "DE",
"meaning": "From / this is (precedes the sending station's call sign).",
"exam": true,
"explanation": "Used between the called station and own call sign, especially in CW.",
"example": "DL1ABC: `CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`"
},
{
"code": "DX",
"meaning": "Long distance / distant station.",
"exam": true,
"explanation": "Used to seek or describe long-distance contacts. The required distance depends on band: intercontinental on HF, several hundred km on VHF/UHF.",
"example": "DL1ABC: `CQ DX de DL1ABC k` / PY2XYZ: `DL1ABC de PY2XYZ k`"
},
{
"code": "ES",
"meaning": "And.",
"exam": false,
"explanation": "Telegraphy shorthand for 'and', from the Morse ampersand.",
"example": "DL1ABC: `73 es gud DX`"
},
{
"code": "FB",
"meaning": "Fine business — excellent / understood well.",
"exam": false,
"explanation": "A general expression of approval: good, excellent, all copied."
},
{
"code": "FD",
"meaning": "Field Day (portable contest operation).",
"exam": true,
"explanation": "Used during Field Day contest operation to attract participating stations.",
"example": "DL1ABC/P: `CQ FD de DL1ABC/P TEST`"
},
{
"code": "GA",
"meaning": "Good afternoon / go ahead.",
"exam": false,
"explanation": "Context decides the meaning: a greeting near the start of a contact, or an invitation to continue. Use K or KN for an unambiguous CW handover."
},
{
"code": "GE",
"meaning": "Good evening.",
"exam": false,
"explanation": "Common greeting near the start of a CW or digital-text contact."
},
{
"code": "GM",
"meaning": "Good morning.",
"exam": false,
"explanation": "Common greeting near the start of a CW or digital-text contact."
},
{
"code": "GN",
"meaning": "Good night.",
"exam": false,
"explanation": "Friendly closing, normally used at the end rather than as the formal end-of-contact prosign."
},
{
"code": "GL",
"meaning": "Good luck.",
"exam": false,
"explanation": "Common friendly wish in contests, award chasing and ordinary CW or digital contacts."
},
{
"code": "GUD",
"meaning": "Good.",
"exam": false,
"explanation": "Common CW spelling reduction, as in 'gud sig' or 'gud copy'."
},
{
"code": "HI",
"meaning": "Laughter (the Morse 'smiley').",
"exam": false,
"explanation": "Written representation of laughter in telegraphy; its dot pattern sounds like a chuckle."
},
{
"code": "HR",
"meaning": "Here / hear.",
"exam": false,
"explanation": "A context-dependent CW contraction: 'WX HR' means weather here, while 'HR U' means hear you."
},
{
"code": "HW",
"meaning": "How?",
"exam": false,
"explanation": "Usually asks for reception or an opinion near the end of an exchange.",
"example": "DL1ABC: `HW CPY?` / DO2XYZ: `FB CPY.`"
},
{
"code": "K",
"meaning": "Invitation to transmit — go ahead / over.",
"exam": true,
"explanation": "Used at the end of a CW transmission to invite the other station to reply.",
"example": "DL1ABC: `DO2XYZ de DL1ABC k`"
},
{
"code": "KN",
"meaning": "Go ahead, named station only (no other stations break in).",
"exam": false,
"tags": ["prosign"],
"explanation": "Like K, but invites only the specific station addressed to reply."
},
{
"code": "MNI",
"meaning": "Many.",
"exam": false,
"explanation": "Frequent CW contraction, for example 'mni tnx' or 'mni QSOs'."
},
{
"code": "NIL",
"meaning": "Nothing / nothing heard.",
"exam": false,
"explanation": "Used when no signal, traffic or result was obtained, for example 'NIL HRD' in a log or net report."
},
{
"code": "NR",
"meaning": "Number.",
"exam": false,
"explanation": "Used before a serial number, message number or other numbered item. Restricting the card to this common operational meaning avoids the ambiguous dictionary expansion 'near'."
},
{
"code": "OM",
"meaning": "Old man — any male operator.",
"exam": false,
"explanation": "Friendly address for a male amateur, regardless of age."
},
{
"code": "OP",
"meaning": "Operator / the operator's name.",
"exam": false,
"explanation": "Common when exchanging names, for example 'OP Anna', and in station descriptions such as single-op or multi-op."
},
{
"code": "PSE",
"meaning": "Please.",
"exam": true,
"explanation": "Used as a polite request marker, e.g. PSE QRP or PSE QSY.",
"example": "DL1ABC: `PSE QSY 145.525` / DO2XYZ: `QSL, QSY.`"
},
{
"code": "PWR",
"meaning": "Power (transmitter output).",
"exam": false,
"explanation": "Usually followed by the RF output power and unit, for example 'PWR 5 W'. Do not confuse transmitter output with ERP or EIRP."
},
{
"code": "R",
"meaning": "Received — everything before it was copied correctly.",
"exam": true,
"explanation": "Sent at the start of a transmission to acknowledge full receipt of the previous one.",
"example": "DL1ABC: `R tnx report, ur RST 579` / DO2XYZ: `QSL, 73.`"
},
{
"code": "RIG",
"meaning": "Station equipment / transceiver.",
"exam": false,
"explanation": "Informal term for the radio equipment in use, often exchanged together with antenna and power."
},
{
"code": "RPT",
"meaning": "Repeat.",
"exam": false,
"explanation": "Requests retransmission of missed text. Signal report is better abbreviated RPRT, avoiding a meaning collision in the reverse card."
},
{
"code": "RPRT",
"meaning": "Report — usually a signal report.",
"exam": false,
"explanation": "Distinguishes the noun 'report' from RPT meaning 'repeat'. It is commonly used when asking for or sending an RST report."
},
{
"code": "RR73",
"meaning": "Report received; best regards — final FT8 acknowledgement.",
"exam": false,
"explanation": "In an FT8 exchange, RR73 confirms receipt of the other station's report and closes the contact in one message. It is protocol text, not a general voice or CW abbreviation."
},
{
"code": "RST",
"meaning": "Signal report: Readability, Strength, Tone.",
"exam": true,
"explanation": "Three-figure reception report. In SSB only R and S are normally given (Tone applies to CW).",
"example": "DL1ABC: `ur RST 599` / DO2XYZ: `tnx, ur 579.`"
},
{
"code": "RX",
"meaning": "Receiver.",
"exam": true,
"explanation": "Equipment and diagram abbreviation paired with TX. On repeater listings, RX denotes the frequency on which the station receives."
},
{
"code": "SK",
"meaning": "End of contact (prosign); also 'silent key', a deceased radio amateur.",
"exam": false,
"tags": ["prosign"],
"explanation": "Sent run-together to close out a complete contact; outside operating, a Silent Key is a ham who has died."
},
{
"code": "SRI",
"meaning": "Sorry.",
"exam": false,
"explanation": "Brief apology in CW or digital text, often followed by a correction or repeat."
},
{
"code": "SWL",
"meaning": "Short-Wave Listener — listener who receives but does not transmit.",
"exam": false,
"explanation": "SWLs monitor amateur and broadcast stations, may submit reception reports, and do not need an amateur transmitting authorization merely to receive."
},
{
"code": "TEST",
"meaning": "Contest call indicator.",
"exam": true,
"explanation": "Used in contest calls to show the call is for contest contacts.",
"example": "DL1ABC: `CQ TEST de DL1ABC` / DO2XYZ: `DL1ABC de DO2XYZ 59 012`"
},
{
"code": "TNX",
"meaning": "Thanks — three-letter telegraphic abbreviation.",
"exam": false,
"explanation": "Very common in CW and digital text; TKS is an equivalent spelling. The explicit three-letter wording distinguishes this reverse card from TU."
},
{
"code": "TU",
"meaning": "Thank you — two-letter acknowledgement or sign-off.",
"exam": false,
"explanation": "Frequently used by contest or DX stations to acknowledge one caller and immediately continue, for example 'TU QRZ?'."
},
{
"code": "TX",
"meaning": "Transmitter.",
"exam": true,
"explanation": "Equipment and diagram abbreviation paired with RX. On repeater listings, TX denotes the frequency on which the station transmits."
},
{
"code": "UR",
"meaning": "Your / you're.",
"exam": false,
"explanation": "Context-dependent CW and text contraction, as in 'UR RST 579' or 'UR FB'."
},
{
"code": "UTC",
"meaning": "Coordinated Universal Time — koordinierte Weltzeit.",
"exam": true,
"explanation": "Use UTC for logs, schedules and QSL confirmations so both stations record the same date and time without daylight-saving ambiguity. Germany is UTC+1 in MEZ and UTC+2 in MESZ."
},
{
"code": "VY",
"meaning": "Very.",
"exam": false,
"explanation": "Common CW contraction, for example 'vy gud' or 'vy strong'."
},
{
"code": "WX",
"meaning": "Weather.",
"exam": false,
"explanation": "Common conversational topic in CW and digital contacts, for example 'WX HR SUNNY'."
},
{
"code": "WPM",
"meaning": "Words Per Minute — Morse sending speed.",
"exam": false,
"explanation": "Standard practical speed unit for CW. Send no faster than you can reliably receive, and slow down to match a station that answers at a lower speed."
},
{
"code": "XYL",
"meaning": "Wife (ex-young-lady).",
"exam": false,
"explanation": "Traditional amateur shorthand that is still encountered but can sound dated; use a person's name when known."
},
{
"code": "YL",
"meaning": "Young lady — any female operator.",
"exam": false,
"explanation": "Traditional term for a female radio amateur regardless of age. It remains common in names of clubs, nets and awards."
},
{
"code": "MAYDAY",
"meaning": "International distress signal (spoken). Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used in maritime or aeronautical distress traffic, not in normal amateur radio traffic.",
"example": "Vessel: `MAYDAY MAYDAY MAYDAY, this is DABC.`"
},
{
"code": "SOS",
"meaning": "International distress signal (Morse). Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used as an international distress signal, especially in Morse contexts outside amateur traffic.",
"example": "Station: `SOS SOS SOS de DABC`"
},
{
"code": "PAN PAN",
"meaning": "International urgency signal. Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used outside amateur radio for urgent situations that are serious but not yet distress.",
"example": "Vessel: `PAN PAN, this is DABC, engine failure.`"
},
{
"code": "SÉCURITÉ",
"meaning": "International safety signal. Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used outside amateur radio for safety information such as navigation or weather warnings.",
"example": "Coast station: `SECURITE, weather warning.`"
}
]
}
+642
View File
@@ -0,0 +1,642 @@
{
"terms": [
{
"code": "AM",
"category": "Betriebsart",
"meaning": "Amplitudenmodulation — amplitude modulation.",
"exam": true,
"explanation": "The carrier amplitude follows the audio signal. Both sidebands and the carrier are transmitted, so AM needs more bandwidth and power than SSB."
},
{
"code": "FM",
"category": "Betriebsart",
"meaning": "Frequenzmodulation — frequency modulation.",
"exam": true,
"explanation": "The carrier frequency follows the audio signal at constant amplitude. Robust against amplitude noise; the usual mode for VHF/UHF (UKW) voice and repeaters."
},
{
"code": "PM",
"category": "Betriebsart",
"meaning": "Phasenmodulation — phase modulation.",
"exam": true,
"explanation": "The carrier phase follows the signal; closely related to FM and often used to generate it."
},
{
"code": "DSB",
"category": "Betriebsart",
"meaning": "Doppelseitenbandmodulation — double-sideband modulation.",
"exam": true,
"explanation": "Both sidebands are transmitted. The carrier may be present, as in conventional AM, or suppressed, as in DSB-SC; suppressing one of the two sidebands produces SSB."
},
{
"code": "SSB",
"category": "Betriebsart",
"meaning": "Einseitenbandmodulation — single sideband.",
"exam": true,
"explanation": "Only one sideband is transmitted and the carrier is normally suppressed. Compared with conventional AM, SSB uses about half the bandwidth and does not waste transmitter power on a full carrier, which is why it dominates analogue HF voice."
},
{
"code": "USB",
"category": "Betriebsart",
"meaning": "oberes Seitenband — upper sideband.",
"exam": true,
"explanation": "SSB using the sideband above the suppressed carrier. Convention on HF above 10 MHz and on VHF/UHF."
},
{
"code": "LSB",
"category": "Betriebsart",
"meaning": "unteres Seitenband — lower sideband.",
"exam": true,
"explanation": "SSB using the sideband below the suppressed carrier. Convention on the HF bands below 10 MHz (160/80/40 m)."
},
{
"code": "CW",
"category": "Betriebsart",
"meaning": "Continuous Wave — Morsetelegrafie (A1A).",
"exam": true,
"explanation": "On/off keying of an unmodulated carrier. Very narrow bandwidth and excellent weak-signal readability."
},
{
"code": "FSK",
"category": "Betriebsart",
"meaning": "Frequency Shift Keying — Frequenzumtastung.",
"exam": true,
"explanation": "Digital keying that shifts the carrier between discrete frequencies; the basis of RTTY."
},
{
"code": "PSK",
"category": "Betriebsart",
"meaning": "Phase Shift Keying — Phasenumtastung.",
"exam": true,
"explanation": "Digital keying that encodes data in carrier phase shifts."
},
{
"code": "ASK",
"category": "Betriebsart",
"meaning": "Amplitude Shift Keying — Amplitudenumtastung.",
"exam": true,
"explanation": "Digital symbols are represented by discrete carrier amplitudes. Simple on-off keying is the two-state special case; unlike FM or PSK, amplitude noise directly affects the keyed quantity."
},
{
"code": "QAM",
"category": "Betriebsart",
"meaning": "Quadraturamplitudenmodulation — quadrature amplitude modulation.",
"exam": true,
"explanation": "Combines amplitude and phase modulation on two quadrature carriers to carry several bits per symbol."
},
{
"code": "RTTY",
"category": "Betriebsart",
"meaning": "Radioteletype — Funkfernschreiben.",
"exam": true,
"explanation": "Classic teleprinter operation using frequency-shift keying and the five-bit ITA2 alphabet, commonly called Baudot. On amateur HF it is usually copied with software rather than a mechanical teleprinter."
},
{
"code": "PSK31",
"category": "Betriebsart",
"meaning": "Phase Shift Keying at 31.25 baud — narrowband keyboard digital mode.",
"exam": true,
"explanation": "The name is conventionally rounded: the symbol rate is 31.25 baud. This live keyboard-to-keyboard HF mode is very narrow, but overdriving an SSB transmitter produces a needlessly wide, distorted signal."
},
{
"code": "FT8",
"category": "Betriebsart",
"meaning": "digitale Schwachsignal-Betriebsart (WSJT-X) — weak-signal digital mode.",
"exam": true,
"explanation": "A structured weak-signal mode with synchronized 15-second periods and short, highly constrained exchanges. It can decode signals far below the audible noise floor, but it is intended for brief contacts rather than conversation."
},
{
"code": "WSPR",
"category": "Betriebsart",
"meaning": "Weak Signal Propagation Reporter — Bakenbetrieb zur Ausbreitungsforschung.",
"exam": true,
"explanation": "Very low-power beacon mode whose reception reports are pooled to map propagation."
},
{
"code": "SSTV",
"category": "Betriebsart",
"meaning": "Slow Scan Television — Schmalband-Bildübertragung.",
"exam": true,
"explanation": "Sends still pictures slowly enough to fit a voice-bandwidth channel."
},
{
"code": "ATV",
"category": "Betriebsart",
"meaning": "Amateurfernsehen — (fast-scan) amateur television.",
"exam": true,
"explanation": "Real-time video, typically on the higher bands because of its wide bandwidth."
},
{
"code": "NBFM",
"category": "Betriebsart",
"meaning": "Schmalband-FM — narrowband FM.",
"exam": true,
"explanation": "FM with limited frequency deviation and occupied bandwidth. It is the normal analogue voice mode on VHF/UHF repeaters and simplex channels; channel spacing and occupied bandwidth are related but are not the same quantity."
},
{
"code": "MF",
"category": "Band",
"meaning": "Medium Frequency — 300 kHz to 3 MHz (Mittelwelle).",
"exam": true,
"explanation": "The ITU band containing the amateur 630 m and 160 m allocations. In practice these low bands need large or electrically shortened antennas and are strongly affected by ground loss and nighttime propagation."
},
{
"code": "KW",
"category": "Band",
"meaning": "Kurzwelle — shortwave, 3 to 30 MHz.",
"exam": true,
"explanation": "The German name for the ITU HF band. This is the classic long-distance amateur range, where ionospheric propagation can support regional or worldwide contacts."
},
{
"code": "UKW",
"category": "Band",
"meaning": "Ultrakurzwelle — German name for 30 to 300 MHz.",
"exam": true,
"explanation": "Mostly line-of-sight propagation; home of FM voice and repeaters. In the BNetzA band names UKW is exactly VHF; 300 MHz to 3 GHz is UHF / Dezimeterwelle. Colloquially 'UKW' is sometimes stretched to mean VHF and up, but that is not the exam definition."
},
{
"code": "VHF",
"category": "Band",
"meaning": "Very High Frequency — ITU band from 30 to 300 MHz.",
"exam": true,
"explanation": "The international designation corresponding to German UKW. The 2 m amateur band is the main practical example, used for local SSB/CW, FM simplex, repeaters, satellites and weak-signal work."
},
{
"code": "UHF",
"category": "Band",
"meaning": "Ultra High Frequency — 300 MHz to 3 GHz.",
"exam": true,
"explanation": "Includes the amateur 70 cm, 23 cm and 13 cm bands. Propagation is mainly line of sight, although repeaters, satellites, tropospheric enhancement and reflection can extend the range."
},
{
"code": "SHF",
"category": "Band",
"meaning": "Super High Frequency — 3 to 30 GHz (Mikrowellen).",
"exam": true,
"explanation": "Microwave range containing several amateur bands. Feed-line loss, connector quality and accurate antenna alignment become increasingly important, so the transverter or low-noise amplifier is often mounted near the antenna."
},
{
"code": "EHF",
"category": "Band",
"meaning": "Extremely High Frequency — 30 to 300 GHz.",
"exam": false,
"explanation": "Millimetre-wave range containing experimental amateur allocations. Operation is specialized because atmospheric absorption, mechanical tolerances and feed-line losses are severe."
},
{
"code": "NF",
"category": "Signal & Frequenz",
"meaning": "Niederfrequenz — audio frequency (about 16 Hz to 20 kHz).",
"exam": true,
"explanation": "The baseband audio signal before modulation or after demodulation."
},
{
"code": "HF",
"category": "Signal & Frequenz",
"meaning": "Hochfrequenz — German technical term for a radio-frequency signal.",
"exam": true,
"explanation": "In German circuit descriptions, HF contrasts with NF and can mean radio-frequency energy generally. In the international ITU band names, however, HF specifically means 3 to 30 MHz; the German exam normally calls that range KW. Context decides which use is intended."
},
{
"code": "ZF",
"category": "Signal & Frequenz",
"meaning": "Zwischenfrequenz — German abbreviation for intermediate frequency.",
"exam": true,
"explanation": "The fixed frequency a superheterodyne receiver mixes the signal to, where most selective amplification and filtering happens."
},
{
"code": "IF",
"category": "Signal & Frequenz",
"meaning": "Intermediate Frequency — English abbreviation for an intermediate frequency.",
"exam": false,
"explanation": "English-language manuals and schematics use this where German material uses ZF. A superheterodyne receiver converts the wanted signal to this fixed frequency for most of its filtering and gain."
},
{
"code": "RF",
"category": "Signal & Frequenz",
"meaning": "Radio Frequency — English term for a radio-frequency signal.",
"exam": false,
"explanation": "The usual English label for signals and circuitry at radio frequencies. German schematics commonly label the same signal domain HF."
},
{
"code": "VFO",
"category": "Baugruppe",
"meaning": "variabler Oszillator — variable frequency oscillator.",
"exam": true,
"explanation": "The tunable oscillator that sets the operating frequency of a transmitter or receiver."
},
{
"code": "VCO",
"category": "Baugruppe",
"meaning": "spannungsgesteuerter Oszillator — voltage-controlled oscillator.",
"exam": true,
"explanation": "An oscillator whose frequency follows a control voltage; the tunable element inside a PLL."
},
{
"code": "PLL",
"category": "Baugruppe",
"meaning": "Phasenregelschleife — phase-locked loop.",
"exam": true,
"explanation": "Locks a VCO to a stable reference by comparing phase, forming the basis of modern frequency synthesizers."
},
{
"code": "DDS",
"category": "Baugruppe",
"meaning": "direkte digitale Synthese — direct digital synthesis.",
"exam": true,
"explanation": "Generates a signal numerically from a clock and a phase accumulator, giving fine, fast frequency steps."
},
{
"code": "BFO",
"category": "Baugruppe",
"meaning": "Schwebungsoszillator — beat frequency oscillator.",
"exam": true,
"explanation": "Reinjects a carrier at the product detector so CW and SSB become audible."
},
{
"code": "LO",
"category": "Baugruppe",
"meaning": "Lokaloszillator — local (mixing) oscillator.",
"exam": false,
"explanation": "Provides the frequency mixed with the received or transmitted signal. The sum or difference product is selected, for example to convert an amateur band to a fixed intermediate frequency."
},
{
"code": "AGC",
"category": "Baugruppe",
"meaning": "automatische Verstärkungsregelung — automatic gain control.",
"exam": true,
"explanation": "Holds the receiver output roughly constant across strong and weak signals."
},
{
"code": "ALC",
"category": "Baugruppe",
"meaning": "automatische Pegelregelung — automatic level control.",
"exam": true,
"explanation": "Limits transmitter drive to prevent overdrive and splatter."
},
{
"code": "PA",
"category": "Baugruppe",
"meaning": "Endstufe / Leistungsverstärker — power amplifier.",
"exam": true,
"explanation": "The final transmitter stage that raises the RF signal to the required output power. It must be driven within its linear range for SSB and digital modes to avoid splatter and intermodulation."
},
{
"code": "LNA",
"category": "Baugruppe",
"meaning": "rauscharmer Vorverstärker — low-noise amplifier.",
"exam": true,
"explanation": "A low-noise preamplifier, often at the antenna, to improve weak-signal reception on VHF and up."
},
{
"code": "ATU",
"category": "Baugruppe",
"meaning": "Antennenanpassgerät — antenna tuning unit.",
"exam": false,
"explanation": "Matches the feed line to the transmitter so the PA sees a low SWR; it does not 'tune' the antenna itself."
},
{
"code": "TRX",
"category": "Baugruppe",
"meaning": "Transceiver — combined transmitter and receiver.",
"exam": true,
"explanation": "Common shorthand in German amateur-radio diagrams and operating notes for a radio that shares controls and circuitry between transmit and receive."
},
{
"code": "VOX",
"category": "Baugruppe & Bedienung",
"meaning": "Voice-Operated Transmit — sprachgesteuerte Sendeumschaltung.",
"exam": true,
"explanation": "Switches a transceiver to transmit when microphone audio exceeds a threshold. Set gain, delay and anti-VOX carefully so loudspeaker audio or room noise does not key the transmitter accidentally."
},
{
"code": "Balun",
"category": "Baugruppe",
"meaning": "Symmetrierglied — balanced-to-unbalanced transformer.",
"exam": true,
"explanation": "Interfaces a balanced load, such as a dipole, with an unbalanced feed line such as coax. Common-mode suppression depends on the balun type: a suitable current balun or choke is needed when feed-line current must be blocked."
},
{
"code": "LED",
"category": "Bauteil",
"meaning": "Leuchtdiode — light-emitting diode.",
"exam": true,
"explanation": "Emits light when forward biased and requires current limiting. In station equipment it is commonly used for status indication and display backlighting."
},
{
"code": "FET",
"category": "Bauteil",
"meaning": "Feldeffekttransistor — field-effect transistor.",
"exam": true,
"explanation": "A voltage-controlled transistor with very high input impedance."
},
{
"code": "MOSFET",
"category": "Bauteil",
"meaning": "Metall-Oxid-Halbleiter-FET — metal-oxide-semiconductor FET.",
"exam": true,
"explanation": "An insulated-gate FET; common in switching and RF power stages."
},
{
"code": "BJT",
"category": "Bauteil",
"meaning": "Bipolartransistor — bipolar junction transistor.",
"exam": false,
"explanation": "A current-controlled transistor using both electron and hole conduction. NPN and PNP devices are used in switching and amplifier stages."
},
{
"code": "IC",
"category": "Bauteil",
"meaning": "integrierter Schaltkreis — integrated circuit.",
"exam": false,
"explanation": "Many components and circuit functions fabricated on one semiconductor chip, from simple voltage regulators to complete mixers, synthesizers and processors."
},
{
"code": "NTC",
"category": "Bauteil",
"meaning": "Heißleiter — thermistor with a negative temperature coefficient.",
"exam": true,
"explanation": "Its resistance falls as temperature rises; used for temperature sensing and inrush limiting."
},
{
"code": "PTC",
"category": "Bauteil",
"meaning": "Kaltleiter — thermistor with a positive temperature coefficient.",
"exam": true,
"explanation": "Its resistance rises as temperature rises; used as a resettable fuse and for protection."
},
{
"code": "LDR",
"category": "Bauteil",
"meaning": "Fotowiderstand — light-dependent resistor.",
"exam": true,
"explanation": "Its resistance decreases as illumination increases. It is useful for light sensing but is slower than a photodiode or phototransistor."
},
{
"code": "VDR",
"category": "Bauteil",
"meaning": "spannungsabhängiger Widerstand (Varistor) — voltage-dependent resistor.",
"exam": true,
"explanation": "Resistance drops sharply above a threshold voltage; clamps surges and protects against overvoltage."
},
{
"code": "dB",
"category": "Messgröße & Leistung",
"meaning": "Dezibel — logarithmic ratio.",
"exam": true,
"explanation": "For power ratios use 10·log₁₀(P₂/P₁); for voltage or current ratios under the same impedance use 20·log₁₀. +3 dB is about twice the power and +10 dB is ten times. Plain dB is relative, unlike dBm or dBW."
},
{
"code": "dBi",
"category": "Messgröße & Leistung",
"meaning": "Gewinn über Isotropstrahler — gain relative to an isotropic radiator.",
"exam": true,
"explanation": "An antenna gain reference, not transmitter power. Because a half-wave dipole has about 2.15 dBi gain, a value in dBi is 2.15 dB higher than the same gain stated in dBd."
},
{
"code": "dBd",
"category": "Messgröße & Leistung",
"meaning": "Gewinn über Dipol — gain relative to a half-wave dipole.",
"exam": true,
"explanation": "0 dBd = 2.15 dBi; the dipole already has 2.15 dBi of gain over isotropic."
},
{
"code": "dBm",
"category": "Messgröße & Leistung",
"meaning": "Pegel bezogen auf 1 mW — power level referenced to 1 milliwatt.",
"exam": true,
"explanation": "An absolute level: 0 dBm = 1 mW, 30 dBm = 1 W."
},
{
"code": "dBW",
"category": "Messgröße & Leistung",
"meaning": "Pegel bezogen auf 1 W — power level referenced to 1 watt.",
"exam": true,
"explanation": "An absolute logarithmic power level: 0 dBW = 1 W and 10 dBW = 10 W. To convert dBW to dBm, add 30 dB."
},
{
"code": "SWR",
"category": "Messgröße & Leistung",
"meaning": "Stehwellenverhältnis — standing wave ratio.",
"exam": true,
"explanation": "Normally the ratio of maximum to minimum RF voltage along a feed line. 1:1 is a perfect match; a higher value indicates a larger reflection, but does not by itself identify whether the antenna, feed line or another component causes the mismatch."
},
{
"code": "ERP",
"category": "Messgröße & Leistung",
"meaning": "effektive Strahlungsleistung — effective radiated power.",
"exam": true,
"explanation": "Radiated power referenced to a half-wave dipole. In linear units, multiply power delivered to the antenna by antenna gain relative to a dipole; in decibel units, subtract feed losses and add gain in dBd."
},
{
"code": "EIRP",
"category": "Messgröße & Leistung",
"meaning": "äquivalente isotrope Strahlungsleistung — equivalent isotropically radiated power.",
"exam": true,
"explanation": "Radiated power referenced to an ideal isotropic radiator. For the same station, EIRP is 1.64 times ERP; when both powers are expressed logarithmically, EIRP is 2.15 dB higher."
},
{
"code": "PEP",
"category": "Messgröße & Leistung",
"meaning": "Spitzenleistung — peak envelope power.",
"exam": true,
"explanation": "The average power over one RF cycle at the peak of the modulation envelope; the standard rating for SSB transmitters."
},
{
"code": "RMS",
"category": "Messgröße & Leistung",
"meaning": "Effektivwert — root mean square.",
"exam": true,
"explanation": "The equivalent DC value that produces the same heating; for a sine wave it is the peak divided by √2."
},
{
"code": "Q",
"category": "Messgröße & Leistung",
"meaning": "Güte — quality factor of a resonant circuit.",
"exam": true,
"explanation": "At resonance, Q = 2π times the energy stored divided by the energy lost per cycle; equivalently it is approximately resonant frequency divided by bandwidth. Higher Q gives a narrower response but also a smaller usable bandwidth."
},
{
"code": "SNR",
"category": "Messgröße & Leistung",
"meaning": "Signal-Rausch-Verhältnis — signal-to-noise ratio.",
"exam": true,
"explanation": "Compares wanted signal power with noise power, normally in dB and for a stated bandwidth. Narrowing the receiver bandwidth reduces admitted noise and can improve readability even though the received carrier power is unchanged."
},
{
"code": "AC",
"category": "Strom & Spannung",
"meaning": "Alternating Current — Wechselstrom; also used for alternating voltage.",
"exam": true,
"explanation": "Current whose direction changes periodically. Station mains supplies are AC; power supplies rectify and smooth it to provide the DC required by most transceivers."
},
{
"code": "DC",
"category": "Strom & Spannung",
"meaning": "Direct Current — Gleichstrom; also used for direct voltage.",
"exam": true,
"explanation": "Current with constant polarity. Most mobile amateur equipment is designed for a nominal 13.8 V DC supply, where correct polarity and adequate cable size are essential."
},
{
"code": "MUF",
"category": "Ausbreitung",
"meaning": "höchste brauchbare Frequenz — maximum usable frequency.",
"exam": true,
"explanation": "The highest frequency that is refracted back to Earth for a specified path and time. Above it, that ionospheric path normally fails; the value changes with distance, solar conditions, season and time of day."
},
{
"code": "LUF",
"category": "Ausbreitung",
"meaning": "niedrigste brauchbare Frequenz — lowest usable frequency.",
"exam": true,
"explanation": "The lowest frequency that still provides the required signal quality on a specified path. Below it, ionospheric absorption and atmospheric noise usually dominate; it is an operational limit, not a fixed band boundary."
},
{
"code": "NVIS",
"category": "Ausbreitung",
"meaning": "Steilstrahlung — near-vertical incidence skywave.",
"exam": true,
"explanation": "Uses high-angle HF radiation refracted back to Earth for regional communication, typically on 160, 80, 60 or 40 m depending on conditions. It can cover the skip zone of lower-angle paths, but coverage is never guaranteed."
},
{
"code": "DMR",
"category": "Digital & Daten",
"meaning": "Digital Mobile Radio — digitale Sprach- und Datenbetriebsart.",
"exam": true,
"explanation": "An ETSI digital mobile-radio standard using two-slot TDMA on a 12.5 kHz carrier. Amateur Tier II operation is common on repeaters and hotspots; talkgroups and timeslots must be selected correctly."
},
{
"code": "D-STAR",
"category": "Digital & Daten",
"meaning": "Digital Smart Technologies for Amateur Radio — digital voice and data system.",
"exam": true,
"explanation": "An open amateur-radio specification developed through JARL. Callsign routing and reflector linking are common in practice; Icom is a major equipment vendor, not part of the abbreviation."
},
{
"code": "C4FM",
"category": "Digital & Daten",
"meaning": "Continuous 4-Level Frequency Modulation — four-level digital modulation.",
"exam": true,
"explanation": "Maps dibits to four frequency deviations. Yaesu System Fusion uses C4FM, but the modulation name and the complete network/system are not synonymous."
},
{
"code": "APRS",
"category": "Digital & Daten",
"meaning": "Automatic Packet Reporting System — Positions- und Telemetriedaten über Packet.",
"exam": false,
"explanation": "Shares position, status, telemetry, messages and objects, commonly as 1200-baud packet on 144.800 MHz in Europe. Use an appropriate path and beacon rate to avoid unnecessary channel congestion."
},
{
"code": "TNC",
"category": "Digital & Daten",
"meaning": "Terminal Node Controller — Packet-Radio-Modem.",
"exam": false,
"explanation": "Interfaces a computer or terminal with a radio and handles packet framing and modulation. Modern APRS stations often implement the same functions in software."
},
{
"code": "AX.25",
"category": "Digital & Daten",
"meaning": "Amateurfunk-Datenprotokoll — amateur packet-radio link protocol.",
"exam": false,
"explanation": "Amateur adaptation of X.25 used for packet radio and APRS frames. It carries amateur callsigns in the link-layer addresses and can support connected or unconnected operation."
},
{
"code": "CTCSS",
"category": "Signalisierung & Bedienung",
"meaning": "Continuous Tone-Coded Squelch System — subaudible tone squelch.",
"exam": false,
"explanation": "A continuous low audio-frequency tone, typically 67 to 254 Hz, that allows a repeater or receiver to open only for the selected tone. 'Subaudible' means it is normally filtered from the loudspeaker audio, not that it lies below the human hearing range."
},
{
"code": "DTMF",
"category": "Signalisierung & Bedienung",
"meaning": "Dual-Tone Multi-Frequency — Mehrfrequenzwahlverfahren.",
"exam": false,
"explanation": "Each key sends one tone from a low group and one from a high group. Amateur stations use it mainly for repeater control, link commands and legacy telephone interconnects."
},
{
"code": "DCS",
"category": "Signalisierung & Bedienung",
"meaning": "Digital-Coded Squelch — digitale Rauschsperrencodierung.",
"exam": false,
"explanation": "Sends a continuous low-rate digital code with analogue FM so a receiver opens only for the selected code. It serves a similar access-filtering purpose to CTCSS but is not a digital voice mode."
},
{
"code": "PTT",
"category": "Baugruppe & Bedienung",
"meaning": "Push To Talk — Sendetaste.",
"exam": true,
"explanation": "Keys the transmitter while pressed. Confirm that the frequency is clear before using it, and allow a brief pause after keying a repeater so the first syllable is not clipped."
},
{
"code": "SDR",
"category": "Digital & Daten",
"meaning": "Software Defined Radio — softwaredefiniertes Funkgerät.",
"exam": true,
"explanation": "Implements functions such as mixing, filtering and demodulation in software or programmable logic. An SDR still needs suitable analogue RF filtering, gain control and conversion hardware."
},
{
"code": "RIT",
"category": "Baugruppe & Bedienung",
"meaning": "Receiver Incremental Tuning — Empfänger-Feinverstimmung.",
"exam": true,
"explanation": "Shifts only the receive frequency while leaving transmit frequency unchanged. It is useful when the other station is slightly off frequency; reset it afterward to avoid later tuning confusion."
},
{
"code": "ITU",
"category": "Organisation & Vorschrift",
"meaning": "Internationale Fernmeldeunion — International Telecommunication Union.",
"exam": true,
"explanation": "UN agency that allocates spectrum worldwide and defines the three ITU regions."
},
{
"code": "IARU",
"category": "Organisation & Vorschrift",
"meaning": "Internationale Amateur-Radio-Union — International Amateur Radio Union.",
"exam": true,
"explanation": "Worldwide federation of national amateur-radio societies; coordinates band plans and represents amateurs at the ITU."
},
{
"code": "CEPT",
"category": "Organisation & Vorschrift",
"meaning": "Europäische Konferenz der Verwaltungen für Post und Telekommunikation.",
"exam": true,
"explanation": "Its recommendations let an amateur licensee operate temporarily in other member countries without applying for a separate licence."
},
{
"code": "BNetzA",
"category": "Organisation & Vorschrift",
"meaning": "Bundesnetzagentur — the German telecommunications regulator.",
"exam": true,
"explanation": "Issues amateur licences and call signs in Germany and supervises the service."
},
{
"code": "EMV",
"category": "Organisation & Vorschrift",
"meaning": "elektromagnetische Verträglichkeit — electromagnetic compatibility (EMC).",
"exam": true,
"explanation": "The ability of equipment to operate without causing or suffering undue interference."
},
{
"code": "EMVU",
"category": "Organisation & Vorschrift",
"meaning": "elektromagnetische Umweltverträglichkeit — EMF exposure compliance.",
"exam": true,
"explanation": "Assessment and limits for human exposure to the electromagnetic fields a station produces."
},
{
"code": "AFu",
"category": "Organisation & Vorschrift",
"meaning": "Amateurfunk — amateur radio.",
"exam": false,
"explanation": "Common German written abbreviation in terms such as AFuG and AFuV. It is mainly documentation shorthand rather than something spoken during a contact."
}
]
}
+375
View File
@@ -11,6 +11,7 @@ import tempfile
import unittest import unittest
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import patch
import amateurfunk_anki as aa import amateurfunk_anki as aa
@@ -108,6 +109,24 @@ def extract_collection(apkg_path: Path, tmp: Path):
return tmp / "collection.anki2", media, names return tmp / "collection.anki2", media, names
class TestResolveShuffleSeed(unittest.TestCase):
"""Unit coverage for the shuffle-seed resolution itself.
The CLI-level test patches `resolve_shuffle_seed` out, so the
actual default-seed behavior is only exercised here.
"""
def test_explicit_seed_is_passed_through(self):
self.assertEqual(aa.resolve_shuffle_seed("my-seed"), "my-seed")
def test_omitted_seed_mints_a_fresh_value_each_call(self):
first = aa.resolve_shuffle_seed(None)
second = aa.resolve_shuffle_seed(None)
self.assertIsInstance(first, str)
self.assertTrue(first)
self.assertNotEqual(first, second)
class TestAnkiBuild(unittest.TestCase): class TestAnkiBuild(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
@@ -174,6 +193,48 @@ class TestAnkiBuild(unittest.TestCase):
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]}, {"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
) )
def test_split_class_packages_ship_topic_subdeck_tree(self):
# The E and A packages each stay one file but carry a deck tree:
# the anchoring class deck plus one sub-deck per first-level
# topic, with each card filed under its topic sub-deck.
self._build_all()
for letter in ("e", "a"):
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / letter)
conn = sqlite3.connect(db_path)
try:
decks = json.loads(
conn.execute("select decks from col").fetchone()[0]
)
deck_names = {d["name"] for d in decks.values()}
id_to_name = {int(k): d["name"] for k, d in decks.items()}
card_decks = sorted(
id_to_name[row[0]]
for row in conn.execute("select did from cards")
)
finally:
conn.close()
root = f"Amateurfunk::Technische Kenntnisse::{letter.upper()}"
self.assertEqual(deck_names, {root, f"{root}::Grundlagen"})
self.assertEqual(card_decks, [f"{root}::Grundlagen"])
def test_single_topic_classes_ship_one_flat_deck(self):
# N (and E) are not split: one deck, card filed directly in it.
self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "n")
conn = sqlite3.connect(db_path)
try:
decks = json.loads(
conn.execute("select decks from col").fetchone()[0]
)
finally:
conn.close()
self.assertEqual(
{d["name"] for d in decks.values()},
{"Amateurfunk::Technische Kenntnisse::N"},
)
def test_apkg_contains_notes_cards_and_media(self): def test_apkg_contains_notes_cards_and_media(self):
self._build_all() self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg" apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
@@ -359,6 +420,50 @@ class TestAnkiBuild(unittest.TestCase):
self.assertNotIn("af-explanation-low-confidence", back_high) self.assertNotIn("af-explanation-low-confidence", back_high)
self.assertNotIn("low confidence", back_high) self.assertNotIn("low confidence", back_high)
def test_cc_by_credit_gated_on_provenance_not_source_domain(self):
"""The CC BY derivative-work credit must follow explicit
provenance, not the citation domain: an explanation merely
citing a 50ohm.de page is NOT a derivative work and must not
carry the credit, while one marked as derived from a worked
solution must."""
derived = {
"revision": 2,
"explanation": "Worked-solution port.",
"source": "https://50ohm.de/NEA_x.html#AD106",
"confidence": 8,
"provenance": aa.PROVENANCE_50OHM_SOLUTION,
}
cited_only = {
"revision": 1,
"explanation": "Independently written, just cites a study page.",
"source": "https://50ohm.de/NEA_x.html#XX101",
"confidence": 8,
}
derived_html = aa.render_explanation(derived)
cited_html = aa.render_explanation(cited_only)
self.assertIn("af-explanation-credit", derived_html)
self.assertIn("CC BY 4.0", derived_html)
self.assertIn("50ohm.de-Autorenteam", derived_html)
self.assertNotIn("af-explanation-credit", cited_html)
self.assertNotIn("translated", cited_html)
def test_root_deck_description_carries_attribution(self):
self._build_all()
apkg = self.out_dir / "amateurfunk-betriebliche-kenntnisse.apkg"
db_path, _media, _names = extract_collection(apkg, self.root / "b")
conn = sqlite3.connect(db_path)
try:
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
finally:
conn.close()
descs = [d["desc"] for d in decks.values() if d["desc"]]
self.assertEqual(len(descs), 1) # only the root deck carries it
desc = descs[0]
self.assertIn("CC BY 4.0", desc)
self.assertIn("govdata.de/dl-de/by-2-0", desc)
self.assertIn("50ohm.de-Autorenteam", desc)
self.assertIn("Bundesnetzagentur", desc)
def test_missing_explanation_leaves_card_unchanged(self): def test_missing_explanation_leaves_card_unchanged(self):
self._build_all() self._build_all()
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg" apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
@@ -440,6 +545,48 @@ class TestAnkiBuild(unittest.TestCase):
self._build_all() self._build_all()
self.assertIn("author", str(ctx.exception)) self.assertIn("author", str(ctx.exception))
def test_schema_accepts_valid_provenance(self):
path = self.root / "exp_provenance.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": aa.PROVENANCE_50OHM_SOLUTION,
}}),
encoding="utf-8",
)
loaded = aa.load_explanations(path)
self.assertEqual(
loaded["NA101"]["provenance"], aa.PROVENANCE_50OHM_SOLUTION,
)
def test_schema_rejects_unknown_provenance_value(self):
path = self.root / "exp_badprov.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": "made-up",
}}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
aa.load_explanations(path)
def test_schema_rejects_unhashable_provenance(self):
# A malformed but valid-JSON provenance value (list/object) is
# unhashable; the validator must surface AnkiBuildError, never a
# raw TypeError from the set-membership test.
for bad in ([], {}):
path = self.root / "exp_unhashable.json"
path.write_text(
json.dumps({"NA101": {
"revision": 1, "explanation": "ok", "source": "ok",
"confidence": 5, "provenance": bad,
}}),
encoding="utf-8",
)
with self.assertRaises(aa.AnkiBuildError):
aa.load_explanations(path)
def test_invalid_explanation_schema_raises_build_error(self): def test_invalid_explanation_schema_raises_build_error(self):
self.explanations_path.write_text( self.explanations_path.write_text(
json.dumps({ json.dumps({
@@ -479,6 +626,7 @@ class TestAnkiBuild(unittest.TestCase):
common = [ common = [
"--data", str(self.data_dir), "--data", str(self.data_dir),
"--explanations", str(self.explanations_path), "--explanations", str(self.explanations_path),
"--seed", "stable-test-seed",
"--epoch", "1234567890", "--epoch", "1234567890",
] ]
aa.main([*common, "--out", str(out_a)]) aa.main([*common, "--out", str(out_a)])
@@ -488,6 +636,188 @@ class TestAnkiBuild(unittest.TestCase):
second = out_b / first.name second = out_b / first.name
self.assertEqual(first.read_bytes(), second.read_bytes(), first.name) self.assertEqual(first.read_bytes(), second.read_bytes(), first.name)
def test_cli_without_seed_reshuffles_each_build(self):
out_a = self.root / "anki-fresh-a"
out_b = self.root / "anki-fresh-b"
common = [
"--data", str(self.data_dir),
"--explanations", str(self.explanations_path),
"--epoch", "1234567890",
]
with patch(
"amateurfunk_anki.resolve_shuffle_seed",
side_effect=["fresh-a", "fresh-b"],
) as resolve_seed:
aa.main([*common, "--out", str(out_a)])
aa.main([*common, "--out", str(out_b)])
resolved_inputs = [
call.args[0] for call in resolve_seed.call_args_list
]
self.assertEqual(resolved_inputs, [None, None])
byte_equal = [
first.read_bytes() == (out_b / first.name).read_bytes()
for first in sorted(out_a.glob("*.apkg"))
]
self.assertFalse(all(byte_equal))
class TestIntrinsicSvgSize(unittest.TestCase):
"""Parsing of native figure dimensions — SVG only."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
def _write(self, name, body):
path = self.root / name
path.write_text(body, encoding="utf-8")
return path
def test_svg_unitless_width_and_height(self):
p = self._write("a.svg", '<svg width="115.5" height="120"><g/></svg>')
self.assertEqual(aa.intrinsic_svg_size(p), (115.5, 120.0))
def test_svg_px_suffix_is_tolerated(self):
p = self._write("a.svg", '<svg width="200px" height="80px"></svg>')
self.assertEqual(aa.intrinsic_svg_size(p), (200.0, 80.0))
def test_svg_percentage_dimension_is_rejected(self):
p = self._write("a.svg", '<svg width="100%" height="100%"></svg>')
self.assertIsNone(aa.intrinsic_svg_size(p))
def test_svg_missing_dimension_returns_none(self):
p = self._write("a.svg", '<svg width="200"></svg>')
self.assertIsNone(aa.intrinsic_svg_size(p))
def test_svg_without_root_tag_returns_none(self):
p = self._write("a.svg", "not an svg at all")
self.assertIsNone(aa.intrinsic_svg_size(p))
def test_svg_leading_decimal_is_accepted(self):
p = self._write("a.svg", '<svg width=".5" height="10"></svg>')
self.assertEqual(aa.intrinsic_svg_size(p), (0.5, 10.0))
def test_svg_malformed_number_returns_none_not_raises(self):
# Permissive matching used to let "1.2.3" reach float() and abort
# the build; now it falls back to None like any other bad value.
for bad in ('1.2.3', '.', '1.', '1..2'):
p = self._write("a.svg", f'<svg width="{bad}" height="10"></svg>')
self.assertIsNone(aa.intrinsic_svg_size(p), bad)
def test_non_svg_is_not_sized(self):
# A PNG with a perfectly valid-looking name is still not sized:
# we deliberately only enlarge SVGs.
p = self.root / "a.png"
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
self.assertIsNone(aa.intrinsic_svg_size(p))
class TestScaleForSize(unittest.TestCase):
"""The clamped enlargement factor."""
def test_small_figure_gets_full_multiplier(self):
self.assertEqual(aa.scale_for_size(115, 120), aa.MEDIA_SCALE)
def test_wide_figure_is_clamped_by_max_width(self):
self.assertEqual(aa.scale_for_size(aa.MEDIA_MAX_WIDTH, 50), 1.0)
self.assertAlmostEqual(
aa.scale_for_size(aa.MEDIA_MAX_WIDTH / 2, 50), 2.0
)
# Between half-cap and cap: enlarged only until width hits the cap.
wide = aa.MEDIA_MAX_WIDTH * 0.75
self.assertAlmostEqual(
aa.scale_for_size(wide, 50), aa.MEDIA_MAX_WIDTH / wide
)
def test_tall_figure_is_clamped_by_max_height(self):
self.assertEqual(aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT), 1.0)
self.assertAlmostEqual(
aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT / 2), 2.0
)
tall = aa.MEDIA_MAX_HEIGHT * 0.75
self.assertAlmostEqual(
aa.scale_for_size(50, tall), aa.MEDIA_MAX_HEIGHT / tall
)
def test_large_catalog_schematic_grows_toward_cap(self):
# NG205-like (635x460): with the 1024x768 cap it now enlarges,
# bounded by width — up to 1024 px wide.
self.assertAlmostEqual(
aa.scale_for_size(635, 460), aa.MEDIA_MAX_WIDTH / 635
)
def test_never_shrinks_below_native(self):
self.assertEqual(aa.scale_for_size(5000, 5000), 1.0)
def test_degenerate_size_is_native(self):
self.assertEqual(aa.scale_for_size(0, 0), 1.0)
class TestImageHtmlScaling(unittest.TestCase):
"""End-to-end `<img>` emission and the per-path size cache."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.media_dir = Path(self.tmp.name) / "svgs"
self.media_dir.mkdir()
def _write(self, name, body):
(self.media_dir / name).write_text(body, encoding="utf-8")
def test_small_figure_emits_doubled_width(self):
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
media = aa.MediaRegistry(self.media_dir)
self.assertIn('width="230"', media.image_html("S_q"))
def test_figure_at_cap_emits_no_width(self):
# Exactly at the cap → scale 1.0 → native size, no width.
self._write(
"L_q.svg",
f'<svg width="{aa.MEDIA_MAX_WIDTH}" '
f'height="{aa.MEDIA_MAX_HEIGHT}"></svg>',
)
media = aa.MediaRegistry(self.media_dir)
html = media.image_html("L_q")
self.assertIn('class="af-media"', html)
self.assertNotIn("width=", html)
def test_png_is_not_scaled(self):
# Only a PNG for this stem (no .svg sibling): emitted at native
# size with no width, because we never size raster figures.
(self.media_dir / "P_q.png").write_bytes(
b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
)
media = aa.MediaRegistry(self.media_dir)
html = media.image_html("P_q")
self.assertIn('src="P_q.png"', html)
self.assertNotIn("width=", html)
def test_unreadable_size_falls_back_to_no_width(self):
self._write("U_q.svg", '<svg width="100%" height="100%"></svg>')
media = aa.MediaRegistry(self.media_dir)
self.assertNotIn("width=", media.image_html("U_q"))
def test_malformed_number_falls_back_without_raising(self):
self._write("M_q.svg", '<svg width="1.2.3" height="10"></svg>')
media = aa.MediaRegistry(self.media_dir)
html = media.image_html("M_q") # must not raise
self.assertIn('src="M_q.svg"', html)
self.assertNotIn("width=", html)
def test_size_is_read_once_per_path(self):
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
media = aa.MediaRegistry(self.media_dir)
with patch.object(
aa, "intrinsic_svg_size", wraps=aa.intrinsic_svg_size
) as spy:
media.image_html("S_q")
media.image_html("S_q")
self.assertEqual(spy.call_count, 1)
class TestRealExplanationsFile(unittest.TestCase): class TestRealExplanationsFile(unittest.TestCase):
"""Validate the repo's actual explanations.json against the live catalog. """Validate the repo's actual explanations.json against the live catalog.
@@ -512,6 +842,51 @@ class TestRealExplanationsFile(unittest.TestCase):
categories = aa.collect_categories(catalog) categories = aa.collect_categories(catalog)
aa._check_explanation_keys_against_catalog(explanations, categories) aa._check_explanation_keys_against_catalog(explanations, categories)
def test_repo_explanations_do_not_source_the_question_catalog(self):
repo = Path(__file__).resolve().parent
explanations_path = repo / "explanations.json"
if not explanations_path.exists():
self.skipTest("explanations.json not available")
explanations = aa.load_explanations(explanations_path)
forbidden = [
"Fragenkatalog/Pruefungsfragen",
"Fragenkatalog/BetriebVorschrift",
]
offenders = sorted(
key
for key, value in explanations.items()
if any(token in value["source"] for token in forbidden)
)
self.assertEqual([], offenders)
def test_safety_distance_block_uses_transmitter_power_not_erp(self):
"""AK106-AK112 must feed the EIRP formula with transmitter power.
The relation is `P_EIRP = P_S · 10^((g_d+2.15-a)/10)` with `P_S`
the transmitter (Sender) power; writing `P_EIRP = P_ERP · 10^…`
double-counts the antenna gain (ERP already includes it). The
upstream 50ohm solutions carry exactly this mislabel, so guard
the whole block against it regressing back in.
"""
repo = Path(__file__).resolve().parent
explanations_path = repo / "explanations.json"
if not explanations_path.exists():
self.skipTest("explanations.json not available")
explanations = aa.load_explanations(explanations_path)
block = [f"AK1{n:02d}" for n in range(6, 13)] # AK106..AK112
for key in block:
if key not in explanations:
continue
body = explanations[key]["explanation"]
self.assertIn(
"P_S", body,
f"{key}: EIRP should be computed from transmitter power P_S",
)
self.assertNotIn(
r"P_\mathrm{EIRP} = P_\mathrm{ERP}", body,
f"{key}: EIRP must not be derived by scaling ERP by the gain",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+277
View File
@@ -0,0 +1,277 @@
"""Tests for amateurfunk_shorthand.
Like the multiple-choice tests, these inspect the generated `.apkg`
directly (ZIP entries plus the SQLite collection) and never require
Anki itself. They also validate the repo's real `shorthand.json`
against the schema so editorial mistakes surface in CI.
"""
import json
import sqlite3
import tempfile
import unittest
import zipfile
from pathlib import Path
import amateurfunk_shorthand as sh
def make_shorthand():
return {
"q_codes": [
{
"code": "QSO",
"question": "Can you communicate directly with ...?",
"statement": "I can communicate directly with ...",
"exam": True,
"explanation": "The radio contact itself.",
"example": "DL1ABC: `Tnx for QSO.`",
},
{
"code": "QRG",
"question": "Will you tell me my exact frequency?",
"statement": "Your exact frequency is ... kHz.",
},
],
"abbreviations": [
{
"code": "73",
"meaning": "Best regards.",
"exam": True,
},
{
"code": "SK",
"meaning": "End of contact; also silent key.",
"tags": ["prosign"],
},
],
}
def write_shorthand(root: Path, data=None):
path = root / "shorthand.json"
path.write_text(
json.dumps(data if data is not None else make_shorthand()),
encoding="utf-8",
)
return path
def extract_collection(apkg_path: Path, tmp: Path):
with zipfile.ZipFile(apkg_path) as zf:
zf.extract("collection.anki2", tmp)
media = json.loads(zf.read("media").decode("utf-8"))
names = set(zf.namelist())
return tmp / "collection.anki2", media, names
class TestShorthandBuild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.shorthand_path = write_shorthand(self.root)
self.out_dir = self.root / "anki"
def _build(self, **kwargs):
return sh.build_deck(
self.shorthand_path,
self.out_dir,
self.root / "data", # absent → epoch fallback
**kwargs,
)
def _collection(self):
apkg = next(self.out_dir.glob("*.apkg"))
return extract_collection(apkg, self.root)
def test_qgroup_yields_two_notes_abbrev_yields_one(self):
result = self._build()
# 2 Q-groups → 4 notes, 2 abbreviations → 2 notes.
self.assertEqual(result["notes"], 6)
# Two cards per note (forward + reverse).
self.assertEqual(result["cards"], 12)
self.assertEqual(result["q_codes"], 2)
self.assertEqual(result["abbreviations"], 2)
def test_qgroup_statement_and_question_become_distinct_notes(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
# sfld has INTEGER affinity, so numeric codes (e.g. 73) come
# back as ints; stringify before comparing.
sflds = sorted(str(r[0]) for r in conn.execute("select sfld from notes"))
finally:
conn.close()
self.assertIn("QSO", sflds)
self.assertIn("QSO?", sflds)
def test_each_note_has_two_cards_one_per_template(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"select sfld, ord from notes join cards on cards.nid = notes.id"
" order by sfld, ord"
).fetchall()
ords_for_qso = sorted(o for s, o in rows if s == "QSO")
finally:
conn.close()
self.assertEqual(ords_for_qso, [0, 1])
def test_model_has_two_templates_and_per_template_requirements(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
models = json.loads(
conn.execute("select models from col").fetchone()[0]
)
finally:
conn.close()
model = next(iter(models.values()))
self.assertEqual([t["name"] for t in model["tmpls"]], ["Bedeutung", "Kürzel"])
# Forward template needs Code (field 0); reverse needs Meaning (1).
self.assertEqual(model["req"], [[0, "all", [0]], [1, "all", [1]]])
def test_tags_mark_kind_exam_and_extra_editorial_tags(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
tags = dict(
conn.execute("select sfld, tags from notes").fetchall()
)
finally:
conn.close()
self.assertIn(" q-gruppe ", tags["QSO"])
self.assertIn(" pruefung ", tags["QSO"])
self.assertIn(" abkuerzung ", tags["SK"])
self.assertIn(" prosign ", tags["SK"])
# QRG is not an exam code → no pruefung tag.
self.assertNotIn("pruefung", tags["QRG"])
def test_no_media_entries_in_package(self):
self._build()
_db, media, names = self._collection()
self.assertEqual(media, {})
self.assertEqual(names, {"collection.anki2", "media"})
def test_example_backticks_become_code_and_text_is_escaped(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
flds = next(
r[0] for r in conn.execute("select flds from notes")
if r[0].startswith("QSO" + sh.FIELD_SEP)
)
finally:
conn.close()
self.assertIn("<code>Tnx for QSO.</code>", flds)
def test_build_is_byte_deterministic_for_same_input(self):
out_a = self.root / "a"
out_b = self.root / "b"
sh.build_deck(self.shorthand_path, out_a, self.root / "data", override_epoch=42)
sh.build_deck(self.shorthand_path, out_b, self.root / "data", override_epoch=42)
for first in sorted(out_a.glob("*.apkg")):
second = out_b / first.name
self.assertEqual(first.read_bytes(), second.read_bytes(), first.name)
def test_stable_ids_are_unchanged_across_builds(self):
self._build()
db_path, _m, _n = self._collection()
conn = sqlite3.connect(db_path)
try:
first = sorted(r[0] for r in conn.execute("select id from notes"))
finally:
conn.close()
# Rebuild into a fresh dir; ids derive from the code form only.
out_b = self.root / "again"
sh.build_deck(self.shorthand_path, out_b, self.root / "data")
db_b, _m, _n = extract_collection(next(out_b.glob("*.apkg")), out_b)
conn = sqlite3.connect(db_b)
try:
second = sorted(r[0] for r in conn.execute("select id from notes"))
finally:
conn.close()
self.assertEqual(first, second)
def test_duplicate_code_form_is_rejected(self):
data = make_shorthand()
data["abbreviations"].append({"code": "73", "meaning": "duplicate"})
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError) as ctx:
sh.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("73", str(ctx.exception))
def test_qgroup_question_form_collides_with_abbrev_question_mark(self):
# A Q-group `QRX` expands to `QRX?`; an abbreviation literally
# named `QRX?` would collide and must be rejected.
data = make_shorthand()
data["q_codes"].append(
{"code": "QRX", "question": "When?", "statement": "Wait."}
)
data["abbreviations"].append({"code": "QRX?", "meaning": "clash"})
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError):
sh.build_deck(path, self.out_dir, self.root / "data")
def test_missing_required_field_is_rejected(self):
data = make_shorthand()
del data["q_codes"][0]["statement"]
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError) as ctx:
sh.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("statement", str(ctx.exception))
def test_unknown_field_is_rejected(self):
data = make_shorthand()
data["abbreviations"][0]["author"] = "claude"
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError) as ctx:
sh.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("author", str(ctx.exception))
def test_non_bool_exam_is_rejected(self):
data = make_shorthand()
data["abbreviations"][0]["exam"] = "yes"
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError):
sh.build_deck(path, self.out_dir, self.root / "data")
def test_missing_file_is_a_hard_error(self):
with self.assertRaises(sh.AnkiBuildError):
sh.build_deck(
self.root / "nope.json", self.out_dir, self.root / "data"
)
def test_main_returns_success(self):
rc = sh.main([
"--shorthand", str(self.shorthand_path),
"--out", str(self.out_dir),
"--data", str(self.root / "data"),
])
self.assertEqual(rc, sh.EXIT_OK)
class TestRealShorthandFile(unittest.TestCase):
"""Validate the repo's actual shorthand.json against the schema."""
def test_repo_shorthand_builds(self):
repo = Path(__file__).resolve().parent
path = repo / "shorthand.json"
if not path.exists():
self.skipTest("shorthand.json not available")
q_codes, abbreviations = sh.load_shorthand(path)
notes = sh.build_notes(q_codes, abbreviations)
self.assertEqual(len(notes), 2 * len(q_codes) + len(abbreviations))
# Every note carries exactly two cards.
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
if __name__ == "__main__":
unittest.main()
+213
View File
@@ -0,0 +1,213 @@
"""Tests for amateurfunk_technical.
Like the other deck tests, these inspect the generated `.apkg` directly
(ZIP entries plus the SQLite collection) and never require Anki. They
also validate the repo's real `technical.json` against the schema.
"""
import json
import sqlite3
import tempfile
import unittest
import zipfile
from pathlib import Path
import amateurfunk_technical as tech
from amateurfunk_anki import FIELD_SEP
def make_technical():
return {
"terms": [
{
"code": "SSB",
"category": "Betriebsart",
"meaning": "Single sideband.",
"exam": True,
"explanation": "One sideband, carrier suppressed.",
},
{
"code": "NF",
"category": "Signal & Frequenz",
"meaning": "Niederfrequenz — audio frequency.",
"exam": True,
},
{
"code": "SDR",
"category": "Digital & Daten",
"meaning": "Software defined radio.",
"tags": ["modern"],
},
],
}
def write_technical(root: Path, data=None):
path = root / "technical.json"
path.write_text(
json.dumps(data if data is not None else make_technical()),
encoding="utf-8",
)
return path
def extract_collection(apkg_path: Path, tmp: Path):
with zipfile.ZipFile(apkg_path) as zf:
zf.extract("collection.anki2", tmp)
media = json.loads(zf.read("media").decode("utf-8"))
names = set(zf.namelist())
return tmp / "collection.anki2", media, names
class TestTechnicalBuild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.path = write_technical(self.root)
self.out_dir = self.root / "anki"
def _build(self, **kwargs):
return tech.build_deck(self.path, self.out_dir, self.root / "data", **kwargs)
def _collection(self):
apkg = next(self.out_dir.glob("*.apkg"))
return extract_collection(apkg, self.root)
def test_one_note_two_cards_per_term(self):
result = self._build()
self.assertEqual(result["terms"], 3)
self.assertEqual(result["notes"], 3)
self.assertEqual(result["cards"], 6)
def test_deck_and_model_are_distinct_from_operating_deck(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
models = json.loads(conn.execute("select models from col").fetchone()[0])
finally:
conn.close()
self.assertIn(tech.DECK_NAME, [d["name"] for d in decks.values()])
self.assertEqual(
next(iter(models.values()))["name"], tech.MODEL_NAME
)
def test_category_becomes_kind_field_and_tag(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = dict(
(r[0], (r[1], r[2]))
for r in conn.execute("select sfld, flds, tags from notes")
)
finally:
conn.close()
flds, tags = rows["SSB"]
# Kind field (index 2) carries the German category.
self.assertEqual(flds.split(FIELD_SEP)[2], "Betriebsart")
self.assertIn(" technik ", tags)
self.assertIn(" kategorie-betriebsart ", tags)
self.assertIn(" pruefung ", tags)
# A non-exam term carries no pruefung tag but keeps extra tags.
_flds, sdr_tags = rows["SDR"]
self.assertNotIn("pruefung", sdr_tags)
self.assertIn(" modern ", sdr_tags)
def test_each_note_has_forward_and_reverse_card(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"select sfld, ord from notes join cards on cards.nid = notes.id"
).fetchall()
finally:
conn.close()
ords_for_ssb = sorted(o for s, o in rows if s == "SSB")
self.assertEqual(ords_for_ssb, [0, 1])
def test_no_media_in_package(self):
self._build()
_db, media, names = self._collection()
self.assertEqual(media, {})
self.assertEqual(names, {"collection.anki2", "media"})
def test_build_is_byte_deterministic_for_same_input(self):
out_a = self.root / "a"
out_b = self.root / "b"
tech.build_deck(self.path, out_a, self.root / "data", override_epoch=99)
tech.build_deck(self.path, out_b, self.root / "data", override_epoch=99)
for first in sorted(out_a.glob("*.apkg")):
self.assertEqual(
first.read_bytes(), (out_b / first.name).read_bytes(), first.name
)
def test_ids_are_namespaced_apart_from_operating_deck(self):
# A code that exists in both decks (e.g. a hypothetical clash)
# must not share a GUID across the two namespaces.
from amateurfunk_shorthand import make_note as sh_make_note
op = sh_make_note("SSB", "x", "k", "", None, None, " t ", "shorthand")
te = tech.build_notes(
[{"code": "SSB", "category": "Betriebsart", "meaning": "x"}]
)[0]
self.assertNotEqual(op["guid"], te["guid"])
self.assertNotEqual(op["note_id"], te["note_id"])
def test_duplicate_code_is_rejected(self):
data = make_technical()
data["terms"].append(
{"code": "SSB", "category": "Betriebsart", "meaning": "dup"}
)
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("SSB", str(ctx.exception))
def test_missing_category_is_rejected(self):
data = make_technical()
del data["terms"][0]["category"]
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("category", str(ctx.exception))
def test_unknown_field_is_rejected(self):
data = make_technical()
data["terms"][0]["author"] = "claude"
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("author", str(ctx.exception))
def test_missing_file_is_a_hard_error(self):
with self.assertRaises(tech.AnkiBuildError):
tech.build_deck(self.root / "nope.json", self.out_dir, self.root / "data")
def test_main_returns_success(self):
rc = tech.main([
"--technical", str(self.path),
"--out", str(self.out_dir),
"--data", str(self.root / "data"),
])
self.assertEqual(rc, tech.EXIT_OK)
class TestRealTechnicalFile(unittest.TestCase):
"""Validate the repo's actual technical.json against the schema."""
def test_repo_technical_builds(self):
repo = Path(__file__).resolve().parent
path = repo / "technical.json"
if not path.exists():
self.skipTest("technical.json not available")
terms = tech.load_technical(path)
notes = tech.build_notes(terms)
self.assertEqual(len(notes), len(terms))
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
if __name__ == "__main__":
unittest.main()