Compare commits
28 Commits
00feaf41fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f7de7debbb
|
|||
|
baeb325922
|
|||
|
4cb6412d4f
|
|||
|
3179f54681
|
|||
|
4a14bbb989
|
|||
|
989d6d6888
|
|||
|
189a1954f7
|
|||
|
c1603b1643
|
|||
|
c5c5f5f18e
|
|||
|
8fc3c0c32e
|
|||
|
6a4db1d889
|
|||
|
2b73096d3c
|
|||
|
fb81e9c1aa
|
|||
| 5c6fbebd65 | |||
|
3ce060947d
|
|||
|
fdc5932d7d
|
|||
|
7bf6f98ee2
|
|||
|
1222f86ae4
|
|||
|
2c3c8ffcc5
|
|||
|
899a169da7
|
|||
|
d1e096b13f
|
|||
|
81b70123bf
|
|||
|
e5af931c0a
|
|||
|
6c714c8b08
|
|||
|
a47795b081
|
|||
|
4a51997167
|
|||
|
7674643100
|
|||
|
044b68fff4
|
@@ -44,10 +44,16 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
|
||||
|
||||
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
||||
├── amateurfunk-technische-kenntnisse-n.apkg
|
||||
├── amateurfunk-technische-kenntnisse-e.apkg
|
||||
├── amateurfunk-technische-kenntnisse-a.apkg
|
||||
├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks)
|
||||
├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks)
|
||||
├── amateurfunk-betriebliche-kenntnisse.apkg
|
||||
└── amateurfunk-kenntnisse-von-vorschriften.apkg
|
||||
|
||||
shorthand.json ──[Stage 2b: amateurfunk_shorthand.py]──► anki/
|
||||
└── amateurfunk-abkuerzungen-q-gruppen.apkg
|
||||
|
||||
technical.json ──[Stage 2c: amateurfunk_technical.py]──► anki/
|
||||
└── amateurfunk-technische-abkuerzungen.apkg
|
||||
```
|
||||
|
||||
### Stage 1 — `amateurfunk_fetch.py`
|
||||
@@ -70,10 +76,16 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
||||
`manifest-latest.json` to a per-edition directory).
|
||||
2. Split the catalog into five categories. Betriebliche and
|
||||
Vorschriften get one deck each (shared across every candidate).
|
||||
Technische is additionally fanned out per license class into three
|
||||
decks (N / E / A) using a strict equality split on the question's
|
||||
`class` field. The `klasse-N|E|A` tag is still emitted on every
|
||||
note for inside-Anki filtering.
|
||||
Technische is additionally fanned out per license class using a
|
||||
strict equality split on the question's `class` field: one `.apkg`
|
||||
each for N, E, and A. The E (463) and A (716) packages — the large
|
||||
pools — are each built as a deck *tree*: one sub-deck per
|
||||
first-level catalog topic (the 11 subsections) under an anchoring
|
||||
`Technische Kenntnisse::E` / `::A` parent, so each imports as a
|
||||
single file but studies topic by topic. N stays a single flat deck.
|
||||
The set of split classes is `TOPIC_SPLIT_CLASSES`. The
|
||||
`klasse-N|E|A` tag is still emitted on every note for inside-Anki
|
||||
filtering.
|
||||
3. Render every question as an Anki note: shuffled A/B/C/D choices on
|
||||
the front, the displayed position of the correct answer on the
|
||||
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
||||
@@ -83,12 +95,54 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
||||
block is appended to the back; a "low confidence" badge shows for
|
||||
entries with `confidence < 7`.
|
||||
4. Hand-roll the v11 Anki collection (SQLite + JSON config) and
|
||||
package it as a `.apkg` ZIP with deterministic timestamps. Same
|
||||
input → byte-identical output across runs.
|
||||
package it as a `.apkg` ZIP with deterministic timestamps. By
|
||||
default each build mints a fresh shuffle seed, so answers are
|
||||
reshuffled every run. Pass `--seed` (and `--epoch`) for the
|
||||
reproducible-build contract: same catalog + same seed + same
|
||||
timestamp → byte-identical output across runs.
|
||||
|
||||
The Anki design decisions (shuffle seeding, deterministic build epoch,
|
||||
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
|
||||
|
||||
### Stage 2b — `amateurfunk_shorthand.py`
|
||||
|
||||
A sibling builder for a standalone reference deck of Q-groups and
|
||||
operating abbreviations — the ones in the exam plus the most common
|
||||
on-air shorthand the exam never covers (real operating knowledge, not
|
||||
just the test). Content lives in the hand-curated `shorthand.json`
|
||||
(editorial, tracked in git, like `explanations.json`); the
|
||||
`references/Q-Codes.md` reference is where the exam-present codes were
|
||||
catalogued.
|
||||
|
||||
Each code is one Anki *note* with two card templates — forward
|
||||
(code → meaning) and reverse (meaning → code) — so a single record
|
||||
drives both directions. A Q-group means one thing as a statement
|
||||
(`QSO`) and another as a question (`QSO?`), so each Q-group yields two
|
||||
notes; plain abbreviations yield one. All IDs/GUIDs are hashed from the
|
||||
displayed code form (stable re-import). The deck is catalog-independent
|
||||
and fully deterministic; it only consults `data/` to borrow the
|
||||
manifest build epoch when present. Low-level apkg/SQLite machinery is
|
||||
imported from `amateurfunk_anki.py` so the two stay in lockstep. The
|
||||
glossary machinery shared with Stage 2c (two-template note type,
|
||||
two-cards-per-note writer, packager, build-epoch resolver, entry
|
||||
validator) also lives here.
|
||||
|
||||
### Stage 2c — `amateurfunk_technical.py`
|
||||
|
||||
A third glossary deck, same card mechanics as Stage 2b (one note,
|
||||
forward+reverse templates), for the *technical* vocabulary rather than
|
||||
operating shorthand: modulation/modes (SSB, FM, CW), signal domains
|
||||
(NF, HF, ZF), building blocks (VFO, PLL, AGC), components, measurements
|
||||
(dB, SWR, PEP), propagation, digital modes, and the
|
||||
organisations/regulations (ITU, CEPT, EMV) — exam terms plus common HAM
|
||||
abbreviations beyond the exam. Content lives in the curated
|
||||
`technical.json`; each entry carries a German `category` (Betriebsart,
|
||||
Bauteil, …) shown on the card and used as a `kategorie-*` tag. The
|
||||
shared glossary machinery is imported from `amateurfunk_shorthand.py`;
|
||||
this script only adds the data shape, the deck/model names, and the tag
|
||||
scheme. IDs live in their own `technical` namespace so the two glossary
|
||||
decks never collide on import.
|
||||
|
||||
## Repo conventions
|
||||
|
||||
- Python 3.11+, standard library only. No third-party dependencies
|
||||
@@ -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
|
||||
or improve per-question explanations. The schema, the workflows
|
||||
("explain everything unexplained", "improve everything below
|
||||
confidence 7"), and the source/confidence guidance live there.
|
||||
`explanations.json` is an empty `{}` until agents populate it.
|
||||
confidence 7"), the source/confidence guidance, and the **MathJax
|
||||
typesetting rules (§4a) with a verification sweep** live there. The
|
||||
`$...$` typesetting is the most error-prone part of the file — read
|
||||
§4a before touching any formula, and fix every occurrence in both the
|
||||
explanation body and the `Hilfsmittel:` note.
|
||||
- Start from `DESIGN.md` — it has the JSON schema, the question/answer
|
||||
conventions (answer A is always correct upstream → consumers shuffle
|
||||
before display), the LaTeX-in-questions caveat, the exam-structure
|
||||
|
||||
@@ -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
|
||||
Anki `.apkg` files that turn every catalog question into a flash card.
|
||||
Same input must produce byte-identical output across runs — this is
|
||||
important so generated decks can be checksummed and cached cleanly.
|
||||
By default, each build uses a fresh shuffle seed so repeated imports
|
||||
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
|
||||
|
||||
@@ -475,9 +477,9 @@ amateurfunk-anki [--data DIR] [--out DIR] [--seed STR] [--epoch INT]
|
||||
- `--data DIR` — fetch output root (default `./data`). Must contain
|
||||
`manifest-latest.json` pointing at a per-edition directory.
|
||||
- `--out DIR` — destination for `.apkg` files (default `./anki`).
|
||||
- `--seed STR` — deterministic seed for answer shuffling. The default
|
||||
is fixed; changing it produces a different (but still deterministic)
|
||||
shuffle.
|
||||
- `--seed STR` — deterministic seed for answer shuffling. Omit it
|
||||
for the normal study-deck behavior: a fresh seed is generated for
|
||||
each build, so answers are reshuffled every time decks are rebuilt.
|
||||
- `--epoch INT` — override the package timestamp epoch. By default we
|
||||
derive it from the manifest's `fetched_at`; this flag is mainly for
|
||||
tests and explicit rebuilds.
|
||||
@@ -492,25 +494,44 @@ output artifacts.
|
||||
```
|
||||
anki/
|
||||
amateurfunk-technische-kenntnisse-n.apkg (195 cards)
|
||||
amateurfunk-technische-kenntnisse-e.apkg (463 cards)
|
||||
amateurfunk-technische-kenntnisse-a.apkg (716 cards)
|
||||
amateurfunk-technische-kenntnisse-e.apkg (463 cards, 11 sub-decks)
|
||||
amateurfunk-technische-kenntnisse-a.apkg (716 cards, 11 sub-decks)
|
||||
amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
|
||||
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
|
||||
```
|
||||
|
||||
Five `.apkg` files. Betriebliche and Vorschriften are shared across
|
||||
every candidate (class-1-only in the data per §3 axis 2) and stay as
|
||||
one deck each. Technische is fanned out per license class using a
|
||||
**strict equality split** on the question's `class` field — class-1
|
||||
questions land in the N deck only, class-2 in E only, class-3 in A
|
||||
only. The card counts therefore equal the new-at-this-class slices
|
||||
from §3, not the cumulative study pools: a candidate studying for
|
||||
class E imports Technische-N + Technische-E + Betriebliche +
|
||||
Vorschriften.
|
||||
Betriebliche and Vorschriften are shared across every candidate
|
||||
(class-1-only in the data per §3 axis 2) and stay as one deck each.
|
||||
Technische is fanned out per license class using a **strict equality
|
||||
split** on the question's `class` field — class-1 questions land in
|
||||
the N deck only, class-2 in E only, class-3 in A only. The card counts
|
||||
therefore equal the new-at-this-class slices from §3, not the
|
||||
cumulative study pools: a candidate studying for class E imports
|
||||
Technische-N + Technische-E + Betriebliche + 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
|
||||
(`Amateurfunk::Technische Kenntnisse::N`) so the three decks render
|
||||
as children of a shared parent in Anki's deck browser. The
|
||||
(`Amateurfunk::Technische Kenntnisse::N`, and one level deeper for E/A —
|
||||
`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
|
||||
note — redundant within each Technische deck but useful in Betr/Vor
|
||||
for in-Anki filtering, and harmless besides.
|
||||
@@ -565,8 +586,10 @@ for in-Anki filtering, and harmless besides.
|
||||
|
||||
### Determinism
|
||||
|
||||
The contract is: same catalog in → same `.apkg` bytes out.
|
||||
Determinism rests on three things:
|
||||
The default CLI intentionally is not byte-deterministic because it
|
||||
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
|
||||
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
|
||||
archive's per-entry mtimes would still vary between runs.
|
||||
|
||||
The combined effect: two runs with the same `data/` produce
|
||||
byte-identical sha256 on each `.apkg`. Verified during review.
|
||||
The combined effect: two runs with the same `data/`, `--seed`, and
|
||||
timestamp inputs produce byte-identical sha256 on each `.apkg`.
|
||||
Verified during review.
|
||||
|
||||
### Rendering decisions worth knowing
|
||||
|
||||
@@ -636,7 +660,7 @@ byte-identical sha256 on each `.apkg`. Verified during review.
|
||||
`explanations.json` (CLI: `--explanations`) and, for any
|
||||
question number found there, appends an English explanation
|
||||
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`
|
||||
field is below `LOW_CONFIDENCE_THRESHOLD` (= 7). `revision` and
|
||||
the raw `confidence` number are editorial-only and never
|
||||
|
||||
+209
-16
@@ -27,21 +27,31 @@ entry is purely additive — no regenerate ceremony beyond
|
||||
|
||||
### 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 |
|
||||
|---------------|---------|--------------------------------------------|
|
||||
| `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)` |
|
||||
| `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
|
||||
with `unknown fields [...]` listing them. The loader is similarly
|
||||
strict about types: a JSON `true` will not satisfy the integer
|
||||
contract for `revision` or `confidence`. If you need to track
|
||||
editorial metadata that isn't shown on the card, propose a schema
|
||||
change rather than smuggling fields in.
|
||||
`provenance` records **how the text was produced**, which `source` (a
|
||||
citation) does not. Set it to `"50ohm-loesungsweg"` only on entries
|
||||
that are genuinely a translation/condensation of a 50ohm.de worked
|
||||
solution (`contents/solutions/<ID>.md` in `DARC-e-V/50ohm-contents-dl`).
|
||||
The build uses it — and *not* the `source` domain — to decide whether
|
||||
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
|
||||
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
|
||||
question text), and a "Source: ..." line.
|
||||
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
|
||||
entry's `confidence` is **below 7**, a small "low confidence"
|
||||
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
|
||||
on the card; the explanation is the one English element. Don't
|
||||
switch.
|
||||
- **Terse.** Aim for 1–3 sentences. The card already shows the
|
||||
question, the correct answer text, and the breadcrumb — the
|
||||
explanation should add the missing *why*, not restate any of
|
||||
those.
|
||||
- **Correct and helpful first; length follows the topic.** There is
|
||||
no sentence cap. Use as much room as the question genuinely needs —
|
||||
a one-line arithmetic restatement for a simple lookup, a full
|
||||
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
|
||||
is X because..." — the card already declares the correct answer
|
||||
one line above. Go straight to the principle, the formula, the
|
||||
rule of thumb.
|
||||
- **Math.** Use inline `$...$`; it's rewritten to MathJax on the
|
||||
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
|
||||
type by hand. Everything else gets HTML-escaped.
|
||||
- **No spoilers about other answers.** If a distractor is a common
|
||||
trap, naming the trap is fine; quoting the distractor's text is
|
||||
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
|
||||
|
||||
| Less useful | More useful |
|
||||
@@ -171,6 +310,43 @@ band-plan lookup), say that plainly and cite the band plan in
|
||||
`source` — confidence stays low (3–4) until someone finds a deeper
|
||||
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. 2–3)").
|
||||
- **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
|
||||
@@ -236,6 +412,11 @@ The full priority order, top (most preferred) to bottom:
|
||||
official explanatory notes.
|
||||
- Useful when primary law is too terse to convey the *why* on
|
||||
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.**
|
||||
- DARC publications and band plan (`darc.de`), Runder Tisch
|
||||
@@ -401,13 +582,25 @@ Before saving, confirm:
|
||||
|
||||
- [ ] Key is the exact catalog `number` (case-sensitive).
|
||||
- [ ] 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.
|
||||
- [ ] `revision` is correct (1 for new, bumped for improvement).
|
||||
- [ ] `confidence` honestly reflects how well the source supports
|
||||
the explanation.
|
||||
- [ ] 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.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
python3 amateurfunk_fetch.py
|
||||
@@ -8,8 +8,14 @@ fetch:
|
||||
anki:
|
||||
python3 amateurfunk_anki.py
|
||||
|
||||
shorthand:
|
||||
python3 amateurfunk_shorthand.py
|
||||
|
||||
technical:
|
||||
python3 amateurfunk_technical.py
|
||||
|
||||
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:
|
||||
rm -rf data anki
|
||||
rm -rf data anki __pycache__
|
||||
|
||||
@@ -6,19 +6,33 @@ Bundesnetzagentur and turn it into Anki decks.
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
make # fetch + build (default)
|
||||
make # fetch + build everything (default)
|
||||
make fetch # download + extract the catalog only → data/
|
||||
make anki # rebuild .apkg files from data/ → anki/
|
||||
make test # run both test suites
|
||||
make anki # rebuild the five exam decks from data/ → anki/
|
||||
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/
|
||||
```
|
||||
|
||||
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);
|
||||
Technische is split per class into three decks (N / E / A) following
|
||||
the catalog's `class` field. A class-A candidate who wants every
|
||||
Technische question imports all three Technische decks. Re-importing
|
||||
a newer build preserves your review history.
|
||||
Technische question imports all three Technische decks. The E (463)
|
||||
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
|
||||
|
||||
@@ -36,18 +50,63 @@ belongs to:
|
||||
|
||||
**Technical (one deck per license class):**
|
||||
|
||||
| Class | Section name | ID prefix | Questions |
|
||||
|-------|---------------------------|-----------|----------:|
|
||||
| N | Technische Kenntnisse (N) | `N*` | 195 |
|
||||
| E | Technische Kenntnisse (E) | `E*` | 463 |
|
||||
| A | Technische Kenntnisse (A) | `A*` | 716 |
|
||||
| Class | Section name | ID prefix | Questions | Layout |
|
||||
|-------|---------------------------|-----------|----------:|--------|
|
||||
| N | Technische Kenntnisse (N) | `N*` | 195 | one flat deck |
|
||||
| E | Technische Kenntnisse (E) | `E*` | 463 | 11 topic sub-decks |
|
||||
| A | Technische Kenntnisse (A) | `A*` | 716 | 11 topic sub-decks |
|
||||
|
||||
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-A candidate is responsible for everything. Filter inside
|
||||
Anki by deck, by the `klasse-N|E|A` tag, or by the `Number` field
|
||||
prefix.
|
||||
Anki by deck, by the `klasse-N|E|A` tag, or — for the topic within a
|
||||
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
|
||||
|
||||
@@ -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`
|
||||
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
|
||||
|
||||
- [**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
|
||||
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
|
||||
guidance, confidence scale, and the workflows an AI agent should
|
||||
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
|
||||
|
||||
- `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 contracts.
|
||||
- `EXPLANATIONS.md` — schema + workflows for the explanations
|
||||
database.
|
||||
- `shorthand.json` / `technical.json` — curated source data for the
|
||||
two [glossary decks](#glossary-decks).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+348
-35
@@ -33,6 +33,7 @@ import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
@@ -62,6 +63,17 @@ DEFAULT_EXPLANATIONS_PATH = Path("explanations.json")
|
||||
# EXPLANATIONS.md for the full editorial contract.
|
||||
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
|
||||
# the card as a "low confidence" badge — a learner-facing warning
|
||||
# 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").
|
||||
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 & condensed from the worked solution on "
|
||||
'<a href="https://50ohm.de/">50ohm.de</a> — '
|
||||
"© 50ohm.de-Autorenteam (AJW-Referat, DARC e. 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 & figures:</b> official question catalog "
|
||||
""Prüfungsfragen zum Erwerb von "
|
||||
"Amateurfunkprüfungsbescheinigungen", 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 "
|
||||
"("Lösungsweg"), 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 "Hilfsmittel" "
|
||||
"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
|
||||
# no "operator must resolve local state" case here, so two codes are
|
||||
# enough.
|
||||
@@ -102,6 +157,31 @@ CLASS_ORDER = [("1", "N"), ("2", "E"), ("3", "A")]
|
||||
# shared across all candidates and stay as a single deck each.
|
||||
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
|
||||
# one. Picked as 0 so missing-metadata builds are still deterministic
|
||||
# and obviously wrong (timestamps would all show 1970).
|
||||
@@ -144,13 +224,17 @@ class Category:
|
||||
`title` is the original German title (with the Prüfungsteil
|
||||
prefix); `short_title` has the prefix stripped (used in the deck
|
||||
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
|
||||
short_title: str
|
||||
slug: str
|
||||
questions: list
|
||||
subdeck_by_topic: bool = False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -204,7 +288,7 @@ def load_explanations(path):
|
||||
{
|
||||
"NA101": {
|
||||
"revision": 1,
|
||||
"explanation": "Terse English text explaining why ...",
|
||||
"explanation": "English text explaining why ...",
|
||||
"source": "https://... or AFuV §16(2)",
|
||||
"confidence": 7
|
||||
},
|
||||
@@ -233,12 +317,15 @@ def load_explanations(path):
|
||||
def _validate_explanation(key, value):
|
||||
"""Raise `AnkiBuildError` if one explanation entry is malformed.
|
||||
|
||||
Strict shape check: the four documented fields are required, no
|
||||
extras allowed, and integer fields reject `bool` (which would
|
||||
pass `isinstance(..., int)` because `bool` subclasses `int` in
|
||||
Python). Extras are rejected so a stray `"note"` or `"author"`
|
||||
field can't accumulate silently and drift from the documented
|
||||
schema in EXPLANATIONS.md.
|
||||
Strict shape check: the four documented fields are required and the
|
||||
optional `provenance` field is allowed (and, if present, must be a
|
||||
string from `ALLOWED_PROVENANCE`); any *other* extra field is
|
||||
rejected, so a stray `"note"` or `"author"` field can't accumulate
|
||||
silently and drift from the documented 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):
|
||||
raise AnkiBuildError(
|
||||
@@ -249,11 +336,24 @@ def _validate_explanation(key, value):
|
||||
raise AnkiBuildError(
|
||||
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:
|
||||
raise AnkiBuildError(
|
||||
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:
|
||||
raise AnkiBuildError(
|
||||
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
|
||||
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)
|
||||
for digit, letter in CLASS_ORDER:
|
||||
@@ -367,6 +470,7 @@ def _split_by_class(title, short_title, questions):
|
||||
short_title=f"{short_title}::{letter}",
|
||||
slug=f"{base_slug}-{letter.lower()}",
|
||||
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">'
|
||||
'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 (
|
||||
'<div class="af-explanation">'
|
||||
f'<div class="af-explanation-header">Explanation{badge}</div>'
|
||||
f'<div class="af-explanation-body">{body}</div>'
|
||||
f'<div class="af-explanation-source">Source: {source_html}</div>'
|
||||
f'{credit}'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
@@ -738,6 +854,8 @@ class MediaRegistry:
|
||||
self.used_paths = set()
|
||||
# references that didn't resolve (recorded for diagnostics)
|
||||
self.missing = []
|
||||
# Path → intrinsic (w, h) in px (memoised; None when unreadable)
|
||||
self._size_cache = {}
|
||||
self._index_media_dir()
|
||||
|
||||
def _index_media_dir(self):
|
||||
@@ -779,12 +897,43 @@ class MediaRegistry:
|
||||
return None
|
||||
|
||||
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)
|
||||
if path is None:
|
||||
return ""
|
||||
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")
|
||||
|
||||
deck_name = f"Amateurfunk::{category.short_title}"
|
||||
deck_id = stable_id("deck", deck_name)
|
||||
# The category's own deck is always present (even empty, it anchors
|
||||
# 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")
|
||||
|
||||
try:
|
||||
@@ -857,11 +1010,14 @@ def build_apkg_for_category(
|
||||
number = str(question.get("number", f"q{ordinal}"))
|
||||
if number in explanations:
|
||||
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}")
|
||||
card_id = stable_id("card", f"{category.slug}:{number}")
|
||||
fields = [
|
||||
number,
|
||||
category.short_title,
|
||||
deck_short,
|
||||
display_path(item.path),
|
||||
front,
|
||||
back,
|
||||
@@ -870,6 +1026,7 @@ def build_apkg_for_category(
|
||||
notes.append({
|
||||
"note_id": note_id,
|
||||
"card_id": card_id,
|
||||
"deck_id": deck_id,
|
||||
"guid": stable_guid(f"{category.slug}:{number}"),
|
||||
"fields": FIELD_SEP.join(fields),
|
||||
"sort": number,
|
||||
@@ -881,8 +1038,8 @@ def build_apkg_for_category(
|
||||
|
||||
create_collection_db(
|
||||
db_path,
|
||||
deck_id=deck_id,
|
||||
deck_name=deck_name,
|
||||
decks=decks,
|
||||
cur_deck_id=root_deck_id,
|
||||
model_id=model_id,
|
||||
notes=notes,
|
||||
now=build_epoch,
|
||||
@@ -895,7 +1052,8 @@ def build_apkg_for_category(
|
||||
|
||||
return {
|
||||
"path": out_path,
|
||||
"deck": deck_name,
|
||||
"deck": root_deck_name,
|
||||
"subdecks": len(decks) - 1,
|
||||
"questions": len(category.questions),
|
||||
"media": len(media_paths),
|
||||
"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):
|
||||
"""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")
|
||||
|
||||
|
||||
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):
|
||||
"""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.
|
||||
|
||||
Uses the v11 schema, which modern Anki still understands (it
|
||||
upgrades the collection on first open). We pre-write a single
|
||||
`col` row with the JSON config blobs, then insert one `notes`
|
||||
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)
|
||||
try:
|
||||
create_schema(conn)
|
||||
insert_collection_metadata(
|
||||
conn,
|
||||
deck_id=deck_id,
|
||||
deck_name=deck_name,
|
||||
decks=decks,
|
||||
cur_deck_id=cur_deck_id,
|
||||
model_id=model_id,
|
||||
now=now,
|
||||
)
|
||||
@@ -1061,7 +1308,7 @@ def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now):
|
||||
(
|
||||
note["card_id"],
|
||||
note["note_id"],
|
||||
deck_id,
|
||||
note["deck_id"],
|
||||
0,
|
||||
now,
|
||||
-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.
|
||||
|
||||
`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(
|
||||
"""
|
||||
INSERT INTO col
|
||||
@@ -1183,15 +1444,12 @@ def insert_collection_metadata(conn, deck_id, deck_name, model_id, now):
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
json.dumps(collection_conf(deck_id), separators=(",", ":")),
|
||||
json.dumps(collection_conf(cur_deck_id), separators=(",", ":")),
|
||||
json.dumps(
|
||||
{str(model_id): model_json(model_id, now)},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
json.dumps(
|
||||
{str(deck_id): deck_json(deck_id, deck_name, now)},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
json.dumps(decks_json, 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`.
|
||||
|
||||
The `Amateurfunk::Foo` naming gives the user a single top-level
|
||||
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 {
|
||||
"id": deck_id,
|
||||
@@ -1246,7 +1507,7 @@ def deck_json(deck_id, deck_name, now):
|
||||
"browserCollapsed": False,
|
||||
"dyn": 0,
|
||||
"conf": 1,
|
||||
"desc": "",
|
||||
"desc": desc,
|
||||
"extendNew": 0,
|
||||
"extendRev": 0,
|
||||
}
|
||||
@@ -1415,7 +1676,7 @@ CARD_CSS = """
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
font-style: italic;
|
||||
font-style: normal;
|
||||
}
|
||||
.af-explanation-header {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -1451,6 +1712,47 @@ CARD_CSS = """
|
||||
color: #1a73e8;
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Build every category's `.apkg` and return their result dicts.
|
||||
|
||||
@@ -1546,8 +1855,11 @@ def _parse_args(argv):
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed",
|
||||
default="amateurfunk-anki-v1",
|
||||
help="deterministic seed for answer shuffling",
|
||||
default=None,
|
||||
help=(
|
||||
"deterministic seed for answer shuffling; omit to reshuffle "
|
||||
"answers on every build"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--epoch",
|
||||
@@ -1573,11 +1885,12 @@ def _parse_args(argv):
|
||||
def main(argv=None):
|
||||
"""Top-level entry point. Returns an exit code; never raises."""
|
||||
args = _parse_args(argv)
|
||||
seed = resolve_shuffle_seed(args.seed)
|
||||
try:
|
||||
results = build_all(
|
||||
args.data,
|
||||
args.out,
|
||||
seed=args.seed,
|
||||
seed=seed,
|
||||
override_epoch=args.epoch,
|
||||
explanations_path=args.explanations,
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
+10037
-399
File diff suppressed because it is too large
Load Diff
@@ -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` |
|
||||
@@ -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. |
|
||||
@@ -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. 1–3 — Frequenzbereichszuweisung (Anlage 1, Amateurfunkverordnung)
|
||||
|
||||
- **S. 1 — Nutzungsbedingungen (¶1–4):** 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 1247–1263 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. 4–7 — Rufzeichenplan für den Amateurfunkdienst in Deutschland
|
||||
|
||||
- **S. 4** — intro: structure of German call signs (prefix DA–DR excl.
|
||||
DE/DI, digit, 2–3-char suffix).
|
||||
- **S. 5 — Tabelle "Rufzeichen mit 2- oder 3-buchstabigen Suffixen":**
|
||||
the main **call-sign-series → class/use** table. DA0 = Klubstation;
|
||||
DL1–DL9 = person-bound class A; DO1–DO9 = class E; DN9 = class N;
|
||||
DP is intended for exterritorial-location call signs; DP0–DP1 =
|
||||
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 (4–7-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**); §§ 7–8 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. 9–10 — 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_d−a)/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_v−U_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| = (s−1)/(s+1) = |(R2−Z)/(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. 19–22 — Konstanten und Tabellen
|
||||
|
||||
- **S. 19–20** — 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>
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import amateurfunk_anki as aa
|
||||
|
||||
@@ -108,6 +109,24 @@ def extract_collection(apkg_path: Path, tmp: Path):
|
||||
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):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
@@ -174,6 +193,48 @@ class TestAnkiBuild(unittest.TestCase):
|
||||
{"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):
|
||||
self._build_all()
|
||||
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("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):
|
||||
self._build_all()
|
||||
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
|
||||
@@ -440,6 +545,48 @@ class TestAnkiBuild(unittest.TestCase):
|
||||
self._build_all()
|
||||
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):
|
||||
self.explanations_path.write_text(
|
||||
json.dumps({
|
||||
@@ -479,6 +626,7 @@ class TestAnkiBuild(unittest.TestCase):
|
||||
common = [
|
||||
"--data", str(self.data_dir),
|
||||
"--explanations", str(self.explanations_path),
|
||||
"--seed", "stable-test-seed",
|
||||
"--epoch", "1234567890",
|
||||
]
|
||||
aa.main([*common, "--out", str(out_a)])
|
||||
@@ -488,6 +636,257 @@ class TestAnkiBuild(unittest.TestCase):
|
||||
second = out_b / 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):
|
||||
"""Validate the repo's actual explanations.json against the live catalog.
|
||||
|
||||
The other tests are hermetic by design: they exercise the schema
|
||||
validator on synthetic fixtures so the validator's own logic is
|
||||
tested without coupling to editorial data. That leaves a gap — a
|
||||
real malformed entry or stale key in the repo's explanations.json
|
||||
would not show up in `python3 -m unittest`. This test closes it
|
||||
when the fetched catalog is present, and skips otherwise so an
|
||||
unfetched checkout still runs the rest of the suite.
|
||||
"""
|
||||
|
||||
def test_repo_explanations_pass_schema_and_match_catalog(self):
|
||||
repo = Path(__file__).resolve().parent
|
||||
explanations_path = repo / "explanations.json"
|
||||
catalogs = sorted((repo / "data").glob("*/fragenkatalog*.json"))
|
||||
if not explanations_path.exists() or not catalogs:
|
||||
self.skipTest("explanations.json or fetched catalog not available")
|
||||
explanations = aa.load_explanations(explanations_path)
|
||||
catalog = json.loads(catalogs[-1].read_text("utf-8"))
|
||||
categories = aa.collect_categories(catalog)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user