Compare commits

...

12 Commits

16 changed files with 8251 additions and 4229 deletions
+62 -8
View File
@@ -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 `\(...\)`
@@ -92,6 +104,45 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
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
@@ -112,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
+32 -13
View File
@@ -494,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.
+191 -9
View File
@@ -27,7 +27,8 @@ 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 |
|---------------|---------|--------------------------------------------|
@@ -35,13 +36,22 @@ Every entry MUST have exactly these four fields:
| `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
@@ -156,13 +166,137 @@ source citation.
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 |
@@ -176,6 +310,43 @@ band-plan lookup), say that plainly and cite the band plan in
`source` — confidence stays low (34) until someone finds a deeper
hook.
### The Hilfsmittel note
Candidates may use the official exam aid (`Hilfsmittel_12062024.pdf`)
during the exam. Its complete contents — every formula and table, with
**printed page numbers** — are catalogued in
[`references/Hilfsmittel.md`](references/Hilfsmittel.md). When a
question's answer is a lookup or a direct application of something in
that sheet, append a short note to the end of the `explanation` body:
```
... <u>Hilfsmittel:</u> <pointer>
```
Rules — this is the part that was historically done badly (generic
boilerplate stamped on everything), so be strict:
- **Only cite what is actually in the sheet.** Verify against
`references/Hilfsmittel.md`. If the fact the question turns on is a
memory item — diode forward voltages (~0.6 V / ~0.3 V), `tan δ = 1/Q`,
S-meter step = 6 dB, harmonics = n × fundamental, ppm/percent
arithmetic, semiconductor behaviour, definitions, antenna
length/shortening factors — **do not add a Hilfsmittel note at all.**
- **Name the specific formula(s) or table, and the page.** Not "the
formula is in the Formelsammlung" but e.g. `P = U²/R (Leistung,
S.12)` or `the Widerstands-Farbcode table (S.11)`.
- **For multi-step calculations, give the order:** "first
`U_eff = Û/√2` (Wechselspannung, S.12), then `P = U_eff²/R`
(Leistung, S.12)". State plainly when a value comes from outside the
sheet (e.g. a diode drop) versus from a sheet formula.
- **For a pure table lookup**, say it is a lookup and cite the table +
page (e.g. "a table lookup, not a memory item — the band limits …
are in the Frequenzbereichszuweisung (Anlage 1, Tabellarische
Übersicht, S. 23)").
- **Page numbering:** the printed page number = PDF page 2 (the cover
and the "Hinweis" page are unnumbered). Always cite the printed
number, as `references/Hilfsmittel.md` does.
---
## 5. Confidence scale
@@ -418,7 +589,18 @@ Before saving, confirm:
- [ ] `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.)
---
+9 -3
View File
@@ -1,6 +1,6 @@
.PHONY: all fetch anki test clean
.PHONY: all fetch anki shorthand technical test clean
all: fetch anki
all: fetch anki shorthand technical
fetch:
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 __pycache__
+108 -16
View File
@@ -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,6 +189,13 @@ 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.
@@ -135,15 +222,20 @@ not part of the build, and are **not** bundled into the `.apkg` decks.
- `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.
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
+309 -30
View File
@@ -63,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
@@ -70,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 &amp; condensed from the worked solution on "
'<a href="https://50ohm.de/">50ohm.de</a> — '
"© 50ohm.de-Autorenteam (AJW-Referat, DARC e.&nbsp;V.), "
'<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. '
"Question © Bundesnetzagentur, "
'<a href="https://www.govdata.de/dl-de/by-2-0">DL-DE→BY-2.0</a>.'
)
DECK_ATTRIBUTION = (
"Amateurfunk-Lernkarten / amateur-radio exam flashcards.<br><br>"
"<b>Questions &amp; figures:</b> official question catalog "
"&quot;Prüfungsfragen zum Erwerb von "
"Amateurfunkprüfungsbescheinigungen&quot;, Bundesnetzagentur, "
"3. Auflage, März 2024 "
"(https://www.bundesnetzagentur.de/amateurfunk). Licensed under "
"Datenlizenz Deutschland Namensnennung Version 2.0 "
"(https://www.govdata.de/dl-de/by-2-0).<br><br>"
"<b>Explanations:</b> written for this project. Where an explanation "
"is marked as derived from a 50ohm.de worked solution "
"(&quot;Lösungsweg&quot;), that text is © 50ohm.de-Autorenteam, "
"koordiniert durch das AJW-Referat des DARC e. V., licensed under "
"CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), and has "
"been modified — translated from German and shortened. Other "
"explanations cite their own sources.<br><br>"
"<b>Exam-aid references</b> cite the BNetzA &quot;Hilfsmittel&quot; "
"formula sheet.<br><br>"
"This deck is an independent project and is not endorsed by the "
"Bundesnetzagentur or by the DARC e. V."
)
# Exit codes. The builder is much simpler than the fetcher — there is
# no "operator must resolve local state" case here, so two codes are
# enough.
@@ -103,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).
@@ -145,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
# ============================================================================
@@ -234,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(
@@ -250,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"
@@ -355,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:
@@ -368,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),
)
@@ -603,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>'
)
@@ -739,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):
@@ -780,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]
# ============================================================================
@@ -843,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:
@@ -858,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,
@@ -871,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,
@@ -882,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,
@@ -896,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)),
@@ -904,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.
@@ -988,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>`.
@@ -1013,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,
)
@@ -1062,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,
@@ -1163,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
@@ -1184,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=(",", ":")),
"{}",
),
@@ -1228,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,
@@ -1247,7 +1507,7 @@ def deck_json(deck_id, deck_name, now):
"browserCollapsed": False,
"dyn": 0,
"conf": 1,
"desc": "",
"desc": desc,
"extendNew": 0,
"extendRev": 0,
}
@@ -1452,6 +1712,17 @@ 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;
@@ -1469,6 +1740,14 @@ CARD_CSS = """
.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;
+747
View File
@@ -0,0 +1,747 @@
#!/usr/bin/env python3
"""Build an Anki deck of Q-groups and operating abbreviations.
This is a sibling of `amateurfunk_anki.py`. Where that script turns the
BNetzA question catalog into multiple-choice decks, this one builds a
single reference deck from a hand-curated database (`shorthand.json`):
the Q-groups and operating abbreviations a candidate meets in the exam,
plus the most common ones used on the air that the exam never mentions
(so the deck teaches real operating knowledge, not just the test).
Design follows the four rules the deck was specified with:
1. Stable IDs. Every deck/model/note/card id and note GUID is hashed
from the displayed code form, so re-importing updates in place
instead of duplicating.
2. Two cards per code. Each note carries two templates one prompts
for the meaning given the code, the reverse prompts for the code
given the meaning. Modelling them as a single *note* (rule 4) is
what lets one record drive both directions without us emitting two
hand-built cards.
3. Q-groups get one note per usage. A Q-group means one thing as a
statement (`QSO`) and another as a question (`QSO?`), so each
yields two notes and therefore two forward/reverse pairs.
Plain abbreviations have only one form and yield one note.
Everything is stdlib only. The heavy lifting (SQLite schema, the apkg
ZIP writer, the deterministic timestamps, the ID hashing) is shared
with `amateurfunk_anki` by import, so the two stay in lockstep.
"""
import argparse
import datetime as dt
import html
import json
import re
import shutil
import sqlite3
import sys
from pathlib import Path
# Reuse the low-level Anki package machinery rather than copying it.
# These helpers are pure and model-agnostic; only the note type, the
# card templates, and the card-per-note fan-out are specific to this
# deck and are defined below.
from amateurfunk_anki import (
AnkiBuildError,
DEFAULT_BUILD_EPOCH,
FIELD_SEP,
build_epoch_from_manifest,
checksum_sort_field,
collection_conf,
create_schema,
deck_json,
default_deck_conf,
field_json,
load_latest_catalog,
slugify,
stable_guid,
stable_id,
text_html,
write_apkg,
)
# ============================================================================
# Constants
# ============================================================================
# Curated source database. Editorial content tracked in git, not a
# build artifact — same status as `explanations.json` in the sibling
# script.
DEFAULT_SHORTHAND_PATH = Path("shorthand.json")
# Default location of the fetcher's output. Only consulted to borrow a
# deterministic build epoch from the catalog manifest; the deck content
# itself does not depend on the catalog, so a missing data dir is not
# fatal here (unlike in `amateurfunk_anki`).
DEFAULT_DATA_DIR = Path("data")
# Default destination for the generated `.apkg` (shared with the
# multiple-choice decks).
DEFAULT_OUT_DIR = Path("anki")
# Human-facing deck name. The `Amateurfunk::` prefix nests it under the
# same top-level node as the catalog decks in Anki's browser.
DECK_NAME = "Amateurfunk::Abkürzungen & Q-Gruppen"
# Note-type name. Distinct from the multiple-choice model so importing
# both decks never merges the two note types.
MODEL_NAME = "Amateurfunk Q-Gruppe / Abkürzung"
# User-facing kind labels, shown on the card and used to build the
# `q-gruppe` / `abkuerzung` filter tags.
KIND_QGROUP = "Q-Gruppe"
KIND_ABBREV = "Abkürzung"
# Usage-form labels for Q-groups. A Q-group statement (`QSO`) and the
# matching question (`QSO?`) become two separate notes (rule 3).
FORM_STATEMENT = "Aussageform"
FORM_QUESTION = "Frageform"
# Exit codes (mirrors the sibling script's two-state convention).
EXIT_OK = 0
EXIT_ERROR = 1
# Fields allowed on a Q-group entry / an abbreviation entry in
# `shorthand.json`. Extras are rejected so a typo can't silently shift
# meaning onto a card that never renders it.
QGROUP_REQUIRED = ("code", "question", "statement")
QGROUP_OPTIONAL = ("exam", "explanation", "example", "tags")
ABBREV_REQUIRED = ("code", "meaning")
ABBREV_OPTIONAL = ("exam", "explanation", "example", "tags")
# Inline `` `code` `` spans in the curated examples become <code> tags.
BACKTICK_SPAN_RE = re.compile(r"`([^`]+)`")
# ============================================================================
# Loading and validating the curated database
# ============================================================================
def load_shorthand(path):
"""Return the validated `(q_codes, abbreviations)` from a JSON file.
The file is editorial content with a strict shape (see the field
allow-lists above). Unlike the explanations database in the sibling
script, this file is mandatory: there is no deck without it, so a
missing or malformed file is a hard error.
"""
try:
raw = json.loads(path.read_text("utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise AnkiBuildError(
f"could not read shorthand file {path}: {e}"
) from e
if not isinstance(raw, dict):
raise AnkiBuildError(
f"shorthand file {path} must contain a JSON object at the top level"
)
q_codes = raw.get("q_codes", [])
abbreviations = raw.get("abbreviations", [])
if not isinstance(q_codes, list) or not isinstance(abbreviations, list):
raise AnkiBuildError(
f"shorthand file {path}: 'q_codes' and 'abbreviations' must be lists"
)
for entry in q_codes:
validate_entry(entry, QGROUP_REQUIRED, QGROUP_OPTIONAL, "q_codes")
for entry in abbreviations:
validate_entry(entry, ABBREV_REQUIRED, ABBREV_OPTIONAL, "abbreviations")
_check_no_duplicate_codes(q_codes, abbreviations)
return q_codes, abbreviations
def validate_entry(entry, required, optional, where):
"""Raise `AnkiBuildError` if one database entry is malformed."""
if not isinstance(entry, dict):
raise AnkiBuildError(f"{where}: every entry must be a JSON object")
label = entry.get("code", "<no code>")
missing = [f for f in required if f not in entry]
if missing:
raise AnkiBuildError(
f"{where} entry {label!r} missing required fields: {missing}"
)
extra = sorted(set(entry) - set(required) - set(optional))
if extra:
raise AnkiBuildError(f"{where} entry {label!r}: unknown fields {extra}")
for field in required + ("explanation", "example"):
if field in entry and (
not isinstance(entry[field], str) or not entry[field].strip()
):
raise AnkiBuildError(
f"{where} entry {label!r}: {field!r} must be a non-empty string"
)
if "exam" in entry and not isinstance(entry["exam"], bool):
raise AnkiBuildError(
f"{where} entry {label!r}: 'exam' must be a boolean"
)
if "tags" in entry:
tags = entry["tags"]
if not isinstance(tags, list) or not all(
isinstance(t, str) and t.strip() for t in tags
):
raise AnkiBuildError(
f"{where} entry {label!r}: 'tags' must be a list of non-empty strings"
)
def _check_no_duplicate_codes(q_codes, abbreviations):
"""Raise if any displayed code form would collide.
Displayed forms are the per-note keys that every stable ID and GUID
is hashed from, so a duplicate would silently overwrite another
note on import. Q-groups expand to `CODE` and `CODE?`; abbreviations
stay as `CODE`.
"""
seen = {}
forms = [entry["code"] for entry in q_codes]
forms += [entry["code"] + "?" for entry in q_codes]
forms += [entry["code"] for entry in abbreviations]
duplicates = sorted({form for form in forms if forms.count(form) > 1})
if duplicates:
raise AnkiBuildError(
"duplicate code form(s) in shorthand database: "
+ ", ".join(duplicates)
)
return seen
# ============================================================================
# Note expansion
# ============================================================================
def build_notes(q_codes, abbreviations):
"""Expand the curated entries into the flat list of notes to emit.
Each Q-group becomes two notes (statement form `QSO`, question form
`QSO?`); each abbreviation becomes one. Order is stable: Q-groups in
file order first (statement then question for each), abbreviations
after, so the deck's `due` ordering is deterministic.
"""
notes = []
for entry in q_codes:
notes.append(
_note_record(
code_display=entry["code"],
meaning=entry["statement"],
kind=KIND_QGROUP,
form=FORM_STATEMENT,
entry=entry,
)
)
notes.append(
_note_record(
code_display=entry["code"] + "?",
meaning=entry["question"],
kind=KIND_QGROUP,
form=FORM_QUESTION,
entry=entry,
)
)
for entry in abbreviations:
notes.append(
_note_record(
code_display=entry["code"],
meaning=entry["meaning"],
kind=KIND_ABBREV,
form="",
entry=entry,
)
)
return notes
def make_note(code_display, meaning, kind, form, explanation, example, tags, namespace):
"""Render one two-card note (forward + reverse) for a glossary deck.
Shared by every glossary-style deck (operating shorthand here, the
technical-abbreviation deck in `amateurfunk_technical.py`). All
identity (`note_id`, `guid`, and the per-template card ids) is
hashed from `code_display` under `namespace`, so the two decks get
independent, stable IDs that never collide on import. The fields are
stored pre-escaped; the card templates only lay them out.
"""
fields = [
html.escape(code_display),
text_html(meaning),
kind,
form,
text_html(explanation) if explanation else "",
render_example(example) if example else "",
]
return {
"note_id": stable_id(f"{namespace}-note", code_display),
"guid": stable_guid(f"{namespace}:{code_display}"),
"fields": FIELD_SEP.join(fields),
"sort": code_display,
"tags": tags,
"card_ids": [
stable_id(f"{namespace}-card", f"{code_display}:{ord_}")
for ord_ in range(2)
],
}
def _note_record(code_display, meaning, kind, form, entry):
"""Build one operating-deck note under the `shorthand` ID namespace.
`code_display` is unique across the database (enforced by
`_check_no_duplicate_codes`), so it's a safe identity key.
"""
return make_note(
code_display,
meaning,
kind,
form,
entry.get("explanation"),
entry.get("example"),
tags_for_entry(kind, entry),
namespace="shorthand",
)
def render_example(example):
"""Render an example string: HTML-escape, then `code` → <code>.
The curated examples use Markdown-style backticks around the bits
that are literally keyed on the air (`QRL?`, `de DL1ABC k`). We
escape first via `text_html` (so the surrounding prose is safe and
newlines survive) and only then turn backtick spans into <code>.
"""
return BACKTICK_SPAN_RE.sub(
lambda m: f"<code>{m.group(1)}</code>", text_html(example)
)
def tags_for_entry(kind, entry):
"""Return the Anki tags field (space-padded) for one note.
A `q-gruppe`/`abkuerzung` kind tag, a `pruefung` tag when the code
appears in the exam catalog, and any extra editorial tags (e.g.
`prosign`, `notsignal`) carried on the entry.
"""
tags = ["q-gruppe" if kind == KIND_QGROUP else "abkuerzung"]
if entry.get("exam"):
tags.append("pruefung")
tags.extend(slugify(tag) for tag in entry.get("tags", []))
return " " + " ".join(tags) + " "
# ============================================================================
# Anki note type (two templates: forward + reverse)
# ============================================================================
def model_json(model_id, model_name, now):
"""Return the note-type entry for `col.models`.
Five fields and two templates. `Bedeutung` prompts for the meaning
given the code; `Kürzel` is the reverse. Modelling both directions
on one note type (rather than two single-template notes) is what
rule 4 asks for: one record, two cards. `req` records which field
each template needs so Anki never generates an empty card. The
technical deck reuses this same shape under its own `model_name`.
"""
fields = [
field_json("Code", 0),
field_json("Meaning", 1),
field_json("Kind", 2),
field_json("Form", 3),
field_json("Explanation", 4),
field_json("Example", 5),
]
extras = (
"{{#Explanation}}<div class=\"af-sh-explanation\">"
"{{Explanation}}</div>{{/Explanation}}\n"
"{{#Example}}<div class=\"af-sh-example\">{{Example}}</div>{{/Example}}"
)
front_meta = (
'<div class="af-sh-kind">{{Kind}}{{#Form}} · {{Form}}{{/Form}}</div>'
)
return {
"id": model_id,
"name": model_name,
"type": 0,
"mod": now,
"usn": -1,
"sortf": 0,
"did": None,
"tmpls": [
{
"name": "Bedeutung",
"ord": 0,
"qfmt": (
'<div class="af-sh">' + front_meta
+ '<div class="af-sh-code">{{Code}}</div>'
+ '<div class="af-sh-prompt">Bedeutung?</div></div>'
),
"afmt": (
"{{FrontSide}}\n<hr id=answer>\n"
'<div class="af-sh-meaning">{{Meaning}}</div>\n' + extras
),
"did": None,
"bqfmt": "",
"bafmt": "",
},
{
"name": "Kürzel",
"ord": 1,
"qfmt": (
'<div class="af-sh">' + front_meta
+ '<div class="af-sh-meaning">{{Meaning}}</div>'
+ '<div class="af-sh-prompt">Kürzel?</div></div>'
),
"afmt": (
"{{FrontSide}}\n<hr id=answer>\n"
'<div class="af-sh-code">{{Code}}</div>\n' + extras
),
"did": None,
"bqfmt": "",
"bafmt": "",
},
],
"flds": fields,
"css": CARD_CSS,
"latexPre": "",
"latexPost": "",
# Template 0 needs Code (field 0); template 1 needs Meaning (1).
"req": [[0, "all", [0]], [1, "all", [1]]],
"vers": [],
}
# ============================================================================
# SQLite collection writer (two cards per note)
# ============================================================================
def create_collection_db(db_path, deck_id, deck_name, model_id, model_name, notes, now):
"""Create the `collection.anki2` database for a glossary deck.
Same v11 schema as the sibling script, but each note fans out into
two cards (one per template `ord`). `due` increments across every
card so the deck has a stable introduction order. `deck_name` and
`model_name` are passed through so the technical deck can reuse this
with its own names.
"""
conn = sqlite3.connect(db_path)
try:
create_schema(conn)
_insert_collection_metadata(
conn, deck_id, deck_name, model_id, model_name, now,
)
due = 0
for note in notes:
conn.execute(
"""
INSERT INTO notes
(id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
note["note_id"],
note["guid"],
model_id,
now,
-1,
note["tags"],
note["fields"],
note["sort"],
checksum_sort_field(note["sort"]),
0,
"",
),
)
for ord_, card_id in enumerate(note["card_ids"]):
due += 1
conn.execute(
"""
INSERT INTO cards
(id, nid, did, ord, mod, usn, type, queue, due, ivl,
factor, reps, lapses, left, odue, odid, flags, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
card_id,
note["note_id"],
deck_id,
ord_,
now,
-1,
0,
0,
due,
0,
0,
0,
0,
0,
0,
0,
0,
"",
),
)
conn.commit()
finally:
conn.close()
def _insert_collection_metadata(conn, deck_id, deck_name, model_id, model_name, now):
"""Write the single `col` row carrying the JSON config blobs.
A trimmed copy of the sibling script's metadata writer, differing
only in the model (two templates) and the single deck.
"""
conn.execute(
"""
INSERT INTO col
(id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
1,
now,
now * 1000,
now * 1000,
11,
0,
0,
0,
json.dumps(collection_conf(deck_id), separators=(",", ":")),
json.dumps(
{str(model_id): model_json(model_id, model_name, now)},
separators=(",", ":"),
),
json.dumps(
{str(deck_id): deck_json(deck_id, deck_name, now)},
separators=(",", ":"),
),
json.dumps(default_deck_conf(now), separators=(",", ":")),
"{}",
),
)
# ============================================================================
# Card styling
# ============================================================================
CARD_CSS = """
.card {
font-family: Arial, sans-serif;
font-size: 18px;
line-height: 1.45;
color: #111;
background: #fff;
text-align: center;
}
.af-sh-kind {
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 11px;
color: #777;
margin-bottom: 0.6rem;
}
.af-sh-code {
font-size: 40px;
font-weight: bold;
font-family: "Courier New", monospace;
margin: 0.4rem 0;
color: #0b3a6b;
}
.af-sh-meaning {
font-size: 22px;
margin: 0.4rem 0;
}
.af-sh-prompt {
margin-top: 0.6rem;
font-size: 14px;
color: #999;
font-style: italic;
}
.af-sh-explanation {
margin-top: 1rem;
padding-top: 0.6rem;
border-top: 1px solid #ccc;
font-size: 15px;
color: #444;
}
.af-sh-example {
margin-top: 0.6rem;
font-size: 14px;
color: #555;
}
.af-sh-example code,
.af-sh-meaning code {
font-family: "Courier New", monospace;
background: #f0f0f0;
padding: 0 3px;
border-radius: 3px;
}
.nightMode .af-sh-code,
.card.nightMode .af-sh-code {
color: #8ab4f8;
}
.nightMode .af-sh-explanation,
.card.nightMode .af-sh-explanation {
border-top-color: #555;
color: #ccc;
}
.nightMode .af-sh-example,
.card.nightMode .af-sh-example {
color: #bbb;
}
.nightMode .af-sh-example code,
.nightMode .af-sh-meaning code,
.card.nightMode .af-sh-example code,
.card.nightMode .af-sh-meaning code {
background: #333;
color: #eee;
}
"""
# ============================================================================
# Build orchestration
# ============================================================================
def resolve_build_epoch(data_dir, override_epoch):
"""Pick the package timestamp epoch.
Prefers `--epoch`, then the catalog manifest's `fetched_at` (so a
`make all` build stamps the shorthand deck consistently with the
catalog decks), and finally falls back to `DEFAULT_BUILD_EPOCH`.
The deck content is catalog-independent, so an absent data dir just
means "no manifest epoch available" rather than an error.
"""
if override_epoch is not None:
return int(override_epoch)
try:
_edition_dir, manifest, _catalog = load_latest_catalog(data_dir)
except AnkiBuildError:
return DEFAULT_BUILD_EPOCH
return build_epoch_from_manifest(manifest)
def write_glossary_deck(notes, deck_name, model_name, out_dir, build_epoch):
"""Write `notes` as one two-template `.apkg` and return its path.
Shared by both glossary decks (operating shorthand here, technical
abbreviations in `amateurfunk_technical.py`). The deck carries no
media these cards are pure text. The output filename is derived
from the leaf of `deck_name` (`Amateurfunk::Foo` `amateurfunk-foo`).
"""
slug = slugify(deck_name.split("::")[-1])
out_path = out_dir / f"amateurfunk-{slug}.apkg"
deck_id = stable_id("deck", deck_name)
model_id = stable_id("model", model_name)
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
db_path = tmp_dir / "collection.anki2"
if tmp_dir.exists():
shutil.rmtree(tmp_dir)
tmp_dir.mkdir(parents=True)
try:
create_collection_db(
db_path, deck_id, deck_name, model_id, model_name, notes, build_epoch,
)
write_apkg(out_path, db_path, {}, build_epoch=build_epoch)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
return out_path
def build_deck(shorthand_path, out_dir, data_dir, override_epoch=None):
"""Build the shorthand `.apkg` and return a small result dict."""
q_codes, abbreviations = load_shorthand(shorthand_path)
notes = build_notes(q_codes, abbreviations)
if not notes:
raise AnkiBuildError("shorthand database produced no notes")
build_epoch = resolve_build_epoch(data_dir, override_epoch)
out_path = write_glossary_deck(
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
)
return {
"path": out_path,
"deck": DECK_NAME,
"notes": len(notes),
"cards": sum(len(note["card_ids"]) for note in notes),
"q_codes": len(q_codes),
"abbreviations": len(abbreviations),
}
# ============================================================================
# Main entry point
# ============================================================================
def _parse_args(argv):
"""Build the argparse object and parse `argv`."""
parser = argparse.ArgumentParser(
description=(
"Build an Anki deck of amateur-radio Q-groups and operating "
"abbreviations from shorthand.json."
),
)
parser.add_argument(
"--shorthand",
type=Path,
default=DEFAULT_SHORTHAND_PATH,
help="curated source database (default: ./shorthand.json)",
)
parser.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT_DIR,
help="output directory for the .apkg file (default: ./anki)",
)
parser.add_argument(
"--data",
type=Path,
default=DEFAULT_DATA_DIR,
help=(
"fetch output dir; only used to borrow a deterministic build "
"epoch from the catalog manifest (default: ./data)"
),
)
parser.add_argument(
"--epoch",
type=int,
default=None,
help="override the package timestamp epoch (default: from manifest)",
)
return parser.parse_args(argv)
def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv)
try:
result = build_deck(
args.shorthand,
args.out,
args.data,
override_epoch=args.epoch,
)
except AnkiBuildError as e:
print(f"error: {e}", file=sys.stderr)
return EXIT_ERROR
except Exception as e: # noqa: BLE001 (main() promises not to raise)
print(f"error: failed to build shorthand deck: {e}", file=sys.stderr)
return EXIT_ERROR
print(
f"wrote {result['path']} "
f"({result['notes']} notes, {result['cards']} cards: "
f"{result['q_codes']} Q-groups, {result['abbreviations']} abbreviations)"
)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Build an Anki deck of technical and HAM abbreviations.
A third glossary deck, sibling to `amateurfunk_shorthand.py`. Where that
script covers *operating* shorthand (Q-groups, CQ, 73), this one covers
the *technical* vocabulary: modulation and modes (SSB, FM, CW), signal
domains (NF, HF, ZF), building blocks (VFO, PLL, AGC), components,
measurements (dB, SWR, PEP), propagation, digital modes, and the
organisations/regulations a candidate meets the exam terms plus the
common HAM abbreviations beyond it (real knowledge, not just the test).
The card mechanics are identical to the operating deck one note per
abbreviation with two templates (code meaning and the reverse) so
this script is deliberately thin: it loads and validates
`technical.json`, turns each entry into a note, and hands the rest to
the shared glossary machinery in `amateurfunk_shorthand`. Each entry
also carries a German `category` (Betriebsart, Bauteil, ) shown on the
card and used as a filter tag.
Stdlib only, like its siblings.
"""
import argparse
import json
import sys
from pathlib import Path
from amateurfunk_anki import AnkiBuildError, slugify
# The glossary deck machinery is shared with the operating-shorthand
# script: the two-template note type, the two-cards-per-note collection
# writer, the deck packager, the build-epoch resolver, and the per-entry
# schema validator. Only the data shape, the deck/model names, and the
# tag scheme are specific to the technical deck.
from amateurfunk_shorthand import (
make_note,
resolve_build_epoch,
validate_entry,
write_glossary_deck,
)
# ============================================================================
# Constants
# ============================================================================
# Curated source database (editorial content tracked in git).
DEFAULT_TECHNICAL_PATH = Path("technical.json")
# Catalog output dir, consulted only to borrow a deterministic build
# epoch from the manifest (the deck content is catalog-independent).
DEFAULT_DATA_DIR = Path("data")
# Shared output dir for every .apkg.
DEFAULT_OUT_DIR = Path("anki")
# Deck and note-type names. Both distinct from the operating deck so the
# two never merge on import, and the IDs live in their own namespace.
DECK_NAME = "Amateurfunk::Technische Abkürzungen"
MODEL_NAME = "Amateurfunk Technische Abkürzung"
# ID/GUID namespace for this deck (kept separate from the operating
# deck's "shorthand" namespace).
ID_NAMESPACE = "technical"
# Schema for one entry in `technical.json`. `category` is the German
# kind label shown on the card; it is required so nothing ships
# uncategorised.
TERM_REQUIRED = ("code", "category", "meaning")
TERM_OPTIONAL = ("exam", "explanation", "example", "tags")
EXIT_OK = 0
EXIT_ERROR = 1
# ============================================================================
# Loading and validating the curated database
# ============================================================================
def load_technical(path):
"""Return the validated list of term entries from a JSON file.
Mandatory file with a strict shape (same contract as the operating
deck): a missing or malformed file is a hard error, and unknown
fields are rejected so a typo can't silently drop content.
"""
try:
data = json.loads(path.read_text("utf-8"))
except (OSError, json.JSONDecodeError) as e:
raise AnkiBuildError(f"could not read technical file {path}: {e}") from e
if not isinstance(data, dict):
raise AnkiBuildError(
f"technical file {path} must contain a JSON object at the top level"
)
terms = data.get("terms", [])
if not isinstance(terms, list):
raise AnkiBuildError(f"technical file {path}: 'terms' must be a list")
for entry in terms:
validate_entry(entry, TERM_REQUIRED, TERM_OPTIONAL, "terms")
_check_no_duplicate_codes(terms)
return terms
def _check_no_duplicate_codes(terms):
"""Raise if two terms share a code — that would overwrite a note.
Every stable ID and GUID is hashed from the code, so a duplicate
would silently clobber the first note on import.
"""
codes = [entry["code"] for entry in terms]
duplicates = sorted({code for code in codes if codes.count(code) > 1})
if duplicates:
raise AnkiBuildError(
"duplicate code(s) in technical database: " + ", ".join(duplicates)
)
# ============================================================================
# Note expansion
# ============================================================================
def build_notes(terms):
"""Turn each term into one two-card note (forward + reverse).
The German `category` becomes the note's `Kind` (shown on the card);
there is no usage form, so `Form` is empty. Identity is hashed from
the code under this deck's own ID namespace.
"""
notes = []
for entry in terms:
notes.append(
make_note(
code_display=entry["code"],
meaning=entry["meaning"],
kind=entry["category"],
form="",
explanation=entry.get("explanation"),
example=entry.get("example"),
tags=tags_for_term(entry),
namespace=ID_NAMESPACE,
)
)
return notes
def tags_for_term(entry):
"""Return the Anki tags field (space-padded) for one term.
A base `technik` tag, a `kategorie-<slug>` tag from the German
category, a `pruefung` tag when the term appears in the exam
catalog, and any extra editorial tags carried on the entry.
"""
tags = ["technik", f"kategorie-{slugify(entry['category'])}"]
if entry.get("exam"):
tags.append("pruefung")
tags.extend(slugify(tag) for tag in entry.get("tags", []))
return " " + " ".join(tags) + " "
# ============================================================================
# Build orchestration
# ============================================================================
def build_deck(technical_path, out_dir, data_dir, override_epoch=None):
"""Build the technical `.apkg` and return a small result dict."""
terms = load_technical(technical_path)
notes = build_notes(terms)
if not notes:
raise AnkiBuildError("technical database produced no notes")
build_epoch = resolve_build_epoch(data_dir, override_epoch)
out_path = write_glossary_deck(
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
)
return {
"path": out_path,
"deck": DECK_NAME,
"notes": len(notes),
"cards": sum(len(note["card_ids"]) for note in notes),
"terms": len(terms),
}
# ============================================================================
# Main entry point
# ============================================================================
def _parse_args(argv):
"""Build the argparse object and parse `argv`."""
parser = argparse.ArgumentParser(
description=(
"Build an Anki deck of amateur-radio technical and HAM "
"abbreviations from technical.json."
),
)
parser.add_argument(
"--technical",
type=Path,
default=DEFAULT_TECHNICAL_PATH,
help="curated source database (default: ./technical.json)",
)
parser.add_argument(
"--out",
type=Path,
default=DEFAULT_OUT_DIR,
help="output directory for the .apkg file (default: ./anki)",
)
parser.add_argument(
"--data",
type=Path,
default=DEFAULT_DATA_DIR,
help=(
"fetch output dir; only used to borrow a deterministic build "
"epoch from the catalog manifest (default: ./data)"
),
)
parser.add_argument(
"--epoch",
type=int,
default=None,
help="override the package timestamp epoch (default: from manifest)",
)
return parser.parse_args(argv)
def main(argv=None):
"""Top-level entry point. Returns an exit code; never raises."""
args = _parse_args(argv)
try:
result = build_deck(
args.technical,
args.out,
args.data,
override_epoch=args.epoch,
)
except AnkiBuildError as e:
print(f"error: {e}", file=sys.stderr)
return EXIT_ERROR
except Exception as e: # noqa: BLE001 (main() promises not to raise)
print(f"error: failed to build technical deck: {e}", file=sys.stderr)
return EXIT_ERROR
print(
f"wrote {result['path']} "
f"({result['notes']} notes, {result['cards']} cards: "
f"{result['terms']} technical abbreviations)"
)
return EXIT_OK
if __name__ == "__main__":
sys.exit(main())
+4313 -4143
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -0,0 +1,214 @@
# Hilfsmittel — official exam aid (Formelsammlung + tables)
Catalog of everything in the BNetzA exam aid sheet that candidates are
**allowed to use during the exam**, with the **printed page numbers** as
they appear on each page. This file is the source of truth for the
`<u>Hilfsmittel:</u>` notes appended to explanations (see
`EXPLANATIONS.md`): a note may only cite a formula/table that is listed
here, and should name it and its page.
- Source PDF: `Hilfsmittel_12062024.pdf`
(https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/AntraegeFormulare/Hilfsmittel_12062024.pdf?__blob=publicationFile&v=3)
- **Page numbering:** the printed page number = PDF page 2 (the cover
and the "Hinweis" page are unnumbered). All "S. N" below are the
printed numbers.
- Errata (per the PDF "Hinweis" page): on S. 15 the parabolic-dish gain
formula, on S. 17 the SWR formula, and on S. 21 the specific-resistance
table (Zink → Zinn) were corrected against the 3rd-edition catalog.
If the fact a question needs is **not** in this list (e.g. diode forward
voltages ~0.6 V / ~0.3 V, tan δ = 1/Q, semiconductor behaviour,
definitions), it is a memory item — **do not** add a Hilfsmittel note.
---
## S. 13 — Frequenzbereichszuweisung (Anlage 1, Amateurfunkverordnung)
- **S. 1 — Nutzungsbedingungen (¶14):** general usage rules (incl. the
**50 W ERP** cap for remote/automatic terrestrial stations above 30 MHz,
with a possible **1000 W ERP** exception for links) and, in ¶3, the
**definitions** of a *primärer* and *sekundärer Funkdienst* and their
protection/priority rights. This is a *text* page, not a table. (No
750 W figure appears here — 750 W PEP is a class-A limit in the S. 2
table.)
- **S. 2 — Tabellarische Übersicht:** per frequency band: status
(primary/secondary), the allowed frequency ranges, max. **power per
licence class (A/E/N) — in PEP or ERP exactly as printed** (not EIRP),
and additional-usage references.
Note the units: class N on **10 m is 10 W ERP**, but on **2 m/70 cm it
is 6,1 W ERP** (= ≈10 W EIRP after ×1,64, see S. 15) — the table does
*not* print "10 W EIRP". Power limits are in PEP/ERP, never EIRP.
- **S. 3 — Zusätzliche Nutzungsbestimmungen (numbered conditions):**
max. occupied **bandwidth** per band (800 Hz, 2,7 kHz, 7 kHz, 40 kHz,
2 MHz …), and special power limits such as the 12471263 MHz
**3,05 W ERP** sub-band cap (= ≈5 W EIRP). The bandwidth values live in
these numbered conditions, not in the S. 2 table.
Not in Anlage 1: the general HF/VHF/UHF (KW/UKW) range *terminology*,
the ISM-band *definition*, and foreign-country prefixes (Landeskenner) —
those are memory items.
## S. 47 — Rufzeichenplan für den Amateurfunkdienst in Deutschland
- **S. 4** — intro: structure of German call signs (prefix DADR excl.
DE/DI, digit, 23-char suffix).
- **S. 5 — Tabelle "Rufzeichen mit 2- oder 3-buchstabigen Suffixen":**
the main **call-sign-series → class/use** table. DA0 = Klubstation;
DL1DL9 = person-bound class A; DO1DO9 = class E; DN9 = class N;
DP is intended for exterritorial-location call signs; DP0DP1 =
class A with use categories KS, RL/FB and SZ; DP2 = class E with the
same use categories; DP8 = class N with the same use categories.
- **S. 6** — club-station suffixes (1-char) table.
- **S. 7** — club-station suffixes (47-digit); **§ 5** Kurzzeitzulassungen
ausländischer Funkamateure (visitors in Germany sign **DL/** class A,
**DO/** class E); **§ 6** Kennungen zum Betrieb leistungsschwacher Sender
(beacon/peil identifiers **MO, MOE, MOI, MOS, MOH, MO5**); §§ 78 rules.
## S. 8 — International gebräuchliche Rufzeichenzusätze / Ausbildung / Remote
- **§ 9** operating suffixes appended with `/`: `/m` (mobile, incl.
inland-waterway), `/mm` (maritime mobile), `/am` (aeronautical mobile),
`/p` (portable/temporary fixed; optional in Germany), etc.
- **§ 10** Ausbildungsfunkbetrieb (`/Trainee` speech, `/T` CW/digital).
- **§ 11** Remotebetrieb (`Remote` speech, `/R` CW/digital).
Foreign-country prefixes (e.g. Swiss HB3/HB9 for a German operating
*abroad*) are **not** in this plan — memory item.
## S. 910 — IARU-Bandplan
- **S. 9 — 2 m** and **S. 10 — 70 cm**: per segment, max bandwidth,
preferred mode and usage (CW, SSB, FM, digital, beacons, satellite…).
## S. 11 — Formelsammlung: Grundlagen / Widerstände
- Zehnerpotenzen & SI-Präfixe (p…T) — table.
- Zweierpotenzen / Bit (2^0…2^12) — table.
- **Ohmsches Gesetz:** `U = R·I`, `R = U/I`, `I = U/R`.
- **Innenwiderstand:** `R_i = ΔU/ΔI`.
- **Widerstand von Drähten:** `R = ρ·l/A_Dr`, `A_Dr = d²·π/4 = r²·π`.
- **Widerstands-Farbcode** — table (Farbe / Wert / Multiplikator / Toleranz).
## S. 12 — Widerstandsnetzwerke / Leistung / Wechselspannung
- **Reihenschaltung:** `R_G = R1+R2+…+R_N`; 2 R: `R_G = R1+R2`.
- **Parallelschaltung:** `1/R_G = 1/R1+1/R2+…`; 2 R: `R_G = R1·R2/(R1+R2)`.
- **Spannungsteiler (unbelastet):** `U_G = U1+U2`, `U1/U2 = R1/R2`,
`U2/U_G = R2/(R1+R2)`.
- **Stromteiler:** `I_G = I1+I2`, `I2/I1 = R1/R2`.
- **Vorzugsreihen** E6/E12/E24 — table.
- **Leistung:** `P = U·I = U²/R = I²·R`; `U = P/I = √(P·R)`;
`I = P/U = √(P/R)`.
- **Arbeit/Energie:** `W = P·t`.
- **Wirkungsgrad:** `η = P_ab/P_zu (·100 %)`; `P_ab = P_zu P_V`.
- **Wechselspannung:** `Û = U_eff·√2`, `U_SS = 2·Û`, `ω = 2·π·f`,
`Z = √(R²+X²)`, `T = 1/f`, `f = 1/T`.
## S. 13 — Induktivität / Transformator / Kapazität
- **Induktiver Blindwiderstand:** `X_L = ω·L`.
- **L Reihe:** `L_G = L1+…+L_N`; **L Parallel:** `1/L_G = 1/L1+…`.
- **Ringspule / lange Zylinderspule:** `L = μ0·μr·N²·A_S / l(_m)`.
- **Ringkernspulen (auch mehrlagig):** `L = N²·A_L`.
- **Magnetische Feldstärke (Ringspule):** `H = I·N/l_m`.
- **Magnetische Flussdichte:** `B_m = μr·μ0·H`.
- **Transformator-Übersetzungsverhältnis:**
`ü = N_P/N_S = U_P/U_S = I_S/I_P = √(Z_P/Z_S)`.
- **Belastbarkeit von Wicklungen:** `I = S·A_Dr` mit `S ≈ 2,5 A/mm²`.
- **Kapazitiver Blindwiderstand:** `X_C = 1/(ω·C)`.
- **C Reihe:** `1/C_G = 1/C1+…`; **C Parallel:** `C_G = C1+…`.
- **E-Feld im homogenen Feld:** `E = U/d`.
- **Kapazität Kondensator:** `C = ε0·εr·A/d`.
## S. 14 — Filter / Schwingkreis / Transistor / ZF & Spiegelfrequenz
- **RC TP/HP:** `f_g = 1/(2π·R·C)`; **RL TP/HP:** `f_g = R/(2π·L)`
(`f_g` = 3 dB-Grenzfrequenz).
- **Schwingkreis:** `f0 = 1/(2π·√(L·C))`, Resonanz `X_C = X_L`.
- **Reihenschwingkreis:** `B = R_s/(2π·L)`, `Q = f0/B = X_L/R_s`.
- **Parallelschwingkreis:** `B = 1/(2π·R_p·C)`, `Q = f0/B = R_p/X_L`.
- **Transistor (DC):** `B = I_C/I_B`, `I_E = I_C+I_B`.
- **Transistor (AC):** `v_I = β = ΔI_C/ΔI_B`, `v_U = β = ΔU_CE/ΔU_BE`,
`v_P = β² = v_U·v_I`. *(Transcribed verbatim. The printed
`v_U = β` is physically dubious — voltage gain is not generally the
current gain — but the aid prints it this way; do not silently
"correct" it.)*
- **Zwischenfrequenz:** `f_ZF = |f_E f_OSZ|` (the sheet notes that the
`f_ZF = f_E + f_OSZ` case is not considered).
- **Spiegelfrequenz:** `f_S = 2·f_OSZ f_E`.
## S. 15 — Pegel / Antennen
- **Pegel:** `p = 10·log10(P/1mW)` dBm; `p = 10·log10(P/1W)` dBW;
`u = 20·log10(U/0,775V)` dBu.
- **Verstärkung/Gewinn:** `g = 10·log10(P2/P1)` dB;
`g = 20·log10(U2/U1)` dB.
- **Dämpfung/Verluste:** `a = 10·log10(P1/P2)`; `a = 20·log10(U1/U2)`.
- **dB ↔ Verhältnis-Tabelle** — exactly **11 rows** (Leistungs- /
Spannungsverhältnis): 20 (0,01/0,1), 10 (0,1/0,32), 6 (0,25/0,5),
3 (0,5/0,71), 1 (0,79/0,89), 0 (1/1), +1 (1,26/1,12), +3 (2/1,41),
+6 (4/2), +10 (10/3,16), +20 (100/10). **There is no 16 dB row** (or
any value off this list): combine rows, e.g. 16 dB = +10 dB (×10) +
+6 dB (×4) → ×40.
- **ERP:** `p_ERP = p_S a + g_d`; `P_ERP = P_S·10^((g_da)/10dB)`.
- **EIRP:** `p_EIRP = p_ERP + 2,15 dB`; `P_EIRP = P_ERP·1,64`.
- **Feldstärke Fernfeld:** `E = √(30Ω·P_A·G_i)/d = √(30Ω·P_EIRP)/d`
(ab `d > λ/(2π)`).
- **Gewinn:** `G_i = G_d·1,64`, `g_i = g_d + 2,15 dB`, `G = 10^(g/10dB)`.
- Halbwellendipol `G_i=1,64 / g_i=2,15 dB`; λ/4-Vertikal mit
Bodenreflexion `G_i=3,28 / g_i=5,15 dB`; Parabolspiegel
`g_i = 10·log10[(π·d/λ)²·η] dB`.
## S. 16 — Rauschen / Amplitudenmodulation / Frequenzmodulation
- **Thermisches Rauschen:** `P_R = k·T_K·B`; `Δp_R = 10·log10(B1/B2)`;
`U_R = 2·√(P_R·R)`.
- **Rauschzahl:** `F = (P_S/P_N)_Eingang/(P_S/P_N)_Ausgang`;
`a_F = 10·log10(F)`; `a_F = SNR_Eingang SNR_Ausgang`.
- **SNR:** `SNR = 10·log10(P_S/P_N) = 20·log10(U_S/U_N)`.
- **Shannon-Hartley:** `C = B/1Hz · log2(1 + P_S/P_N)` bit/s.
- **log2:** `log2(x) = log10(x)/log10(2)`.
- **AM Modulationsgrad:** `m = Û_mod/Û_T`; **AM Bandbreite:**
`B = 2·f_mod,max`.
- **FM Modulationsindex:** `m = Δf_T/f_mod`; **Carson-Bandbreite:**
`B ≈ 2·(Δf_T + f_mod,max)`.
## S. 17 — Wellenlänge & Frequenz / Reflexion / Wellenwiderstand
- **c = f·λ**, `f = c/λ`, `λ = c/f`; `c0 ≈ 3·10⁸ m/s`;
`f[MHz] ≈ 300/λ[m]`, `λ[m] ≈ 300/f[MHz]`.
- **Verkürzungsfaktor:** `k_v = l_G/l_E = 1/√εr = c/c0`.
- **Stehwellenverhältnis (SWR):**
`s = U_max/U_min = (U_v+U_r)/(U_vU_r) = (√P_v+√P_r)/(√P_v−√P_r) = (1+|r|)/(1|r|)`;
`s = R2/Z` (R2>Z) bzw. `s = Z/R2` (R2<Z).
- **Reflexionsfaktor:** `|r| = (s1)/(s+1) = |(R2Z)/(R2+Z)| = |U_r/U_v| = √(P_r/P_v)`.
- **Rücklaufende Leistung:** `P_r = P_v·|r|²`.
- **An R2 abgegebene Leistung:** `P_ab = P_v·(1|r|²)`.
- **Wellenwiderstand:** HF-Leitung `Z = √(L'/C')`; Koax
`Z = 60Ω/√εr·ln(D/d)`; symm. Zweidraht (a/d>2,5)
`Z = 120Ω/√εr·ln(2a/d)`; Viertelwellentransformator `Z = √(Z_E·Z_A)`.
## S. 18 — Weitere Formeln
- **MUF:** `MUF ≈ f_c/sin(α)`; `f_opt = MUF·0,85`.
- **Empfindlichkeit von Messsystemen:** `E_MESS = R_i/U_i = 1/I_i`.
- **Relativer maximaler Fehler:** `F_W = ±(G/100)·(W_E/W_M)`.
- **Abtasttheorem:** `f_abtast > 2·f_max`; für Nicht-Basisband-Signale
`f_abtast > 2·(f_max f_min)` **wenn `f_abtast < f_min` oder
`f_abtast > f_max`**.
- **Datenübertragungs-/Symbolrate:** `C = R_S·n`.
## S. 1922 — Konstanten und Tabellen
- **S. 1920** — symbol/quantity glossary and physical constants
(k, c0, μ0, ε0, e, …).
- **S. 21** — material tables: **relative permittivity ε_r** and
**specific resistance ρ** (per the S. 21 erratum, Zink → Zinn).
- **S. 22 — Kabeldämpfungsdiagramm Koaxialkabel:** a chart of cable
attenuation in **dB per 100 m vs. frequency** for common coax types
(RG58 ≈ 4,95 mm, RG174 ≈ 2,8 mm, PE-foam 10,3/12,7 mm, …). Read the
per-100 m loss at the operating frequency, then **scale linearly with
cable length**. This chart *is* part of the aid — cable-loss questions
are Hilfsmittel lookups.
</content>
</invoke>
+8 -7
View File
@@ -15,17 +15,17 @@ words. Mode names and technical abbreviations such as `CW`, `SSB`, `FM`,
| Code | What it means when question | What it means when not in question | Explanation, when actually used | Usage example | Exam references | Question IDs |
|---|---|---|---|---|---|---|
| `QRM` | Are you being interfered with? | I am being interfered with; interference. | Used to report man-made interference or ask whether the other station has interference. | DL1ABC: `QRM?` / DO2XYZ: `QRM, pse repeat report.` | 2 | `BB201`, `BB204` |
| `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; I am decreasing power. | Used to request or announce reduced transmitter power; also describes low-power operation. | DL1ABC: `PSE QRP.` / DO2XYZ: `QRP 5 W now.` | 1 | `BB205` |
| `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; wait or stand by. | Used to ask for a later call or to tell the other station to wait. | DL1ABC: `QRX 5 min.` / DO2XYZ: `QSL, standing by.` | 1 | `BB202` |
| `QRZ` | Who is calling me? | You are being called by ...; in pile-ups also a request for further callers. | Used when a calling station was not copied, or by a wanted station to invite the next caller. | DL1ABC: `QRZ?` / DO2XYZ: `DO2XYZ calling.` | 6 | `BB203`, `BE101`, `BE104`, `BE107`, `BE112`, `BE115` |
| `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; also a confirmation card or electronic confirmation for a QSO. | Used to confirm that information was received, or later for contact confirmation. | 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 directly with ...? | I can communicate directly with ...; commonly a radio contact. | Used for the radio contact itself, or when asking whether direct contact is possible. | DL1ABC: `Tnx for QSO.` / DO2XYZ: `Tnx also, 73.` | 9 | `BB202`, `BE108`, `BE110`, `BE303`, `BE308`, `BG107`, `BG109`, `NF112`, `NG110` |
| `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` |
@@ -33,7 +33,7 @@ words. Mode names and technical abbreviations such as `CW`, `SSB`, `FM`,
| 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: `... antenna is BK` / DO2XYZ: `BK, pse repeat power.` | 1 | `BB108` |
| `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` |
@@ -43,6 +43,7 @@ words. Mode names and technical abbreviations such as `CW`, `SSB`, `FM`,
| `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
+559
View File
@@ -0,0 +1,559 @@
{
"q_codes": [
{
"code": "QRG",
"question": "Will you tell me my exact frequency?",
"statement": "Your exact frequency is ... kHz or MHz.",
"exam": false,
"explanation": "Formal frequency-reporting Q-code. German-speaking amateurs also use 'QRG' informally as a noun meaning operating frequency, for example 'auf dieser QRG'.",
"example": "DL1ABC: `QRG?` / DO2XYZ: `Your QRG is 7.042 MHz.`"
},
{
"code": "QRL",
"question": "Are you busy?",
"statement": "I am busy; please do not interfere.",
"exam": false,
"explanation": "In amateur operation, QRL? is the standard short CW check meaning 'Is this frequency in use?' Listen first, send it once or twice, and wait before calling CQ.",
"example": "DL1ABC: `QRL?` / DO2XYZ: `QRL, in QSO.`"
},
{
"code": "QRM",
"question": "Is my transmission being interfered with?",
"statement": "Your transmission is being interfered with.",
"exam": true,
"explanation": "Interference from other stations or man-made sources, distinct from atmospheric static (QRN). In everyday amateur speech, 'I have QRM' is also common even though the formal statement addresses the other station's transmission.",
"example": "DL1ABC: `QRM?` / DO2XYZ: `QRM, pse repeat report.`"
},
{
"code": "QRN",
"question": "Are you troubled by atmospheric noise?",
"statement": "I am troubled by atmospheric noise (static).",
"exam": true,
"explanation": "Used when static crashes or atmospheric noise make reception difficult.",
"example": "DL1ABC: `QRN here, ur report?` / DO2XYZ: `RST 55 despite QRN.`"
},
{
"code": "QRO",
"question": "Shall I increase transmitter power?",
"statement": "Increase transmitter power.",
"exam": true,
"explanation": "Used when more power may be needed, usually after trying normal operating improvements first.",
"example": "DL1ABC: `QRO?` / DO2XYZ: `PSE QRO, ur weak.`"
},
{
"code": "QRP",
"question": "Shall I decrease transmitter power?",
"statement": "Decrease transmitter power.",
"exam": true,
"explanation": "Formal request to reduce power. In everyday amateur use, QRP also describes low-power operation—typically no more than 5 W output for CW/data or 10 W PEP for SSB under the common award convention.",
"example": "DL1ABC: `PSE QRP.` / DO2XYZ: `QRP 5 W now.`"
},
{
"code": "QRQ",
"question": "Shall I send faster?",
"statement": "Send faster (... words per minute).",
"exam": false,
"explanation": "Requests a faster Morse sending speed."
},
{
"code": "QRS",
"question": "Shall I send more slowly?",
"statement": "Send more slowly (... words per minute).",
"exam": false,
"explanation": "Requests a slower Morse sending speed; common courtesy to slower operators."
},
{
"code": "QRT",
"question": "Shall I stop sending?",
"statement": "Stop sending; I am closing down.",
"exam": true,
"explanation": "Used to close down transmission or ask another station to stop transmitting.",
"example": "DL1ABC: `I must QRT.` / DO2XYZ: `QSL, 73.`"
},
{
"code": "QRV",
"question": "Are you ready?",
"statement": "I am ready.",
"exam": true,
"explanation": "Used to ask if a station is ready, or to say that a station is available for traffic.",
"example": "DL1ABC: `QRV?` / DO2XYZ: `QRV, go ahead.`"
},
{
"code": "QRX",
"question": "When will you call me again?",
"statement": "I will call you again at ... hours on ... kHz or MHz.",
"exam": true,
"explanation": "The formal code schedules a later call. In normal amateur operation, QRX followed by a duration—such as 'QRX 5'—usually means 'stand by for five minutes'.",
"example": "DL1ABC: `QRX 5 min.` / DO2XYZ: `QSL, standing by.`"
},
{
"code": "QRZ",
"question": "Who is calling me?",
"statement": "You are being called by ...",
"exam": true,
"explanation": "Use QRZ? when a calling station was not copied. DX and contest stations also say QRZ? to invite the next caller; that widespread pile-up use is still the question form, not a separate statement meaning.",
"example": "DL1ABC: `QRZ?` / DO2XYZ: `DO2XYZ calling.`"
},
{
"code": "QSB",
"question": "Is the strength of my signal fading?",
"statement": "Your signal strength is fading or fluctuating.",
"exam": true,
"explanation": "Used when signal strength rises and falls because of propagation.",
"example": "DL1ABC: `QSB?` / DO2XYZ: `Yes, strong QSB, now 55.`"
},
{
"code": "QSK",
"question": "Can you hear me between your signals, and may I break in?",
"statement": "I can hear you between my signals; break in on my transmission.",
"exam": false,
"explanation": "Describes full break-in CW, where transmit/receive switching is fast enough to hear the band between one's own dots and dashes. Modern transceiver menus often label this feature QSK."
},
{
"code": "QSL",
"question": "Can you acknowledge receipt?",
"statement": "I acknowledge receipt.",
"exam": true,
"explanation": "On air it confirms that information was copied. A paper QSL card or electronic QSL is the later confirmation of a completed contact; that noun usage is practical amateur shorthand, not the formal statement text.",
"example": "DL1ABC: `My QTH Berlin, QSL?` / DO2XYZ: `QSL, Berlin copied.`"
},
{
"code": "QSO",
"question": "Can you communicate with ... directly or by relay?",
"statement": "I can communicate with ... directly or by relay through ...",
"exam": true,
"explanation": "The formal code concerns establishing communication. In everyday amateur speech and logging, a QSO simply means a completed or ongoing radio contact.",
"example": "DL1ABC: `Tnx for QSO.` / DO2XYZ: `Tnx also, 73.`"
},
{
"code": "QSY",
"question": "Shall I change to another frequency?",
"statement": "Change frequency; I am changing frequency.",
"exam": true,
"explanation": "Used to move a contact away from a calling frequency or crowded channel.",
"example": "DL1ABC: `PSE QSY 145.525.` / DO2XYZ: `QSL, QSY now.`"
},
{
"code": "QTC",
"question": "How many messages have you to send?",
"statement": "I have ... messages to send.",
"exam": false,
"explanation": "Used in traffic handling and contests to announce a count of formal messages."
},
{
"code": "QTH",
"question": "What is your position or location?",
"statement": "My position or location is ...",
"exam": true,
"explanation": "Used to ask for or give the station location, often city, locator, or portable position.",
"example": "DL1ABC: `QTH?` / DO2XYZ: `QTH Munich, JN58.`"
}
],
"abbreviations": [
{
"code": "73",
"meaning": "Best regards (friendly sign-off).",
"exam": false,
"explanation": "The standard friendly close of a contact. Always singular: 'best 73', never '73s'.",
"example": "DL1ABC: `tnx QSO, 73!` / DO2XYZ: `73 es cuagn.`"
},
{
"code": "88",
"meaning": "Love and kisses (affectionate sign-off).",
"exam": false,
"explanation": "An affectionate close, traditionally used towards a partner or in friendly mixed company."
},
{
"code": "AGN",
"meaning": "Again / please repeat.",
"exam": false,
"explanation": "Asks the other station to repeat something that was missed."
},
{
"code": "ANT",
"meaning": "Antenna.",
"exam": false,
"explanation": "Common CW and logging abbreviation, for example when exchanging antenna type, height or direction."
},
{
"code": "AR",
"meaning": "End of message (prosign, sent as one run-together character).",
"exam": false,
"tags": ["prosign"],
"explanation": "Marks the end of a complete transmission before the callsign exchange and K."
},
{
"code": "AS",
"meaning": "Wait / stand by (prosign).",
"exam": false,
"tags": ["prosign"],
"explanation": "Asks the other station to wait briefly without ending the transmission."
},
{
"code": "BK",
"meaning": "Break — interrupt or quickly hand over a running transmission.",
"exam": true,
"explanation": "Used in telegraphy to interrupt or quickly pass transmission without a formal over.",
"example": "DL1ABC: `... running 10 W BK` / DO2XYZ: `BK, my PWR 5 W.`"
},
{
"code": "BT",
"meaning": "Separator between parts of a message (prosign, written =).",
"exam": false,
"tags": ["prosign"],
"explanation": "Run-together dah-di-di-di-dah, used like a paragraph break inside a message."
},
{
"code": "BTU",
"meaning": "Back to you.",
"exam": false,
"explanation": "Informal voice and digital-text handover. In formal CW procedure, K or KN is clearer."
},
{
"code": "CFM",
"meaning": "Confirm / I confirm.",
"exam": false,
"explanation": "Confirms information such as a report, locator, or QSL."
},
{
"code": "CL",
"meaning": "Closing down / clear (going off the air).",
"exam": false,
"explanation": "Sent in CW to announce that the station is closing and will not remain available for another contact."
},
{
"code": "CQ",
"meaning": "General call to any station.",
"exam": true,
"explanation": "Used to start a contact when no specific station is being called. A target narrows it, e.g. CQ DX or CQ DL.",
"example": "DL1ABC: `CQ CQ CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`"
},
{
"code": "CPY",
"meaning": "Copy — receive or understand a signal.",
"exam": false,
"explanation": "Common operating shorthand in questions such as 'HW CPY?' and replies such as 'CPY 100%'. It refers to successful reception, not making a duplicate document.",
"example": "DL1ABC: `HW CPY?` / DO2XYZ: `FB CPY, no QRM.`"
},
{
"code": "CUAGN",
"meaning": "See you again.",
"exam": false,
"explanation": "Compact friendly sign-off used in CW and digital text, often immediately before 73."
},
{
"code": "CUL",
"meaning": "See you later.",
"exam": false,
"explanation": "Friendly informal closing used mainly in CW and text modes."
},
{
"code": "DE",
"meaning": "From / this is (precedes the sending station's call sign).",
"exam": true,
"explanation": "Used between the called station and own call sign, especially in CW.",
"example": "DL1ABC: `CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`"
},
{
"code": "DX",
"meaning": "Long distance / distant station.",
"exam": true,
"explanation": "Used to seek or describe long-distance contacts. The required distance depends on band: intercontinental on HF, several hundred km on VHF/UHF.",
"example": "DL1ABC: `CQ DX de DL1ABC k` / PY2XYZ: `DL1ABC de PY2XYZ k`"
},
{
"code": "ES",
"meaning": "And.",
"exam": false,
"explanation": "Telegraphy shorthand for 'and', from the Morse ampersand.",
"example": "DL1ABC: `73 es gud DX`"
},
{
"code": "FB",
"meaning": "Fine business — excellent / understood well.",
"exam": false,
"explanation": "A general expression of approval: good, excellent, all copied."
},
{
"code": "FD",
"meaning": "Field Day (portable contest operation).",
"exam": true,
"explanation": "Used during Field Day contest operation to attract participating stations.",
"example": "DL1ABC/P: `CQ FD de DL1ABC/P TEST`"
},
{
"code": "GA",
"meaning": "Good afternoon / go ahead.",
"exam": false,
"explanation": "Context decides the meaning: a greeting near the start of a contact, or an invitation to continue. Use K or KN for an unambiguous CW handover."
},
{
"code": "GE",
"meaning": "Good evening.",
"exam": false,
"explanation": "Common greeting near the start of a CW or digital-text contact."
},
{
"code": "GM",
"meaning": "Good morning.",
"exam": false,
"explanation": "Common greeting near the start of a CW or digital-text contact."
},
{
"code": "GN",
"meaning": "Good night.",
"exam": false,
"explanation": "Friendly closing, normally used at the end rather than as the formal end-of-contact prosign."
},
{
"code": "GL",
"meaning": "Good luck.",
"exam": false,
"explanation": "Common friendly wish in contests, award chasing and ordinary CW or digital contacts."
},
{
"code": "GUD",
"meaning": "Good.",
"exam": false,
"explanation": "Common CW spelling reduction, as in 'gud sig' or 'gud copy'."
},
{
"code": "HI",
"meaning": "Laughter (the Morse 'smiley').",
"exam": false,
"explanation": "Written representation of laughter in telegraphy; its dot pattern sounds like a chuckle."
},
{
"code": "HR",
"meaning": "Here / hear.",
"exam": false,
"explanation": "A context-dependent CW contraction: 'WX HR' means weather here, while 'HR U' means hear you."
},
{
"code": "HW",
"meaning": "How?",
"exam": false,
"explanation": "Usually asks for reception or an opinion near the end of an exchange.",
"example": "DL1ABC: `HW CPY?` / DO2XYZ: `FB CPY.`"
},
{
"code": "K",
"meaning": "Invitation to transmit — go ahead / over.",
"exam": true,
"explanation": "Used at the end of a CW transmission to invite the other station to reply.",
"example": "DL1ABC: `DO2XYZ de DL1ABC k`"
},
{
"code": "KN",
"meaning": "Go ahead, named station only (no other stations break in).",
"exam": false,
"tags": ["prosign"],
"explanation": "Like K, but invites only the specific station addressed to reply."
},
{
"code": "MNI",
"meaning": "Many.",
"exam": false,
"explanation": "Frequent CW contraction, for example 'mni tnx' or 'mni QSOs'."
},
{
"code": "NIL",
"meaning": "Nothing / nothing heard.",
"exam": false,
"explanation": "Used when no signal, traffic or result was obtained, for example 'NIL HRD' in a log or net report."
},
{
"code": "NR",
"meaning": "Number.",
"exam": false,
"explanation": "Used before a serial number, message number or other numbered item. Restricting the card to this common operational meaning avoids the ambiguous dictionary expansion 'near'."
},
{
"code": "OM",
"meaning": "Old man — any male operator.",
"exam": false,
"explanation": "Friendly address for a male amateur, regardless of age."
},
{
"code": "OP",
"meaning": "Operator / the operator's name.",
"exam": false,
"explanation": "Common when exchanging names, for example 'OP Anna', and in station descriptions such as single-op or multi-op."
},
{
"code": "PSE",
"meaning": "Please.",
"exam": true,
"explanation": "Used as a polite request marker, e.g. PSE QRP or PSE QSY.",
"example": "DL1ABC: `PSE QSY 145.525` / DO2XYZ: `QSL, QSY.`"
},
{
"code": "PWR",
"meaning": "Power (transmitter output).",
"exam": false,
"explanation": "Usually followed by the RF output power and unit, for example 'PWR 5 W'. Do not confuse transmitter output with ERP or EIRP."
},
{
"code": "R",
"meaning": "Received — everything before it was copied correctly.",
"exam": true,
"explanation": "Sent at the start of a transmission to acknowledge full receipt of the previous one.",
"example": "DL1ABC: `R tnx report, ur RST 579` / DO2XYZ: `QSL, 73.`"
},
{
"code": "RIG",
"meaning": "Station equipment / transceiver.",
"exam": false,
"explanation": "Informal term for the radio equipment in use, often exchanged together with antenna and power."
},
{
"code": "RPT",
"meaning": "Repeat.",
"exam": false,
"explanation": "Requests retransmission of missed text. Signal report is better abbreviated RPRT, avoiding a meaning collision in the reverse card."
},
{
"code": "RPRT",
"meaning": "Report — usually a signal report.",
"exam": false,
"explanation": "Distinguishes the noun 'report' from RPT meaning 'repeat'. It is commonly used when asking for or sending an RST report."
},
{
"code": "RR73",
"meaning": "Report received; best regards — final FT8 acknowledgement.",
"exam": false,
"explanation": "In an FT8 exchange, RR73 confirms receipt of the other station's report and closes the contact in one message. It is protocol text, not a general voice or CW abbreviation."
},
{
"code": "RST",
"meaning": "Signal report: Readability, Strength, Tone.",
"exam": true,
"explanation": "Three-figure reception report. In SSB only R and S are normally given (Tone applies to CW).",
"example": "DL1ABC: `ur RST 599` / DO2XYZ: `tnx, ur 579.`"
},
{
"code": "RX",
"meaning": "Receiver.",
"exam": true,
"explanation": "Equipment and diagram abbreviation paired with TX. On repeater listings, RX denotes the frequency on which the station receives."
},
{
"code": "SK",
"meaning": "End of contact (prosign); also 'silent key', a deceased radio amateur.",
"exam": false,
"tags": ["prosign"],
"explanation": "Sent run-together to close out a complete contact; outside operating, a Silent Key is a ham who has died."
},
{
"code": "SRI",
"meaning": "Sorry.",
"exam": false,
"explanation": "Brief apology in CW or digital text, often followed by a correction or repeat."
},
{
"code": "SWL",
"meaning": "Short-Wave Listener — listener who receives but does not transmit.",
"exam": false,
"explanation": "SWLs monitor amateur and broadcast stations, may submit reception reports, and do not need an amateur transmitting authorization merely to receive."
},
{
"code": "TEST",
"meaning": "Contest call indicator.",
"exam": true,
"explanation": "Used in contest calls to show the call is for contest contacts.",
"example": "DL1ABC: `CQ TEST de DL1ABC` / DO2XYZ: `DL1ABC de DO2XYZ 59 012`"
},
{
"code": "TNX",
"meaning": "Thanks — three-letter telegraphic abbreviation.",
"exam": false,
"explanation": "Very common in CW and digital text; TKS is an equivalent spelling. The explicit three-letter wording distinguishes this reverse card from TU."
},
{
"code": "TU",
"meaning": "Thank you — two-letter acknowledgement or sign-off.",
"exam": false,
"explanation": "Frequently used by contest or DX stations to acknowledge one caller and immediately continue, for example 'TU QRZ?'."
},
{
"code": "TX",
"meaning": "Transmitter.",
"exam": true,
"explanation": "Equipment and diagram abbreviation paired with RX. On repeater listings, TX denotes the frequency on which the station transmits."
},
{
"code": "UR",
"meaning": "Your / you're.",
"exam": false,
"explanation": "Context-dependent CW and text contraction, as in 'UR RST 579' or 'UR FB'."
},
{
"code": "UTC",
"meaning": "Coordinated Universal Time — koordinierte Weltzeit.",
"exam": true,
"explanation": "Use UTC for logs, schedules and QSL confirmations so both stations record the same date and time without daylight-saving ambiguity. Germany is UTC+1 in MEZ and UTC+2 in MESZ."
},
{
"code": "VY",
"meaning": "Very.",
"exam": false,
"explanation": "Common CW contraction, for example 'vy gud' or 'vy strong'."
},
{
"code": "WX",
"meaning": "Weather.",
"exam": false,
"explanation": "Common conversational topic in CW and digital contacts, for example 'WX HR SUNNY'."
},
{
"code": "WPM",
"meaning": "Words Per Minute — Morse sending speed.",
"exam": false,
"explanation": "Standard practical speed unit for CW. Send no faster than you can reliably receive, and slow down to match a station that answers at a lower speed."
},
{
"code": "XYL",
"meaning": "Wife (ex-young-lady).",
"exam": false,
"explanation": "Traditional amateur shorthand that is still encountered but can sound dated; use a person's name when known."
},
{
"code": "YL",
"meaning": "Young lady — any female operator.",
"exam": false,
"explanation": "Traditional term for a female radio amateur regardless of age. It remains common in names of clubs, nets and awards."
},
{
"code": "MAYDAY",
"meaning": "International distress signal (spoken). Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used in maritime or aeronautical distress traffic, not in normal amateur radio traffic.",
"example": "Vessel: `MAYDAY MAYDAY MAYDAY, this is DABC.`"
},
{
"code": "SOS",
"meaning": "International distress signal (Morse). Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used as an international distress signal, especially in Morse contexts outside amateur traffic.",
"example": "Station: `SOS SOS SOS de DABC`"
},
{
"code": "PAN PAN",
"meaning": "International urgency signal. Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used outside amateur radio for urgent situations that are serious but not yet distress.",
"example": "Vessel: `PAN PAN, this is DABC, engine failure.`"
},
{
"code": "SÉCURITÉ",
"meaning": "International safety signal. Must not be used in amateur radio traffic.",
"exam": true,
"tags": ["notsignal"],
"explanation": "Used outside amateur radio for safety information such as navigation or weather warnings.",
"example": "Coast station: `SECURITE, weather warning.`"
}
]
}
+642
View File
@@ -0,0 +1,642 @@
{
"terms": [
{
"code": "AM",
"category": "Betriebsart",
"meaning": "Amplitudenmodulation — amplitude modulation.",
"exam": true,
"explanation": "The carrier amplitude follows the audio signal. Both sidebands and the carrier are transmitted, so AM needs more bandwidth and power than SSB."
},
{
"code": "FM",
"category": "Betriebsart",
"meaning": "Frequenzmodulation — frequency modulation.",
"exam": true,
"explanation": "The carrier frequency follows the audio signal at constant amplitude. Robust against amplitude noise; the usual mode for VHF/UHF (UKW) voice and repeaters."
},
{
"code": "PM",
"category": "Betriebsart",
"meaning": "Phasenmodulation — phase modulation.",
"exam": true,
"explanation": "The carrier phase follows the signal; closely related to FM and often used to generate it."
},
{
"code": "DSB",
"category": "Betriebsart",
"meaning": "Doppelseitenbandmodulation — double-sideband modulation.",
"exam": true,
"explanation": "Both sidebands are transmitted. The carrier may be present, as in conventional AM, or suppressed, as in DSB-SC; suppressing one of the two sidebands produces SSB."
},
{
"code": "SSB",
"category": "Betriebsart",
"meaning": "Einseitenbandmodulation — single sideband.",
"exam": true,
"explanation": "Only one sideband is transmitted and the carrier is normally suppressed. Compared with conventional AM, SSB uses about half the bandwidth and does not waste transmitter power on a full carrier, which is why it dominates analogue HF voice."
},
{
"code": "USB",
"category": "Betriebsart",
"meaning": "oberes Seitenband — upper sideband.",
"exam": true,
"explanation": "SSB using the sideband above the suppressed carrier. Convention on HF above 10 MHz and on VHF/UHF."
},
{
"code": "LSB",
"category": "Betriebsart",
"meaning": "unteres Seitenband — lower sideband.",
"exam": true,
"explanation": "SSB using the sideband below the suppressed carrier. Convention on the HF bands below 10 MHz (160/80/40 m)."
},
{
"code": "CW",
"category": "Betriebsart",
"meaning": "Continuous Wave — Morsetelegrafie (A1A).",
"exam": true,
"explanation": "On/off keying of an unmodulated carrier. Very narrow bandwidth and excellent weak-signal readability."
},
{
"code": "FSK",
"category": "Betriebsart",
"meaning": "Frequency Shift Keying — Frequenzumtastung.",
"exam": true,
"explanation": "Digital keying that shifts the carrier between discrete frequencies; the basis of RTTY."
},
{
"code": "PSK",
"category": "Betriebsart",
"meaning": "Phase Shift Keying — Phasenumtastung.",
"exam": true,
"explanation": "Digital keying that encodes data in carrier phase shifts."
},
{
"code": "ASK",
"category": "Betriebsart",
"meaning": "Amplitude Shift Keying — Amplitudenumtastung.",
"exam": true,
"explanation": "Digital symbols are represented by discrete carrier amplitudes. Simple on-off keying is the two-state special case; unlike FM or PSK, amplitude noise directly affects the keyed quantity."
},
{
"code": "QAM",
"category": "Betriebsart",
"meaning": "Quadraturamplitudenmodulation — quadrature amplitude modulation.",
"exam": true,
"explanation": "Combines amplitude and phase modulation on two quadrature carriers to carry several bits per symbol."
},
{
"code": "RTTY",
"category": "Betriebsart",
"meaning": "Radioteletype — Funkfernschreiben.",
"exam": true,
"explanation": "Classic teleprinter operation using frequency-shift keying and the five-bit ITA2 alphabet, commonly called Baudot. On amateur HF it is usually copied with software rather than a mechanical teleprinter."
},
{
"code": "PSK31",
"category": "Betriebsart",
"meaning": "Phase Shift Keying at 31.25 baud — narrowband keyboard digital mode.",
"exam": true,
"explanation": "The name is conventionally rounded: the symbol rate is 31.25 baud. This live keyboard-to-keyboard HF mode is very narrow, but overdriving an SSB transmitter produces a needlessly wide, distorted signal."
},
{
"code": "FT8",
"category": "Betriebsart",
"meaning": "digitale Schwachsignal-Betriebsart (WSJT-X) — weak-signal digital mode.",
"exam": true,
"explanation": "A structured weak-signal mode with synchronized 15-second periods and short, highly constrained exchanges. It can decode signals far below the audible noise floor, but it is intended for brief contacts rather than conversation."
},
{
"code": "WSPR",
"category": "Betriebsart",
"meaning": "Weak Signal Propagation Reporter — Bakenbetrieb zur Ausbreitungsforschung.",
"exam": true,
"explanation": "Very low-power beacon mode whose reception reports are pooled to map propagation."
},
{
"code": "SSTV",
"category": "Betriebsart",
"meaning": "Slow Scan Television — Schmalband-Bildübertragung.",
"exam": true,
"explanation": "Sends still pictures slowly enough to fit a voice-bandwidth channel."
},
{
"code": "ATV",
"category": "Betriebsart",
"meaning": "Amateurfernsehen — (fast-scan) amateur television.",
"exam": true,
"explanation": "Real-time video, typically on the higher bands because of its wide bandwidth."
},
{
"code": "NBFM",
"category": "Betriebsart",
"meaning": "Schmalband-FM — narrowband FM.",
"exam": true,
"explanation": "FM with limited frequency deviation and occupied bandwidth. It is the normal analogue voice mode on VHF/UHF repeaters and simplex channels; channel spacing and occupied bandwidth are related but are not the same quantity."
},
{
"code": "MF",
"category": "Band",
"meaning": "Medium Frequency — 300 kHz to 3 MHz (Mittelwelle).",
"exam": true,
"explanation": "The ITU band containing the amateur 630 m and 160 m allocations. In practice these low bands need large or electrically shortened antennas and are strongly affected by ground loss and nighttime propagation."
},
{
"code": "KW",
"category": "Band",
"meaning": "Kurzwelle — shortwave, 3 to 30 MHz.",
"exam": true,
"explanation": "The German name for the ITU HF band. This is the classic long-distance amateur range, where ionospheric propagation can support regional or worldwide contacts."
},
{
"code": "UKW",
"category": "Band",
"meaning": "Ultrakurzwelle — German name for 30 to 300 MHz.",
"exam": true,
"explanation": "Mostly line-of-sight propagation; home of FM voice and repeaters. In the BNetzA band names UKW is exactly VHF; 300 MHz to 3 GHz is UHF / Dezimeterwelle. Colloquially 'UKW' is sometimes stretched to mean VHF and up, but that is not the exam definition."
},
{
"code": "VHF",
"category": "Band",
"meaning": "Very High Frequency — ITU band from 30 to 300 MHz.",
"exam": true,
"explanation": "The international designation corresponding to German UKW. The 2 m amateur band is the main practical example, used for local SSB/CW, FM simplex, repeaters, satellites and weak-signal work."
},
{
"code": "UHF",
"category": "Band",
"meaning": "Ultra High Frequency — 300 MHz to 3 GHz.",
"exam": true,
"explanation": "Includes the amateur 70 cm, 23 cm and 13 cm bands. Propagation is mainly line of sight, although repeaters, satellites, tropospheric enhancement and reflection can extend the range."
},
{
"code": "SHF",
"category": "Band",
"meaning": "Super High Frequency — 3 to 30 GHz (Mikrowellen).",
"exam": true,
"explanation": "Microwave range containing several amateur bands. Feed-line loss, connector quality and accurate antenna alignment become increasingly important, so the transverter or low-noise amplifier is often mounted near the antenna."
},
{
"code": "EHF",
"category": "Band",
"meaning": "Extremely High Frequency — 30 to 300 GHz.",
"exam": false,
"explanation": "Millimetre-wave range containing experimental amateur allocations. Operation is specialized because atmospheric absorption, mechanical tolerances and feed-line losses are severe."
},
{
"code": "NF",
"category": "Signal & Frequenz",
"meaning": "Niederfrequenz — audio frequency (about 16 Hz to 20 kHz).",
"exam": true,
"explanation": "The baseband audio signal before modulation or after demodulation."
},
{
"code": "HF",
"category": "Signal & Frequenz",
"meaning": "Hochfrequenz — German technical term for a radio-frequency signal.",
"exam": true,
"explanation": "In German circuit descriptions, HF contrasts with NF and can mean radio-frequency energy generally. In the international ITU band names, however, HF specifically means 3 to 30 MHz; the German exam normally calls that range KW. Context decides which use is intended."
},
{
"code": "ZF",
"category": "Signal & Frequenz",
"meaning": "Zwischenfrequenz — German abbreviation for intermediate frequency.",
"exam": true,
"explanation": "The fixed frequency a superheterodyne receiver mixes the signal to, where most selective amplification and filtering happens."
},
{
"code": "IF",
"category": "Signal & Frequenz",
"meaning": "Intermediate Frequency — English abbreviation for an intermediate frequency.",
"exam": false,
"explanation": "English-language manuals and schematics use this where German material uses ZF. A superheterodyne receiver converts the wanted signal to this fixed frequency for most of its filtering and gain."
},
{
"code": "RF",
"category": "Signal & Frequenz",
"meaning": "Radio Frequency — English term for a radio-frequency signal.",
"exam": false,
"explanation": "The usual English label for signals and circuitry at radio frequencies. German schematics commonly label the same signal domain HF."
},
{
"code": "VFO",
"category": "Baugruppe",
"meaning": "variabler Oszillator — variable frequency oscillator.",
"exam": true,
"explanation": "The tunable oscillator that sets the operating frequency of a transmitter or receiver."
},
{
"code": "VCO",
"category": "Baugruppe",
"meaning": "spannungsgesteuerter Oszillator — voltage-controlled oscillator.",
"exam": true,
"explanation": "An oscillator whose frequency follows a control voltage; the tunable element inside a PLL."
},
{
"code": "PLL",
"category": "Baugruppe",
"meaning": "Phasenregelschleife — phase-locked loop.",
"exam": true,
"explanation": "Locks a VCO to a stable reference by comparing phase, forming the basis of modern frequency synthesizers."
},
{
"code": "DDS",
"category": "Baugruppe",
"meaning": "direkte digitale Synthese — direct digital synthesis.",
"exam": true,
"explanation": "Generates a signal numerically from a clock and a phase accumulator, giving fine, fast frequency steps."
},
{
"code": "BFO",
"category": "Baugruppe",
"meaning": "Schwebungsoszillator — beat frequency oscillator.",
"exam": true,
"explanation": "Reinjects a carrier at the product detector so CW and SSB become audible."
},
{
"code": "LO",
"category": "Baugruppe",
"meaning": "Lokaloszillator — local (mixing) oscillator.",
"exam": false,
"explanation": "Provides the frequency mixed with the received or transmitted signal. The sum or difference product is selected, for example to convert an amateur band to a fixed intermediate frequency."
},
{
"code": "AGC",
"category": "Baugruppe",
"meaning": "automatische Verstärkungsregelung — automatic gain control.",
"exam": true,
"explanation": "Holds the receiver output roughly constant across strong and weak signals."
},
{
"code": "ALC",
"category": "Baugruppe",
"meaning": "automatische Pegelregelung — automatic level control.",
"exam": true,
"explanation": "Limits transmitter drive to prevent overdrive and splatter."
},
{
"code": "PA",
"category": "Baugruppe",
"meaning": "Endstufe / Leistungsverstärker — power amplifier.",
"exam": true,
"explanation": "The final transmitter stage that raises the RF signal to the required output power. It must be driven within its linear range for SSB and digital modes to avoid splatter and intermodulation."
},
{
"code": "LNA",
"category": "Baugruppe",
"meaning": "rauscharmer Vorverstärker — low-noise amplifier.",
"exam": true,
"explanation": "A low-noise preamplifier, often at the antenna, to improve weak-signal reception on VHF and up."
},
{
"code": "ATU",
"category": "Baugruppe",
"meaning": "Antennenanpassgerät — antenna tuning unit.",
"exam": false,
"explanation": "Matches the feed line to the transmitter so the PA sees a low SWR; it does not 'tune' the antenna itself."
},
{
"code": "TRX",
"category": "Baugruppe",
"meaning": "Transceiver — combined transmitter and receiver.",
"exam": true,
"explanation": "Common shorthand in German amateur-radio diagrams and operating notes for a radio that shares controls and circuitry between transmit and receive."
},
{
"code": "VOX",
"category": "Baugruppe & Bedienung",
"meaning": "Voice-Operated Transmit — sprachgesteuerte Sendeumschaltung.",
"exam": true,
"explanation": "Switches a transceiver to transmit when microphone audio exceeds a threshold. Set gain, delay and anti-VOX carefully so loudspeaker audio or room noise does not key the transmitter accidentally."
},
{
"code": "Balun",
"category": "Baugruppe",
"meaning": "Symmetrierglied — balanced-to-unbalanced transformer.",
"exam": true,
"explanation": "Interfaces a balanced load, such as a dipole, with an unbalanced feed line such as coax. Common-mode suppression depends on the balun type: a suitable current balun or choke is needed when feed-line current must be blocked."
},
{
"code": "LED",
"category": "Bauteil",
"meaning": "Leuchtdiode — light-emitting diode.",
"exam": true,
"explanation": "Emits light when forward biased and requires current limiting. In station equipment it is commonly used for status indication and display backlighting."
},
{
"code": "FET",
"category": "Bauteil",
"meaning": "Feldeffekttransistor — field-effect transistor.",
"exam": true,
"explanation": "A voltage-controlled transistor with very high input impedance."
},
{
"code": "MOSFET",
"category": "Bauteil",
"meaning": "Metall-Oxid-Halbleiter-FET — metal-oxide-semiconductor FET.",
"exam": true,
"explanation": "An insulated-gate FET; common in switching and RF power stages."
},
{
"code": "BJT",
"category": "Bauteil",
"meaning": "Bipolartransistor — bipolar junction transistor.",
"exam": false,
"explanation": "A current-controlled transistor using both electron and hole conduction. NPN and PNP devices are used in switching and amplifier stages."
},
{
"code": "IC",
"category": "Bauteil",
"meaning": "integrierter Schaltkreis — integrated circuit.",
"exam": false,
"explanation": "Many components and circuit functions fabricated on one semiconductor chip, from simple voltage regulators to complete mixers, synthesizers and processors."
},
{
"code": "NTC",
"category": "Bauteil",
"meaning": "Heißleiter — thermistor with a negative temperature coefficient.",
"exam": true,
"explanation": "Its resistance falls as temperature rises; used for temperature sensing and inrush limiting."
},
{
"code": "PTC",
"category": "Bauteil",
"meaning": "Kaltleiter — thermistor with a positive temperature coefficient.",
"exam": true,
"explanation": "Its resistance rises as temperature rises; used as a resettable fuse and for protection."
},
{
"code": "LDR",
"category": "Bauteil",
"meaning": "Fotowiderstand — light-dependent resistor.",
"exam": true,
"explanation": "Its resistance decreases as illumination increases. It is useful for light sensing but is slower than a photodiode or phototransistor."
},
{
"code": "VDR",
"category": "Bauteil",
"meaning": "spannungsabhängiger Widerstand (Varistor) — voltage-dependent resistor.",
"exam": true,
"explanation": "Resistance drops sharply above a threshold voltage; clamps surges and protects against overvoltage."
},
{
"code": "dB",
"category": "Messgröße & Leistung",
"meaning": "Dezibel — logarithmic ratio.",
"exam": true,
"explanation": "For power ratios use 10·log₁₀(P₂/P₁); for voltage or current ratios under the same impedance use 20·log₁₀. +3 dB is about twice the power and +10 dB is ten times. Plain dB is relative, unlike dBm or dBW."
},
{
"code": "dBi",
"category": "Messgröße & Leistung",
"meaning": "Gewinn über Isotropstrahler — gain relative to an isotropic radiator.",
"exam": true,
"explanation": "An antenna gain reference, not transmitter power. Because a half-wave dipole has about 2.15 dBi gain, a value in dBi is 2.15 dB higher than the same gain stated in dBd."
},
{
"code": "dBd",
"category": "Messgröße & Leistung",
"meaning": "Gewinn über Dipol — gain relative to a half-wave dipole.",
"exam": true,
"explanation": "0 dBd = 2.15 dBi; the dipole already has 2.15 dBi of gain over isotropic."
},
{
"code": "dBm",
"category": "Messgröße & Leistung",
"meaning": "Pegel bezogen auf 1 mW — power level referenced to 1 milliwatt.",
"exam": true,
"explanation": "An absolute level: 0 dBm = 1 mW, 30 dBm = 1 W."
},
{
"code": "dBW",
"category": "Messgröße & Leistung",
"meaning": "Pegel bezogen auf 1 W — power level referenced to 1 watt.",
"exam": true,
"explanation": "An absolute logarithmic power level: 0 dBW = 1 W and 10 dBW = 10 W. To convert dBW to dBm, add 30 dB."
},
{
"code": "SWR",
"category": "Messgröße & Leistung",
"meaning": "Stehwellenverhältnis — standing wave ratio.",
"exam": true,
"explanation": "Normally the ratio of maximum to minimum RF voltage along a feed line. 1:1 is a perfect match; a higher value indicates a larger reflection, but does not by itself identify whether the antenna, feed line or another component causes the mismatch."
},
{
"code": "ERP",
"category": "Messgröße & Leistung",
"meaning": "effektive Strahlungsleistung — effective radiated power.",
"exam": true,
"explanation": "Radiated power referenced to a half-wave dipole. In linear units, multiply power delivered to the antenna by antenna gain relative to a dipole; in decibel units, subtract feed losses and add gain in dBd."
},
{
"code": "EIRP",
"category": "Messgröße & Leistung",
"meaning": "äquivalente isotrope Strahlungsleistung — equivalent isotropically radiated power.",
"exam": true,
"explanation": "Radiated power referenced to an ideal isotropic radiator. For the same station, EIRP is 1.64 times ERP; when both powers are expressed logarithmically, EIRP is 2.15 dB higher."
},
{
"code": "PEP",
"category": "Messgröße & Leistung",
"meaning": "Spitzenleistung — peak envelope power.",
"exam": true,
"explanation": "The average power over one RF cycle at the peak of the modulation envelope; the standard rating for SSB transmitters."
},
{
"code": "RMS",
"category": "Messgröße & Leistung",
"meaning": "Effektivwert — root mean square.",
"exam": true,
"explanation": "The equivalent DC value that produces the same heating; for a sine wave it is the peak divided by √2."
},
{
"code": "Q",
"category": "Messgröße & Leistung",
"meaning": "Güte — quality factor of a resonant circuit.",
"exam": true,
"explanation": "At resonance, Q = 2π times the energy stored divided by the energy lost per cycle; equivalently it is approximately resonant frequency divided by bandwidth. Higher Q gives a narrower response but also a smaller usable bandwidth."
},
{
"code": "SNR",
"category": "Messgröße & Leistung",
"meaning": "Signal-Rausch-Verhältnis — signal-to-noise ratio.",
"exam": true,
"explanation": "Compares wanted signal power with noise power, normally in dB and for a stated bandwidth. Narrowing the receiver bandwidth reduces admitted noise and can improve readability even though the received carrier power is unchanged."
},
{
"code": "AC",
"category": "Strom & Spannung",
"meaning": "Alternating Current — Wechselstrom; also used for alternating voltage.",
"exam": true,
"explanation": "Current whose direction changes periodically. Station mains supplies are AC; power supplies rectify and smooth it to provide the DC required by most transceivers."
},
{
"code": "DC",
"category": "Strom & Spannung",
"meaning": "Direct Current — Gleichstrom; also used for direct voltage.",
"exam": true,
"explanation": "Current with constant polarity. Most mobile amateur equipment is designed for a nominal 13.8 V DC supply, where correct polarity and adequate cable size are essential."
},
{
"code": "MUF",
"category": "Ausbreitung",
"meaning": "höchste brauchbare Frequenz — maximum usable frequency.",
"exam": true,
"explanation": "The highest frequency that is refracted back to Earth for a specified path and time. Above it, that ionospheric path normally fails; the value changes with distance, solar conditions, season and time of day."
},
{
"code": "LUF",
"category": "Ausbreitung",
"meaning": "niedrigste brauchbare Frequenz — lowest usable frequency.",
"exam": true,
"explanation": "The lowest frequency that still provides the required signal quality on a specified path. Below it, ionospheric absorption and atmospheric noise usually dominate; it is an operational limit, not a fixed band boundary."
},
{
"code": "NVIS",
"category": "Ausbreitung",
"meaning": "Steilstrahlung — near-vertical incidence skywave.",
"exam": true,
"explanation": "Uses high-angle HF radiation refracted back to Earth for regional communication, typically on 160, 80, 60 or 40 m depending on conditions. It can cover the skip zone of lower-angle paths, but coverage is never guaranteed."
},
{
"code": "DMR",
"category": "Digital & Daten",
"meaning": "Digital Mobile Radio — digitale Sprach- und Datenbetriebsart.",
"exam": true,
"explanation": "An ETSI digital mobile-radio standard using two-slot TDMA on a 12.5 kHz carrier. Amateur Tier II operation is common on repeaters and hotspots; talkgroups and timeslots must be selected correctly."
},
{
"code": "D-STAR",
"category": "Digital & Daten",
"meaning": "Digital Smart Technologies for Amateur Radio — digital voice and data system.",
"exam": true,
"explanation": "An open amateur-radio specification developed through JARL. Callsign routing and reflector linking are common in practice; Icom is a major equipment vendor, not part of the abbreviation."
},
{
"code": "C4FM",
"category": "Digital & Daten",
"meaning": "Continuous 4-Level Frequency Modulation — four-level digital modulation.",
"exam": true,
"explanation": "Maps dibits to four frequency deviations. Yaesu System Fusion uses C4FM, but the modulation name and the complete network/system are not synonymous."
},
{
"code": "APRS",
"category": "Digital & Daten",
"meaning": "Automatic Packet Reporting System — Positions- und Telemetriedaten über Packet.",
"exam": false,
"explanation": "Shares position, status, telemetry, messages and objects, commonly as 1200-baud packet on 144.800 MHz in Europe. Use an appropriate path and beacon rate to avoid unnecessary channel congestion."
},
{
"code": "TNC",
"category": "Digital & Daten",
"meaning": "Terminal Node Controller — Packet-Radio-Modem.",
"exam": false,
"explanation": "Interfaces a computer or terminal with a radio and handles packet framing and modulation. Modern APRS stations often implement the same functions in software."
},
{
"code": "AX.25",
"category": "Digital & Daten",
"meaning": "Amateurfunk-Datenprotokoll — amateur packet-radio link protocol.",
"exam": false,
"explanation": "Amateur adaptation of X.25 used for packet radio and APRS frames. It carries amateur callsigns in the link-layer addresses and can support connected or unconnected operation."
},
{
"code": "CTCSS",
"category": "Signalisierung & Bedienung",
"meaning": "Continuous Tone-Coded Squelch System — subaudible tone squelch.",
"exam": false,
"explanation": "A continuous low audio-frequency tone, typically 67 to 254 Hz, that allows a repeater or receiver to open only for the selected tone. 'Subaudible' means it is normally filtered from the loudspeaker audio, not that it lies below the human hearing range."
},
{
"code": "DTMF",
"category": "Signalisierung & Bedienung",
"meaning": "Dual-Tone Multi-Frequency — Mehrfrequenzwahlverfahren.",
"exam": false,
"explanation": "Each key sends one tone from a low group and one from a high group. Amateur stations use it mainly for repeater control, link commands and legacy telephone interconnects."
},
{
"code": "DCS",
"category": "Signalisierung & Bedienung",
"meaning": "Digital-Coded Squelch — digitale Rauschsperrencodierung.",
"exam": false,
"explanation": "Sends a continuous low-rate digital code with analogue FM so a receiver opens only for the selected code. It serves a similar access-filtering purpose to CTCSS but is not a digital voice mode."
},
{
"code": "PTT",
"category": "Baugruppe & Bedienung",
"meaning": "Push To Talk — Sendetaste.",
"exam": true,
"explanation": "Keys the transmitter while pressed. Confirm that the frequency is clear before using it, and allow a brief pause after keying a repeater so the first syllable is not clipped."
},
{
"code": "SDR",
"category": "Digital & Daten",
"meaning": "Software Defined Radio — softwaredefiniertes Funkgerät.",
"exam": true,
"explanation": "Implements functions such as mixing, filtering and demodulation in software or programmable logic. An SDR still needs suitable analogue RF filtering, gain control and conversion hardware."
},
{
"code": "RIT",
"category": "Baugruppe & Bedienung",
"meaning": "Receiver Incremental Tuning — Empfänger-Feinverstimmung.",
"exam": true,
"explanation": "Shifts only the receive frequency while leaving transmit frequency unchanged. It is useful when the other station is slightly off frequency; reset it afterward to avoid later tuning confusion."
},
{
"code": "ITU",
"category": "Organisation & Vorschrift",
"meaning": "Internationale Fernmeldeunion — International Telecommunication Union.",
"exam": true,
"explanation": "UN agency that allocates spectrum worldwide and defines the three ITU regions."
},
{
"code": "IARU",
"category": "Organisation & Vorschrift",
"meaning": "Internationale Amateur-Radio-Union — International Amateur Radio Union.",
"exam": true,
"explanation": "Worldwide federation of national amateur-radio societies; coordinates band plans and represents amateurs at the ITU."
},
{
"code": "CEPT",
"category": "Organisation & Vorschrift",
"meaning": "Europäische Konferenz der Verwaltungen für Post und Telekommunikation.",
"exam": true,
"explanation": "Its recommendations let an amateur licensee operate temporarily in other member countries without applying for a separate licence."
},
{
"code": "BNetzA",
"category": "Organisation & Vorschrift",
"meaning": "Bundesnetzagentur — the German telecommunications regulator.",
"exam": true,
"explanation": "Issues amateur licences and call signs in Germany and supervises the service."
},
{
"code": "EMV",
"category": "Organisation & Vorschrift",
"meaning": "elektromagnetische Verträglichkeit — electromagnetic compatibility (EMC).",
"exam": true,
"explanation": "The ability of equipment to operate without causing or suffering undue interference."
},
{
"code": "EMVU",
"category": "Organisation & Vorschrift",
"meaning": "elektromagnetische Umweltverträglichkeit — EMF exposure compliance.",
"exam": true,
"explanation": "Assessment and limits for human exposure to the electromagnetic fields a station produces."
},
{
"code": "AFu",
"category": "Organisation & Vorschrift",
"meaning": "Amateurfunk — amateur radio.",
"exam": false,
"explanation": "Common German written abbreviation in terms such as AFuG and AFuV. It is mainly documentation shorthand rather than something spoken during a contact."
}
]
}
+312
View File
@@ -193,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"
@@ -378,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"
@@ -459,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({
@@ -535,6 +663,162 @@ class TestAnkiBuild(unittest.TestCase):
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.
@@ -575,6 +859,34 @@ class TestRealExplanationsFile(unittest.TestCase):
)
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()
+277
View File
@@ -0,0 +1,277 @@
"""Tests for amateurfunk_shorthand.
Like the multiple-choice tests, these inspect the generated `.apkg`
directly (ZIP entries plus the SQLite collection) and never require
Anki itself. They also validate the repo's real `shorthand.json`
against the schema so editorial mistakes surface in CI.
"""
import json
import sqlite3
import tempfile
import unittest
import zipfile
from pathlib import Path
import amateurfunk_shorthand as sh
def make_shorthand():
return {
"q_codes": [
{
"code": "QSO",
"question": "Can you communicate directly with ...?",
"statement": "I can communicate directly with ...",
"exam": True,
"explanation": "The radio contact itself.",
"example": "DL1ABC: `Tnx for QSO.`",
},
{
"code": "QRG",
"question": "Will you tell me my exact frequency?",
"statement": "Your exact frequency is ... kHz.",
},
],
"abbreviations": [
{
"code": "73",
"meaning": "Best regards.",
"exam": True,
},
{
"code": "SK",
"meaning": "End of contact; also silent key.",
"tags": ["prosign"],
},
],
}
def write_shorthand(root: Path, data=None):
path = root / "shorthand.json"
path.write_text(
json.dumps(data if data is not None else make_shorthand()),
encoding="utf-8",
)
return path
def extract_collection(apkg_path: Path, tmp: Path):
with zipfile.ZipFile(apkg_path) as zf:
zf.extract("collection.anki2", tmp)
media = json.loads(zf.read("media").decode("utf-8"))
names = set(zf.namelist())
return tmp / "collection.anki2", media, names
class TestShorthandBuild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.shorthand_path = write_shorthand(self.root)
self.out_dir = self.root / "anki"
def _build(self, **kwargs):
return sh.build_deck(
self.shorthand_path,
self.out_dir,
self.root / "data", # absent → epoch fallback
**kwargs,
)
def _collection(self):
apkg = next(self.out_dir.glob("*.apkg"))
return extract_collection(apkg, self.root)
def test_qgroup_yields_two_notes_abbrev_yields_one(self):
result = self._build()
# 2 Q-groups → 4 notes, 2 abbreviations → 2 notes.
self.assertEqual(result["notes"], 6)
# Two cards per note (forward + reverse).
self.assertEqual(result["cards"], 12)
self.assertEqual(result["q_codes"], 2)
self.assertEqual(result["abbreviations"], 2)
def test_qgroup_statement_and_question_become_distinct_notes(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
# sfld has INTEGER affinity, so numeric codes (e.g. 73) come
# back as ints; stringify before comparing.
sflds = sorted(str(r[0]) for r in conn.execute("select sfld from notes"))
finally:
conn.close()
self.assertIn("QSO", sflds)
self.assertIn("QSO?", sflds)
def test_each_note_has_two_cards_one_per_template(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"select sfld, ord from notes join cards on cards.nid = notes.id"
" order by sfld, ord"
).fetchall()
ords_for_qso = sorted(o for s, o in rows if s == "QSO")
finally:
conn.close()
self.assertEqual(ords_for_qso, [0, 1])
def test_model_has_two_templates_and_per_template_requirements(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
models = json.loads(
conn.execute("select models from col").fetchone()[0]
)
finally:
conn.close()
model = next(iter(models.values()))
self.assertEqual([t["name"] for t in model["tmpls"]], ["Bedeutung", "Kürzel"])
# Forward template needs Code (field 0); reverse needs Meaning (1).
self.assertEqual(model["req"], [[0, "all", [0]], [1, "all", [1]]])
def test_tags_mark_kind_exam_and_extra_editorial_tags(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
tags = dict(
conn.execute("select sfld, tags from notes").fetchall()
)
finally:
conn.close()
self.assertIn(" q-gruppe ", tags["QSO"])
self.assertIn(" pruefung ", tags["QSO"])
self.assertIn(" abkuerzung ", tags["SK"])
self.assertIn(" prosign ", tags["SK"])
# QRG is not an exam code → no pruefung tag.
self.assertNotIn("pruefung", tags["QRG"])
def test_no_media_entries_in_package(self):
self._build()
_db, media, names = self._collection()
self.assertEqual(media, {})
self.assertEqual(names, {"collection.anki2", "media"})
def test_example_backticks_become_code_and_text_is_escaped(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
flds = next(
r[0] for r in conn.execute("select flds from notes")
if r[0].startswith("QSO" + sh.FIELD_SEP)
)
finally:
conn.close()
self.assertIn("<code>Tnx for QSO.</code>", flds)
def test_build_is_byte_deterministic_for_same_input(self):
out_a = self.root / "a"
out_b = self.root / "b"
sh.build_deck(self.shorthand_path, out_a, self.root / "data", override_epoch=42)
sh.build_deck(self.shorthand_path, out_b, self.root / "data", override_epoch=42)
for first in sorted(out_a.glob("*.apkg")):
second = out_b / first.name
self.assertEqual(first.read_bytes(), second.read_bytes(), first.name)
def test_stable_ids_are_unchanged_across_builds(self):
self._build()
db_path, _m, _n = self._collection()
conn = sqlite3.connect(db_path)
try:
first = sorted(r[0] for r in conn.execute("select id from notes"))
finally:
conn.close()
# Rebuild into a fresh dir; ids derive from the code form only.
out_b = self.root / "again"
sh.build_deck(self.shorthand_path, out_b, self.root / "data")
db_b, _m, _n = extract_collection(next(out_b.glob("*.apkg")), out_b)
conn = sqlite3.connect(db_b)
try:
second = sorted(r[0] for r in conn.execute("select id from notes"))
finally:
conn.close()
self.assertEqual(first, second)
def test_duplicate_code_form_is_rejected(self):
data = make_shorthand()
data["abbreviations"].append({"code": "73", "meaning": "duplicate"})
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError) as ctx:
sh.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("73", str(ctx.exception))
def test_qgroup_question_form_collides_with_abbrev_question_mark(self):
# A Q-group `QRX` expands to `QRX?`; an abbreviation literally
# named `QRX?` would collide and must be rejected.
data = make_shorthand()
data["q_codes"].append(
{"code": "QRX", "question": "When?", "statement": "Wait."}
)
data["abbreviations"].append({"code": "QRX?", "meaning": "clash"})
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError):
sh.build_deck(path, self.out_dir, self.root / "data")
def test_missing_required_field_is_rejected(self):
data = make_shorthand()
del data["q_codes"][0]["statement"]
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError) as ctx:
sh.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("statement", str(ctx.exception))
def test_unknown_field_is_rejected(self):
data = make_shorthand()
data["abbreviations"][0]["author"] = "claude"
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError) as ctx:
sh.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("author", str(ctx.exception))
def test_non_bool_exam_is_rejected(self):
data = make_shorthand()
data["abbreviations"][0]["exam"] = "yes"
path = write_shorthand(self.root, data)
with self.assertRaises(sh.AnkiBuildError):
sh.build_deck(path, self.out_dir, self.root / "data")
def test_missing_file_is_a_hard_error(self):
with self.assertRaises(sh.AnkiBuildError):
sh.build_deck(
self.root / "nope.json", self.out_dir, self.root / "data"
)
def test_main_returns_success(self):
rc = sh.main([
"--shorthand", str(self.shorthand_path),
"--out", str(self.out_dir),
"--data", str(self.root / "data"),
])
self.assertEqual(rc, sh.EXIT_OK)
class TestRealShorthandFile(unittest.TestCase):
"""Validate the repo's actual shorthand.json against the schema."""
def test_repo_shorthand_builds(self):
repo = Path(__file__).resolve().parent
path = repo / "shorthand.json"
if not path.exists():
self.skipTest("shorthand.json not available")
q_codes, abbreviations = sh.load_shorthand(path)
notes = sh.build_notes(q_codes, abbreviations)
self.assertEqual(len(notes), 2 * len(q_codes) + len(abbreviations))
# Every note carries exactly two cards.
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
if __name__ == "__main__":
unittest.main()
+213
View File
@@ -0,0 +1,213 @@
"""Tests for amateurfunk_technical.
Like the other deck tests, these inspect the generated `.apkg` directly
(ZIP entries plus the SQLite collection) and never require Anki. They
also validate the repo's real `technical.json` against the schema.
"""
import json
import sqlite3
import tempfile
import unittest
import zipfile
from pathlib import Path
import amateurfunk_technical as tech
from amateurfunk_anki import FIELD_SEP
def make_technical():
return {
"terms": [
{
"code": "SSB",
"category": "Betriebsart",
"meaning": "Single sideband.",
"exam": True,
"explanation": "One sideband, carrier suppressed.",
},
{
"code": "NF",
"category": "Signal & Frequenz",
"meaning": "Niederfrequenz — audio frequency.",
"exam": True,
},
{
"code": "SDR",
"category": "Digital & Daten",
"meaning": "Software defined radio.",
"tags": ["modern"],
},
],
}
def write_technical(root: Path, data=None):
path = root / "technical.json"
path.write_text(
json.dumps(data if data is not None else make_technical()),
encoding="utf-8",
)
return path
def extract_collection(apkg_path: Path, tmp: Path):
with zipfile.ZipFile(apkg_path) as zf:
zf.extract("collection.anki2", tmp)
media = json.loads(zf.read("media").decode("utf-8"))
names = set(zf.namelist())
return tmp / "collection.anki2", media, names
class TestTechnicalBuild(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.path = write_technical(self.root)
self.out_dir = self.root / "anki"
def _build(self, **kwargs):
return tech.build_deck(self.path, self.out_dir, self.root / "data", **kwargs)
def _collection(self):
apkg = next(self.out_dir.glob("*.apkg"))
return extract_collection(apkg, self.root)
def test_one_note_two_cards_per_term(self):
result = self._build()
self.assertEqual(result["terms"], 3)
self.assertEqual(result["notes"], 3)
self.assertEqual(result["cards"], 6)
def test_deck_and_model_are_distinct_from_operating_deck(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
models = json.loads(conn.execute("select models from col").fetchone()[0])
finally:
conn.close()
self.assertIn(tech.DECK_NAME, [d["name"] for d in decks.values()])
self.assertEqual(
next(iter(models.values()))["name"], tech.MODEL_NAME
)
def test_category_becomes_kind_field_and_tag(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = dict(
(r[0], (r[1], r[2]))
for r in conn.execute("select sfld, flds, tags from notes")
)
finally:
conn.close()
flds, tags = rows["SSB"]
# Kind field (index 2) carries the German category.
self.assertEqual(flds.split(FIELD_SEP)[2], "Betriebsart")
self.assertIn(" technik ", tags)
self.assertIn(" kategorie-betriebsart ", tags)
self.assertIn(" pruefung ", tags)
# A non-exam term carries no pruefung tag but keeps extra tags.
_flds, sdr_tags = rows["SDR"]
self.assertNotIn("pruefung", sdr_tags)
self.assertIn(" modern ", sdr_tags)
def test_each_note_has_forward_and_reverse_card(self):
self._build()
db_path, _media, _names = self._collection()
conn = sqlite3.connect(db_path)
try:
rows = conn.execute(
"select sfld, ord from notes join cards on cards.nid = notes.id"
).fetchall()
finally:
conn.close()
ords_for_ssb = sorted(o for s, o in rows if s == "SSB")
self.assertEqual(ords_for_ssb, [0, 1])
def test_no_media_in_package(self):
self._build()
_db, media, names = self._collection()
self.assertEqual(media, {})
self.assertEqual(names, {"collection.anki2", "media"})
def test_build_is_byte_deterministic_for_same_input(self):
out_a = self.root / "a"
out_b = self.root / "b"
tech.build_deck(self.path, out_a, self.root / "data", override_epoch=99)
tech.build_deck(self.path, out_b, self.root / "data", override_epoch=99)
for first in sorted(out_a.glob("*.apkg")):
self.assertEqual(
first.read_bytes(), (out_b / first.name).read_bytes(), first.name
)
def test_ids_are_namespaced_apart_from_operating_deck(self):
# A code that exists in both decks (e.g. a hypothetical clash)
# must not share a GUID across the two namespaces.
from amateurfunk_shorthand import make_note as sh_make_note
op = sh_make_note("SSB", "x", "k", "", None, None, " t ", "shorthand")
te = tech.build_notes(
[{"code": "SSB", "category": "Betriebsart", "meaning": "x"}]
)[0]
self.assertNotEqual(op["guid"], te["guid"])
self.assertNotEqual(op["note_id"], te["note_id"])
def test_duplicate_code_is_rejected(self):
data = make_technical()
data["terms"].append(
{"code": "SSB", "category": "Betriebsart", "meaning": "dup"}
)
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("SSB", str(ctx.exception))
def test_missing_category_is_rejected(self):
data = make_technical()
del data["terms"][0]["category"]
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("category", str(ctx.exception))
def test_unknown_field_is_rejected(self):
data = make_technical()
data["terms"][0]["author"] = "claude"
path = write_technical(self.root, data)
with self.assertRaises(tech.AnkiBuildError) as ctx:
tech.build_deck(path, self.out_dir, self.root / "data")
self.assertIn("author", str(ctx.exception))
def test_missing_file_is_a_hard_error(self):
with self.assertRaises(tech.AnkiBuildError):
tech.build_deck(self.root / "nope.json", self.out_dir, self.root / "data")
def test_main_returns_success(self):
rc = tech.main([
"--technical", str(self.path),
"--out", str(self.out_dir),
"--data", str(self.root / "data"),
])
self.assertEqual(rc, tech.EXIT_OK)
class TestRealTechnicalFile(unittest.TestCase):
"""Validate the repo's actual technical.json against the schema."""
def test_repo_technical_builds(self):
repo = Path(__file__).resolve().parent
path = repo / "technical.json"
if not path.exists():
self.skipTest("technical.json not available")
terms = tech.load_technical(path)
notes = tech.build_notes(terms)
self.assertEqual(len(notes), len(terms))
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
if __name__ == "__main__":
unittest.main()