Compare commits
12 Commits
5c6fbebd65
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
baeb325922
|
|||
|
4cb6412d4f
|
|||
|
3179f54681
|
|||
|
4a14bbb989
|
|||
|
989d6d6888
|
|||
|
189a1954f7
|
|||
|
c1603b1643
|
|||
|
c5c5f5f18e
|
|||
|
8fc3c0c32e
|
|||
|
6a4db1d889
|
|||
|
2b73096d3c
|
|||
|
fb81e9c1aa
|
@@ -44,10 +44,16 @@ BNetzA ZIP ──[Stage 1: amateurfunk_fetch.py]──► data/<slug>/
|
|||||||
|
|
||||||
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
||||||
├── amateurfunk-technische-kenntnisse-n.apkg
|
├── amateurfunk-technische-kenntnisse-n.apkg
|
||||||
├── amateurfunk-technische-kenntnisse-e.apkg
|
├── amateurfunk-technische-kenntnisse-e.apkg (one file, 11 topic sub-decks)
|
||||||
├── amateurfunk-technische-kenntnisse-a.apkg
|
├── amateurfunk-technische-kenntnisse-a.apkg (one file, 11 topic sub-decks)
|
||||||
├── amateurfunk-betriebliche-kenntnisse.apkg
|
├── amateurfunk-betriebliche-kenntnisse.apkg
|
||||||
└── amateurfunk-kenntnisse-von-vorschriften.apkg
|
└── amateurfunk-kenntnisse-von-vorschriften.apkg
|
||||||
|
|
||||||
|
shorthand.json ──[Stage 2b: amateurfunk_shorthand.py]──► anki/
|
||||||
|
└── amateurfunk-abkuerzungen-q-gruppen.apkg
|
||||||
|
|
||||||
|
technical.json ──[Stage 2c: amateurfunk_technical.py]──► anki/
|
||||||
|
└── amateurfunk-technische-abkuerzungen.apkg
|
||||||
```
|
```
|
||||||
|
|
||||||
### Stage 1 — `amateurfunk_fetch.py`
|
### Stage 1 — `amateurfunk_fetch.py`
|
||||||
@@ -70,10 +76,16 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
|||||||
`manifest-latest.json` to a per-edition directory).
|
`manifest-latest.json` to a per-edition directory).
|
||||||
2. Split the catalog into five categories. Betriebliche and
|
2. Split the catalog into five categories. Betriebliche and
|
||||||
Vorschriften get one deck each (shared across every candidate).
|
Vorschriften get one deck each (shared across every candidate).
|
||||||
Technische is additionally fanned out per license class into three
|
Technische is additionally fanned out per license class using a
|
||||||
decks (N / E / A) using a strict equality split on the question's
|
strict equality split on the question's `class` field: one `.apkg`
|
||||||
`class` field. The `klasse-N|E|A` tag is still emitted on every
|
each for N, E, and A. The E (463) and A (716) packages — the large
|
||||||
note for inside-Anki filtering.
|
pools — are each built as a deck *tree*: one sub-deck per
|
||||||
|
first-level catalog topic (the 11 subsections) under an anchoring
|
||||||
|
`Technische Kenntnisse::E` / `::A` parent, so each imports as a
|
||||||
|
single file but studies topic by topic. N stays a single flat deck.
|
||||||
|
The set of split classes is `TOPIC_SPLIT_CLASSES`. The
|
||||||
|
`klasse-N|E|A` tag is still emitted on every note for inside-Anki
|
||||||
|
filtering.
|
||||||
3. Render every question as an Anki note: shuffled A/B/C/D choices on
|
3. Render every question as an Anki note: shuffled A/B/C/D choices on
|
||||||
the front, the displayed position of the correct answer on the
|
the front, the displayed position of the correct answer on the
|
||||||
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
back. Inline `$...$` LaTeX is converted to MathJax `\(...\)`
|
||||||
@@ -92,6 +104,45 @@ data/ ──[Stage 2: amateurfunk_anki.py]──► anki/
|
|||||||
The Anki design decisions (shuffle seeding, deterministic build epoch,
|
The Anki design decisions (shuffle seeding, deterministic build epoch,
|
||||||
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
|
SVG dark-mode handling, schema choices) live in `DESIGN.md` §7.
|
||||||
|
|
||||||
|
### Stage 2b — `amateurfunk_shorthand.py`
|
||||||
|
|
||||||
|
A sibling builder for a standalone reference deck of Q-groups and
|
||||||
|
operating abbreviations — the ones in the exam plus the most common
|
||||||
|
on-air shorthand the exam never covers (real operating knowledge, not
|
||||||
|
just the test). Content lives in the hand-curated `shorthand.json`
|
||||||
|
(editorial, tracked in git, like `explanations.json`); the
|
||||||
|
`references/Q-Codes.md` reference is where the exam-present codes were
|
||||||
|
catalogued.
|
||||||
|
|
||||||
|
Each code is one Anki *note* with two card templates — forward
|
||||||
|
(code → meaning) and reverse (meaning → code) — so a single record
|
||||||
|
drives both directions. A Q-group means one thing as a statement
|
||||||
|
(`QSO`) and another as a question (`QSO?`), so each Q-group yields two
|
||||||
|
notes; plain abbreviations yield one. All IDs/GUIDs are hashed from the
|
||||||
|
displayed code form (stable re-import). The deck is catalog-independent
|
||||||
|
and fully deterministic; it only consults `data/` to borrow the
|
||||||
|
manifest build epoch when present. Low-level apkg/SQLite machinery is
|
||||||
|
imported from `amateurfunk_anki.py` so the two stay in lockstep. The
|
||||||
|
glossary machinery shared with Stage 2c (two-template note type,
|
||||||
|
two-cards-per-note writer, packager, build-epoch resolver, entry
|
||||||
|
validator) also lives here.
|
||||||
|
|
||||||
|
### Stage 2c — `amateurfunk_technical.py`
|
||||||
|
|
||||||
|
A third glossary deck, same card mechanics as Stage 2b (one note,
|
||||||
|
forward+reverse templates), for the *technical* vocabulary rather than
|
||||||
|
operating shorthand: modulation/modes (SSB, FM, CW), signal domains
|
||||||
|
(NF, HF, ZF), building blocks (VFO, PLL, AGC), components, measurements
|
||||||
|
(dB, SWR, PEP), propagation, digital modes, and the
|
||||||
|
organisations/regulations (ITU, CEPT, EMV) — exam terms plus common HAM
|
||||||
|
abbreviations beyond the exam. Content lives in the curated
|
||||||
|
`technical.json`; each entry carries a German `category` (Betriebsart,
|
||||||
|
Bauteil, …) shown on the card and used as a `kategorie-*` tag. The
|
||||||
|
shared glossary machinery is imported from `amateurfunk_shorthand.py`;
|
||||||
|
this script only adds the data shape, the deck/model names, and the tag
|
||||||
|
scheme. IDs live in their own `technical` namespace so the two glossary
|
||||||
|
decks never collide on import.
|
||||||
|
|
||||||
## Repo conventions
|
## Repo conventions
|
||||||
|
|
||||||
- Python 3.11+, standard library only. No third-party dependencies
|
- Python 3.11+, standard library only. No third-party dependencies
|
||||||
@@ -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
|
- `EXPLANATIONS.md` — the editorial contract for agents asked to add
|
||||||
or improve per-question explanations. The schema, the workflows
|
or improve per-question explanations. The schema, the workflows
|
||||||
("explain everything unexplained", "improve everything below
|
("explain everything unexplained", "improve everything below
|
||||||
confidence 7"), and the source/confidence guidance live there.
|
confidence 7"), the source/confidence guidance, and the **MathJax
|
||||||
`explanations.json` is an empty `{}` until agents populate it.
|
typesetting rules (§4a) with a verification sweep** live there. The
|
||||||
|
`$...$` typesetting is the most error-prone part of the file — read
|
||||||
|
§4a before touching any formula, and fix every occurrence in both the
|
||||||
|
explanation body and the `Hilfsmittel:` note.
|
||||||
- Start from `DESIGN.md` — it has the JSON schema, the question/answer
|
- Start from `DESIGN.md` — it has the JSON schema, the question/answer
|
||||||
conventions (answer A is always correct upstream → consumers shuffle
|
conventions (answer A is always correct upstream → consumers shuffle
|
||||||
before display), the LaTeX-in-questions caveat, the exam-structure
|
before display), the LaTeX-in-questions caveat, the exam-structure
|
||||||
|
|||||||
@@ -494,25 +494,44 @@ output artifacts.
|
|||||||
```
|
```
|
||||||
anki/
|
anki/
|
||||||
amateurfunk-technische-kenntnisse-n.apkg (195 cards)
|
amateurfunk-technische-kenntnisse-n.apkg (195 cards)
|
||||||
amateurfunk-technische-kenntnisse-e.apkg (463 cards)
|
amateurfunk-technische-kenntnisse-e.apkg (463 cards, 11 sub-decks)
|
||||||
amateurfunk-technische-kenntnisse-a.apkg (716 cards)
|
amateurfunk-technische-kenntnisse-a.apkg (716 cards, 11 sub-decks)
|
||||||
amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
|
amateurfunk-betriebliche-kenntnisse.apkg (172 cards)
|
||||||
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
|
amateurfunk-kenntnisse-von-vorschriften.apkg (204 cards)
|
||||||
```
|
```
|
||||||
|
|
||||||
Five `.apkg` files. Betriebliche and Vorschriften are shared across
|
Betriebliche and Vorschriften are shared across every candidate
|
||||||
every candidate (class-1-only in the data per §3 axis 2) and stay as
|
(class-1-only in the data per §3 axis 2) and stay as one deck each.
|
||||||
one deck each. Technische is fanned out per license class using a
|
Technische is fanned out per license class using a **strict equality
|
||||||
**strict equality split** on the question's `class` field — class-1
|
split** on the question's `class` field — class-1 questions land in
|
||||||
questions land in the N deck only, class-2 in E only, class-3 in A
|
the N deck only, class-2 in E only, class-3 in A only. The card counts
|
||||||
only. The card counts therefore equal the new-at-this-class slices
|
therefore equal the new-at-this-class slices from §3, not the
|
||||||
from §3, not the cumulative study pools: a candidate studying for
|
cumulative study pools: a candidate studying for class E imports
|
||||||
class E imports Technische-N + Technische-E + Betriebliche +
|
Technische-N + Technische-E + Betriebliche + Vorschriften.
|
||||||
Vorschriften.
|
|
||||||
|
The class-E (463) and class-A (716) Technische pools are large enough
|
||||||
|
that a single flat deck is unwieldy, so each package is built as a deck
|
||||||
|
*tree* rather than fanned out into separate files: one `.apkg`
|
||||||
|
containing one sub-deck per first-level catalog topic (the 11
|
||||||
|
subsections under the Prüfungsteil, e.g. "Sender und Empfänger",
|
||||||
|
"Antennen und Übertragungsleitungen") plus an anchoring `…::E` / `…::A`
|
||||||
|
parent deck. Each card is filed under its topic via the `did` column;
|
||||||
|
grouping is by the first section title below the Prüfungsteil
|
||||||
|
(`item.path[1]`). Anki sorts the sub-decks alphabetically on import (it
|
||||||
|
has no per-deck manual ordering), not in catalog order. N (195) is
|
||||||
|
small enough to stay one flat deck. The split classes are listed in
|
||||||
|
`TOPIC_SPLIT_CLASSES`. These multi-deck packages list every sub-deck in
|
||||||
|
the `col.decks` blob (see `build_apkg_for_category` and
|
||||||
|
`insert_collection_metadata`).
|
||||||
|
|
||||||
|
Note that re-importing a reworked package does not move cards that
|
||||||
|
already exist in a collection — Anki only files *new* cards by the
|
||||||
|
package's decks and leaves existing cards in their current deck.
|
||||||
|
|
||||||
The Technische deck names use Anki's `::` hierarchy separator
|
The Technische deck names use Anki's `::` hierarchy separator
|
||||||
(`Amateurfunk::Technische Kenntnisse::N`) so the three decks render
|
(`Amateurfunk::Technische Kenntnisse::N`, and one level deeper for E/A —
|
||||||
as children of a shared parent in Anki's deck browser. The
|
`Amateurfunk::Technische Kenntnisse::A::Sender und Empfänger`) so the
|
||||||
|
decks render as a nested tree in Anki's deck browser. The
|
||||||
`klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every
|
`klasse-N` / `klasse-E` / `klasse-A` tag is still emitted on every
|
||||||
note — redundant within each Technische deck but useful in Betr/Vor
|
note — redundant within each Technische deck but useful in Betr/Vor
|
||||||
for in-Anki filtering, and harmless besides.
|
for in-Anki filtering, and harmless besides.
|
||||||
|
|||||||
+191
-9
@@ -27,7 +27,8 @@ entry is purely additive — no regenerate ceremony beyond
|
|||||||
|
|
||||||
### Per-entry schema
|
### Per-entry schema
|
||||||
|
|
||||||
Every entry MUST have exactly these four fields:
|
Every entry MUST have these four required fields, and MAY carry the one
|
||||||
|
optional field below:
|
||||||
|
|
||||||
| Field | Type | Constraint |
|
| Field | Type | Constraint |
|
||||||
|---------------|---------|--------------------------------------------|
|
|---------------|---------|--------------------------------------------|
|
||||||
@@ -35,13 +36,22 @@ Every entry MUST have exactly these four fields:
|
|||||||
| `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
|
| `explanation` | string | Non-empty. **English.** Correct & helpful, WHY-focused |
|
||||||
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
|
| `source` | string | Non-empty. URL or citation like `AFuV §16(2)` |
|
||||||
| `confidence` | integer | `1..10` inclusive. See scale in §5 |
|
| `confidence` | integer | `1..10` inclusive. See scale in §5 |
|
||||||
|
| `provenance` | string | *Optional.* Only allowed value: `"50ohm-loesungsweg"` |
|
||||||
|
|
||||||
Extra keys are rejected by `load_explanations()` — the build fails
|
`provenance` records **how the text was produced**, which `source` (a
|
||||||
with `unknown fields [...]` listing them. The loader is similarly
|
citation) does not. Set it to `"50ohm-loesungsweg"` only on entries
|
||||||
strict about types: a JSON `true` will not satisfy the integer
|
that are genuinely a translation/condensation of a 50ohm.de worked
|
||||||
contract for `revision` or `confidence`. If you need to track
|
solution (`contents/solutions/<ID>.md` in `DARC-e-V/50ohm-contents-dl`).
|
||||||
editorial metadata that isn't shown on the card, propose a schema
|
The build uses it — and *not* the `source` domain — to decide whether
|
||||||
change rather than smuggling fields in.
|
to show the CC BY 4.0 derivative-work credit on the card (CC BY
|
||||||
|
requires naming the author team and indicating modification). Do **not**
|
||||||
|
add it just because an entry cites a 50ohm.de study page; merely citing
|
||||||
|
a page is not a derivative work. Any other value, or any other extra
|
||||||
|
field, is rejected by `load_explanations()` with
|
||||||
|
`unknown fields [...]` / `provenance must be one of [...]`. The loader
|
||||||
|
is also strict about types: a JSON `true` will not satisfy the integer
|
||||||
|
contract for `revision` or `confidence`. To track other editorial
|
||||||
|
metadata, propose a schema change rather than smuggling fields in.
|
||||||
|
|
||||||
Top-level keys (the question numbers) must also match the catalog
|
Top-level keys (the question numbers) must also match the catalog
|
||||||
exactly. An entry keyed on a number that no live question carries
|
exactly. An entry keyed on a number that no live question carries
|
||||||
@@ -156,13 +166,137 @@ source citation.
|
|||||||
rule of thumb.
|
rule of thumb.
|
||||||
- **Math.** Use inline `$...$`; it's rewritten to MathJax on the
|
- **Math.** Use inline `$...$`; it's rewritten to MathJax on the
|
||||||
card, same as for question text. Don't use display math `$$...$$`
|
card, same as for question text. Don't use display math `$$...$$`
|
||||||
(the deck has no MathJax support for those).
|
(the deck has no MathJax support for those). Getting the *inside* of
|
||||||
|
`$...$` right is its own discipline — see **§4a Math typesetting**
|
||||||
|
below. This is the single most error-prone part of this file.
|
||||||
- **Inline emphasis.** `<u>...</u>` is the only HTML you should
|
- **Inline emphasis.** `<u>...</u>` is the only HTML you should
|
||||||
type by hand. Everything else gets HTML-escaped.
|
type by hand. Everything else gets HTML-escaped.
|
||||||
- **No spoilers about other answers.** If a distractor is a common
|
- **No spoilers about other answers.** If a distractor is a common
|
||||||
trap, naming the trap is fine; quoting the distractor's text is
|
trap, naming the trap is fine; quoting the distractor's text is
|
||||||
noise.
|
noise.
|
||||||
|
|
||||||
|
### 4a. Math typesetting (MathJax) — get this right
|
||||||
|
|
||||||
|
Everything between `$...$` is TeX. MathJax renders **bare multi-letter
|
||||||
|
runs as a product of italic variables**, so `ohm` shows as *o·h·m*,
|
||||||
|
`log10` as *l·o·g·10*, `P_{PEP}` as *P·(P·E·P)*. A whole multi-round
|
||||||
|
review on this repo was spent fixing exactly these. The rules below are
|
||||||
|
the checklist; wrong → right.
|
||||||
|
|
||||||
|
**Units** — never leave a unit as bare letters; wrap in `\text{}` (or use
|
||||||
|
the symbol) and put a thin space `\,` after the number:
|
||||||
|
|
||||||
|
| Wrong (in `$...$`) | Right |
|
||||||
|
|---|---|
|
||||||
|
| `0.01 ohm`, `10 kOhm`, `5 MOhm` | `0.01\,\Omega`, `10\,\text{k}\Omega`, `5\,\text{M}\Omega` |
|
||||||
|
| `3 microhenry`, `3 uH` | `3\,\mu\text{H}` |
|
||||||
|
| `1 uF`, `100 nF`, `47 pF` | `1\,\mu\text{F}`, `100\,\text{nF}`, `47\,\text{pF}` |
|
||||||
|
| `5 mA`, `2 mV`, `1 kHz`, `28 MHz` | `5\,\text{mA}`, `2\,\text{mV}`, `1\,\text{kHz}`, `28\,\text{MHz}` |
|
||||||
|
| `12 dB`, `8 dBi`, `46.8 bit/s` | `12\,\text{dB}`, `8\,\text{dBi}`, `46.8\,\text{bit/s}` |
|
||||||
|
| `5 mm2`, `2.5 A/mm2` | `5\,\text{mm}^2`, `2.5\,\text{A/mm}^2` |
|
||||||
|
| `360 degrees`, `45°` | `360^\circ`, `45^\circ` |
|
||||||
|
| `20 A`, `12 V`, `100 W`, `5 m`, `50 s` | `20\,\text{A}`, `12\,\text{V}`, `100\,\text{W}`, `5\,\text{m}`, `50\,\text{s}` |
|
||||||
|
|
||||||
|
**Operators, functions, Greek, powers:**
|
||||||
|
|
||||||
|
| Wrong | Right | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| `log10(x)` | `\log_{10}(x)` | bare `log` is *l·o·g* |
|
||||||
|
| `10^(20/10)` | `10^{20/10}` | `^(` superscripts only the `(`; braces group |
|
||||||
|
| `lambda`, `pi`, `tau`, `omega`, `rho`, `mu` | `\lambda`, `\pi`, `\tau`, … | bare = italic letters |
|
||||||
|
| `sin`, `cos`, `ln` | `\sin`, `\cos`, `\ln` | |
|
||||||
|
| `2 x 3`, `R1 || R2` | `2 \cdot 3`, `R_1 \parallel R_2` | `x`/`||` are not operators |
|
||||||
|
| `62.5%` | `62.5\%` | **bare `%` is a TeX comment — it silently eats the rest of the math** |
|
||||||
|
| `4,200,000` | `4{,}200{,}000` | bare `,` gets TeX punctuation spacing |
|
||||||
|
|
||||||
|
**Subscripts.** Descriptive multi-letter labels/acronyms are roman; a
|
||||||
|
single-letter subscript stays italic:
|
||||||
|
|
||||||
|
| Wrong | Right |
|
||||||
|
|---|---|
|
||||||
|
| `P_{EIRP}`, `U_{peak}`, `f_{mod}`, `V_{BE}`, `f_{sum}` | `P_\mathrm{EIRP}`, `U_\mathrm{peak}`, `f_\mathrm{mod}`, `V_\mathrm{BE}`, `f_\mathrm{sum}` |
|
||||||
|
| `R1/R2 = R3/R4` | `R_1/R_2 = R_3/R_4` |
|
||||||
|
| keep italic: `U_F`, `f_c`, `X_C`, `R_g` | (single letter = variable, leave as-is) |
|
||||||
|
|
||||||
|
A standalone acronym used as a variable (e.g. `MUF` on a formula's LHS)
|
||||||
|
also goes roman: `\mathrm{MUF}`.
|
||||||
|
|
||||||
|
**Dimensional constants keep their unit.** The field-strength constant is
|
||||||
|
`30\,\Omega`, so write `\sqrt{30\,\Omega \cdot P_\mathrm{EIRP}}` and
|
||||||
|
`(E\cdot d)^2/(30\,\Omega)`, not a bare `30`. Same idea for the dB-level
|
||||||
|
exponent: `10^{g/(10\,\text{dB})}` (the `(...)` groups the denominator —
|
||||||
|
`10^{g/10\,\text{dB}}` parses as `(g/10)·dB`).
|
||||||
|
|
||||||
|
**Unit vs. variable — don't blindly unit-ize a single letter.** `A` is
|
||||||
|
amperes in `20 A` but *area* in `N^2 A/l`; `s`/`m` are seconds/metres in
|
||||||
|
`50 s` / `5 m` but could be variables elsewhere. The tell: a *free-standing
|
||||||
|
number* immediately before the letter (`20 A`) means a unit; an exponent
|
||||||
|
base (`N^2 A`) or a symbolic factor (`S \cdot A`) means a variable. When
|
||||||
|
unsure, read the sentence.
|
||||||
|
|
||||||
|
**What stays prose — do NOT force into `$...$`:**
|
||||||
|
|
||||||
|
- Band/wavelength designations: `5/8-wave`, `20/15/10 m trap dipole`,
|
||||||
|
`the 80 m band`.
|
||||||
|
- Conceptual word-equations: `1st overtone = 2nd harmonic`,
|
||||||
|
`Region 1 = Europe/Africa`.
|
||||||
|
- Units mentioned adjectivally in a sentence: "a 50 ohm antenna",
|
||||||
|
"the 28 V/m limit", "about 11.7 V/m".
|
||||||
|
|
||||||
|
But a **worked computation with an `=`** (e.g. `230 V / 20 = 11.5 V`)
|
||||||
|
belongs in `$...$`, fully typeset.
|
||||||
|
|
||||||
|
### Verifying it — the process lessons
|
||||||
|
|
||||||
|
These review rounds kept finding *peers* of an already-fixed defect.
|
||||||
|
Avoid the repeat:
|
||||||
|
|
||||||
|
1. **Fix the whole record, not a substring.** The same formula usually
|
||||||
|
appears in *both* the explanation body *and* the `Hilfsmittel:` note.
|
||||||
|
Search the entire entry and fix every occurrence.
|
||||||
|
2. **Fix the whole class, file-wide — not just the IDs someone listed.**
|
||||||
|
If `P_{EIRP}` is wrong in one card it is wrong in twenty; sweep the
|
||||||
|
whole file for the pattern.
|
||||||
|
3. **Verify with a positive sweep, not a blacklist.** A list of
|
||||||
|
known-bad tokens always misses a new category (it missed `degrees`,
|
||||||
|
`bit`, `||`, bare seconds…). Instead: strip `\text{}`/`\mathrm{}` and
|
||||||
|
the known macros, then flag *every remaining* ≥2-letter run inside
|
||||||
|
`$...$` — each survivor must be a deliberate variable product
|
||||||
|
(`LC`, `jX`, `RC`, `di/dt`) or it's a bug.
|
||||||
|
4. **Don't claim "0 / exhaustive" unless the check actually covers that
|
||||||
|
category.** Report what you checked, not a blanket adjective.
|
||||||
|
|
||||||
|
A ready-made sweep:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -c '
|
||||||
|
import json, re
|
||||||
|
d = json.load(open("explanations.json"))
|
||||||
|
span = re.compile(r"\$[^$]*\$")
|
||||||
|
def strip(s):
|
||||||
|
s = re.sub(r"\\(?:text|mathrm|operatorname)\{[^{}]*\}", " ", s)
|
||||||
|
return re.sub(r"\\[A-Za-z]+", " ", s) # drop macros (\sqrt, \pi, \cdot, ...)
|
||||||
|
for k, e in d.items():
|
||||||
|
ex = e["explanation"] if isinstance(e, dict) else ""
|
||||||
|
if ex.count("$") % 2 or ex.count("{") != ex.count("}"):
|
||||||
|
print("BALANCE", k)
|
||||||
|
for m in span.finditer(ex):
|
||||||
|
s = m.group(0)
|
||||||
|
if re.search(r"(?<!\\)%", s) or "||" in s or "°" in s \
|
||||||
|
or re.search(r"(?<!\\)\blog10\b|10\^\(", s) \
|
||||||
|
or re.search(r"_\{[A-Za-z][A-Za-z,]*\}", s) \
|
||||||
|
or re.search(r"(?<=\d),(?=\d\d\d)", s):
|
||||||
|
print("ISSUE", k, m.group(0)[:60]); break
|
||||||
|
for tok in re.findall(r"[A-Za-z]{2,}", strip(s)):
|
||||||
|
# whitelist genuine products / kept tokens; investigate the rest
|
||||||
|
if tok not in {"LC","RC","RA","UI","CU","PR","jX","fL","fC","di","dt"}:
|
||||||
|
print("TOKEN?", k, tok, "|", m.group(0)[:60])
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Anything it prints is either a real defect or a new legitimate product to
|
||||||
|
add to the whitelist — look, don't assume.
|
||||||
|
|
||||||
### What "WHY-focused" means in practice
|
### What "WHY-focused" means in practice
|
||||||
|
|
||||||
| Less useful | More useful |
|
| Less useful | More useful |
|
||||||
@@ -176,6 +310,43 @@ band-plan lookup), say that plainly and cite the band plan in
|
|||||||
`source` — confidence stays low (3–4) until someone finds a deeper
|
`source` — confidence stays low (3–4) until someone finds a deeper
|
||||||
hook.
|
hook.
|
||||||
|
|
||||||
|
### The Hilfsmittel note
|
||||||
|
|
||||||
|
Candidates may use the official exam aid (`Hilfsmittel_12062024.pdf`)
|
||||||
|
during the exam. Its complete contents — every formula and table, with
|
||||||
|
**printed page numbers** — are catalogued in
|
||||||
|
[`references/Hilfsmittel.md`](references/Hilfsmittel.md). When a
|
||||||
|
question's answer is a lookup or a direct application of something in
|
||||||
|
that sheet, append a short note to the end of the `explanation` body:
|
||||||
|
|
||||||
|
```
|
||||||
|
... <u>Hilfsmittel:</u> <pointer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules — this is the part that was historically done badly (generic
|
||||||
|
boilerplate stamped on everything), so be strict:
|
||||||
|
|
||||||
|
- **Only cite what is actually in the sheet.** Verify against
|
||||||
|
`references/Hilfsmittel.md`. If the fact the question turns on is a
|
||||||
|
memory item — diode forward voltages (~0.6 V / ~0.3 V), `tan δ = 1/Q`,
|
||||||
|
S-meter step = 6 dB, harmonics = n × fundamental, ppm/percent
|
||||||
|
arithmetic, semiconductor behaviour, definitions, antenna
|
||||||
|
length/shortening factors — **do not add a Hilfsmittel note at all.**
|
||||||
|
- **Name the specific formula(s) or table, and the page.** Not "the
|
||||||
|
formula is in the Formelsammlung" but e.g. `P = U²/R (Leistung,
|
||||||
|
S.12)` or `the Widerstands-Farbcode table (S.11)`.
|
||||||
|
- **For multi-step calculations, give the order:** "first
|
||||||
|
`U_eff = Û/√2` (Wechselspannung, S.12), then `P = U_eff²/R`
|
||||||
|
(Leistung, S.12)". State plainly when a value comes from outside the
|
||||||
|
sheet (e.g. a diode drop) versus from a sheet formula.
|
||||||
|
- **For a pure table lookup**, say it is a lookup and cite the table +
|
||||||
|
page (e.g. "a table lookup, not a memory item — the band limits …
|
||||||
|
are in the Frequenzbereichszuweisung (Anlage 1, Tabellarische
|
||||||
|
Übersicht, S. 2–3)").
|
||||||
|
- **Page numbering:** the printed page number = PDF page − 2 (the cover
|
||||||
|
and the "Hinweis" page are unnumbered). Always cite the printed
|
||||||
|
number, as `references/Hilfsmittel.md` does.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Confidence scale
|
## 5. Confidence scale
|
||||||
@@ -418,7 +589,18 @@ Before saving, confirm:
|
|||||||
- [ ] `confidence` honestly reflects how well the source supports
|
- [ ] `confidence` honestly reflects how well the source supports
|
||||||
the explanation.
|
the explanation.
|
||||||
- [ ] Keys remain in alphabetical order in the file.
|
- [ ] Keys remain in alphabetical order in the file.
|
||||||
- [ ] `python3 -m unittest test_amateurfunk_anki` passes.
|
- [ ] **Math typesetting (§4a):** every formula is in `$...$`; units are
|
||||||
|
`\text{}`/symbols with a leading `\,`; no bare `log10`, `10^(`,
|
||||||
|
Greek words, `%`, `||`, multi-letter `_{...}` subscripts, or raw
|
||||||
|
thousands commas; dimensional constants keep their unit. Fix every
|
||||||
|
occurrence in **both** the body and the `Hilfsmittel:` note.
|
||||||
|
- [ ] The §4a verification sweep prints nothing unexpected (run it after
|
||||||
|
any batch of math edits — don't trust a per-ID fix to have caught
|
||||||
|
the peers).
|
||||||
|
- [ ] `python3 -m unittest test_amateurfunk_anki` passes. (The full deck
|
||||||
|
suite is `test_amateurfunk_fetch test_amateurfunk_anki
|
||||||
|
test_amateurfunk_shorthand test_amateurfunk_technical` — 95 tests;
|
||||||
|
the two-module subset that validates explanation schema is 65.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.PHONY: all fetch anki test clean
|
.PHONY: all fetch anki shorthand technical test clean
|
||||||
|
|
||||||
all: fetch anki
|
all: fetch anki shorthand technical
|
||||||
|
|
||||||
fetch:
|
fetch:
|
||||||
python3 amateurfunk_fetch.py
|
python3 amateurfunk_fetch.py
|
||||||
@@ -8,8 +8,14 @@ fetch:
|
|||||||
anki:
|
anki:
|
||||||
python3 amateurfunk_anki.py
|
python3 amateurfunk_anki.py
|
||||||
|
|
||||||
|
shorthand:
|
||||||
|
python3 amateurfunk_shorthand.py
|
||||||
|
|
||||||
|
technical:
|
||||||
|
python3 amateurfunk_technical.py
|
||||||
|
|
||||||
test:
|
test:
|
||||||
python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki
|
python3 -m unittest test_amateurfunk_fetch test_amateurfunk_anki test_amateurfunk_shorthand test_amateurfunk_technical
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf data anki __pycache__
|
rm -rf data anki __pycache__
|
||||||
|
|||||||
@@ -6,19 +6,33 @@ Bundesnetzagentur and turn it into Anki decks.
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make # fetch + build (default)
|
make # fetch + build everything (default)
|
||||||
make fetch # download + extract the catalog only → data/
|
make fetch # download + extract the catalog only → data/
|
||||||
make anki # rebuild .apkg files from data/ → anki/
|
make anki # rebuild the five exam decks from data/ → anki/
|
||||||
make test # run both test suites
|
make shorthand # rebuild the Q-group / operating-abbreviation deck → anki/
|
||||||
|
make technical # rebuild the technical / HAM-abbreviation deck → anki/
|
||||||
|
make test # run all test suites
|
||||||
make clean # remove data/ and anki/
|
make clean # remove data/ and anki/
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: five `.apkg` files under `anki/`. Betriebliche and
|
Output: seven `.apkg` files under `anki/`.
|
||||||
|
|
||||||
|
**Five exam decks** built from the catalog: Betriebliche and
|
||||||
Vorschriften get one deck each (shared across all license classes);
|
Vorschriften get one deck each (shared across all license classes);
|
||||||
Technische is split per class into three decks (N / E / A) following
|
Technische is split per class into three decks (N / E / A) following
|
||||||
the catalog's `class` field. A class-A candidate who wants every
|
the catalog's `class` field. A class-A candidate who wants every
|
||||||
Technische question imports all three Technische decks. Re-importing
|
Technische question imports all three Technische decks. The E (463)
|
||||||
a newer build preserves your review history.
|
and A (716) decks are large pools, so each ships as a deck *tree* —
|
||||||
|
one sub-deck per first-level exam topic under `Technische Kenntnisse::E`
|
||||||
|
/ `::A` — so you can study them topic by topic instead of as one giant
|
||||||
|
deck. Each is still a single `.apkg`; N (195) stays flat (small enough
|
||||||
|
that sub-decks would add clutter without helping).
|
||||||
|
|
||||||
|
**Two glossary decks** of radio shorthand — Q-groups and operating
|
||||||
|
abbreviations, and technical/HAM abbreviations — built from curated
|
||||||
|
data rather than the catalog (see [Glossary decks](#glossary-decks)).
|
||||||
|
|
||||||
|
Re-importing a newer build preserves your review history.
|
||||||
|
|
||||||
## Exam sections
|
## Exam sections
|
||||||
|
|
||||||
@@ -36,18 +50,63 @@ belongs to:
|
|||||||
|
|
||||||
**Technical (one deck per license class):**
|
**Technical (one deck per license class):**
|
||||||
|
|
||||||
| Class | Section name | ID prefix | Questions |
|
| Class | Section name | ID prefix | Questions | Layout |
|
||||||
|-------|---------------------------|-----------|----------:|
|
|-------|---------------------------|-----------|----------:|--------|
|
||||||
| N | Technische Kenntnisse (N) | `N*` | 195 |
|
| N | Technische Kenntnisse (N) | `N*` | 195 | one flat deck |
|
||||||
| E | Technische Kenntnisse (E) | `E*` | 463 |
|
| E | Technische Kenntnisse (E) | `E*` | 463 | 11 topic sub-decks |
|
||||||
| A | Technische Kenntnisse (A) | `A*` | 716 |
|
| A | Technische Kenntnisse (A) | `A*` | 716 | 11 topic sub-decks |
|
||||||
|
|
||||||
Counts are from the current edition (3. Auflage, März 2024; ~1750
|
Counts are from the current edition (3. Auflage, März 2024; ~1750
|
||||||
questions total). The license tiers are cumulative for the exam:
|
questions total). The class-E and class-A decks are each split into one
|
||||||
|
sub-deck per first-level exam topic (the 11 catalog subsections —
|
||||||
|
*Bauteile*, *Sender und Empfänger*, *Antennen …*, etc.) because those
|
||||||
|
pools are unwieldy as a single deck; each is still one `.apkg`
|
||||||
|
containing a deck tree. Anki sorts those sub-decks alphabetically on
|
||||||
|
import, not in exam order. The license tiers are cumulative for the exam:
|
||||||
a class-E candidate is responsible for `N*` + `E*` + `B*` + `V*`;
|
a class-E candidate is responsible for `N*` + `E*` + `B*` + `V*`;
|
||||||
a class-A candidate is responsible for everything. Filter inside
|
a class-A candidate is responsible for everything. Filter inside
|
||||||
Anki by deck, by the `klasse-N|E|A` tag, or by the `Number` field
|
Anki by deck, by the `klasse-N|E|A` tag, or — for the topic within a
|
||||||
prefix.
|
deck — by the `pfad-*` tag, or by the `Number` field prefix.
|
||||||
|
|
||||||
|
## Glossary decks
|
||||||
|
|
||||||
|
Two extra decks teach the radio shorthand a candidate actually needs —
|
||||||
|
the codes used in the exam **plus** the most common ones used on the
|
||||||
|
air that the exam never tests (real operating knowledge, not just the
|
||||||
|
test). They are built from hand-curated JSON, independent of the
|
||||||
|
catalog, so they build even without `make fetch`:
|
||||||
|
|
||||||
|
| Deck | Source | Builder |
|
||||||
|
|-----------------------------------------------|------------------|----------------------------|
|
||||||
|
| `amateurfunk-abkuerzungen-q-gruppen.apkg` | `shorthand.json` | `amateurfunk_shorthand.py` |
|
||||||
|
| `amateurfunk-technische-abkuerzungen.apkg` | `technical.json` | `amateurfunk_technical.py` |
|
||||||
|
|
||||||
|
- **Q-groups & operating abbreviations** — Q-codes (QRM, QSO, QSY…),
|
||||||
|
CW/voice shorthand (CQ, DE, 73, RST…), prosigns, and the
|
||||||
|
distress/urgency signals (MAYDAY, SOS…).
|
||||||
|
- **Technical & HAM abbreviations** — modulation and modes (SSB, FM,
|
||||||
|
CW), signal domains (NF, HF, ZF), building blocks (VFO, PLL, AGC),
|
||||||
|
components, measurements (dB, SWR, PEP), propagation, digital modes,
|
||||||
|
and the organisations/regulations (ITU, CEPT, EMV).
|
||||||
|
|
||||||
|
Each code is a single Anki **note** with two cards: one prompts for the
|
||||||
|
meaning given the code, the reverse prompts for the code given the
|
||||||
|
meaning. A Q-group means one thing as a statement (`QSO`) and another
|
||||||
|
as a question (`QSO?`), so each becomes two notes (four cards).
|
||||||
|
|
||||||
|
Filter inside Anki by tag: `pruefung` marks codes that appear in the
|
||||||
|
exam catalog; `q-gruppe` / `abkuerzung` and (technical deck)
|
||||||
|
`kategorie-*` mark the kind; `prosign` and `notsignal` mark prosigns
|
||||||
|
and the non-amateur distress signals. The `pruefung` flags and the
|
||||||
|
meanings were cross-checked against the BNetzA catalog — `pruefung`
|
||||||
|
means "this code's meaning is tested", not merely "the string appears
|
||||||
|
somewhere".
|
||||||
|
|
||||||
|
> **⚠ Important: AI-generated content.** `shorthand.json` and
|
||||||
|
> `technical.json` are compiled with AI assistance. As with the
|
||||||
|
> explanations and references, verify anything that looks off against a
|
||||||
|
> primary source (the catalog, the ARRL/DARC Q-code lists, or the
|
||||||
|
> resources under [See also](#see-also)) before relying on it.
|
||||||
|
|
||||||
## Exam question source
|
## Exam question source
|
||||||
|
|
||||||
@@ -68,6 +127,27 @@ BNetzA replaces the file in place across editions, so the URL is
|
|||||||
stable; the fetcher detects updates via the HTTP `Last-Modified`
|
stable; the fetcher detects updates via the HTTP `Last-Modified`
|
||||||
header.
|
header.
|
||||||
|
|
||||||
|
## Exam aids (Hilfsmittel)
|
||||||
|
|
||||||
|
BNetzA publishes an official aid sheet that candidates **are allowed to
|
||||||
|
use during the exam** — you do not have to memorize anything it
|
||||||
|
contains:
|
||||||
|
|
||||||
|
- [`Hilfsmittel_12062024.pdf`](https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/AntraegeFormulare/Hilfsmittel_12062024.pdf?__blob=publicationFile&v=3)
|
||||||
|
|
||||||
|
It bundles the BNetzA frequency-allocation table (band limits, usage
|
||||||
|
parameters, and maximum power per class), the IARU band plans, the
|
||||||
|
German *Rufzeichenplan* (call-sign series → class plus the
|
||||||
|
international suffixes like `/m`, `/mm`, `/p`), and the *Formelsammlung*
|
||||||
|
(formula collection) together with constants and material tables. It
|
||||||
|
does **not** contain foreign-country prefixes (*Landeskenner*),
|
||||||
|
Q-codes, or the phonetic alphabet — those remain memory items.
|
||||||
|
|
||||||
|
Questions whose answer can be read or derived from this sheet carry a
|
||||||
|
**Hilfsmittel** note in their explanation (see below), so you can tell
|
||||||
|
when something is a lookup in the exam rather than something to learn
|
||||||
|
by heart.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [**50ohm.de**](https://50ohm.de/) — community-maintained
|
- [**50ohm.de**](https://50ohm.de/) — community-maintained
|
||||||
@@ -109,6 +189,13 @@ on this: questions without an entry just show no explanation block.
|
|||||||
Entries with `confidence < 7` render a small "low confidence" badge
|
Entries with `confidence < 7` render a small "low confidence" badge
|
||||||
so learners know the reasoning is provisional.
|
so learners know the reasoning is provisional.
|
||||||
|
|
||||||
|
Where a question's answer is available in the official exam aid sheet
|
||||||
|
(see [Exam aids (Hilfsmittel)](#exam-aids-hilfsmittel)), the explanation
|
||||||
|
appends a short **Hilfsmittel** note — for example pointing at the
|
||||||
|
frequency-allocation table, the IARU band plan, the Rufzeichenplan, or
|
||||||
|
the Formelsammlung — to flag that it is a lookup you may consult during
|
||||||
|
the exam rather than a memory item.
|
||||||
|
|
||||||
`EXPLANATIONS.md` is the editorial contract: schema, sourcing
|
`EXPLANATIONS.md` is the editorial contract: schema, sourcing
|
||||||
guidance, confidence scale, and the workflows an AI agent should
|
guidance, confidence scale, and the workflows an AI agent should
|
||||||
follow when asked to add or improve entries.
|
follow when asked to add or improve entries.
|
||||||
@@ -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
|
- `references/Call-Signs.md` — call-sign patterns, suffixes, and country
|
||||||
prefixes that appear in the catalog.
|
prefixes that appear in the catalog.
|
||||||
- `references/Q-Codes.md` — Q-codes and operating shorthand used in the
|
- `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
|
## More
|
||||||
|
|
||||||
- `CLAUDE.md` — project orientation, pipeline overview.
|
- `CLAUDE.md` — project orientation, pipeline overview (including the
|
||||||
|
two [glossary decks](#glossary-decks)).
|
||||||
- `DESIGN.md` — source-discovery notes, JSON schema, per-stage
|
- `DESIGN.md` — source-discovery notes, JSON schema, per-stage
|
||||||
design contracts.
|
design contracts.
|
||||||
- `EXPLANATIONS.md` — schema + workflows for the explanations
|
- `EXPLANATIONS.md` — schema + workflows for the explanations
|
||||||
database.
|
database.
|
||||||
|
- `shorthand.json` / `technical.json` — curated source data for the
|
||||||
|
two [glossary decks](#glossary-decks).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+309
-30
@@ -63,6 +63,17 @@ DEFAULT_EXPLANATIONS_PATH = Path("explanations.json")
|
|||||||
# EXPLANATIONS.md for the full editorial contract.
|
# EXPLANATIONS.md for the full editorial contract.
|
||||||
EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
|
EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
|
||||||
|
|
||||||
|
# Optional, validated provenance marker. A `source` URL is only a
|
||||||
|
# citation; it does NOT establish that the explanation is a derivative
|
||||||
|
# work. `provenance` records *how the text was produced* so the CC BY
|
||||||
|
# derivative-work credit is shown only for records that genuinely are
|
||||||
|
# one. The single recognised value marks an explanation translated and
|
||||||
|
# condensed from a 50ohm.de worked solution
|
||||||
|
# (contents/solutions/<ID>.md in DARC-e-V/50ohm-contents-dl).
|
||||||
|
PROVENANCE_50OHM_SOLUTION = "50ohm-loesungsweg"
|
||||||
|
ALLOWED_PROVENANCE = frozenset({PROVENANCE_50OHM_SOLUTION})
|
||||||
|
OPTIONAL_EXPLANATION_FIELDS = ("provenance",)
|
||||||
|
|
||||||
# Confidence values strictly below this threshold are surfaced on
|
# Confidence values strictly below this threshold are surfaced on
|
||||||
# the card as a "low confidence" badge — a learner-facing warning
|
# the card as a "low confidence" badge — a learner-facing warning
|
||||||
# that the explanation may be incomplete or weakly sourced. Matches
|
# that the explanation may be incomplete or weakly sourced. Matches
|
||||||
@@ -70,6 +81,49 @@ EXPLANATION_FIELDS = ("revision", "explanation", "source", "confidence")
|
|||||||
# below 7 is review-worthy").
|
# below 7 is review-worthy").
|
||||||
LOW_CONFIDENCE_THRESHOLD = 7
|
LOW_CONFIDENCE_THRESHOLD = 7
|
||||||
|
|
||||||
|
# ---- Attribution (licensing) ----------------------------------------------
|
||||||
|
# Two sources are redistributed in these decks and each carries an
|
||||||
|
# attribution obligation:
|
||||||
|
# * the questions/figures come from the BNetzA question catalog under
|
||||||
|
# Datenlizenz Deutschland – Namensnennung 2.0 (DL-DE→BY-2.0); and
|
||||||
|
# * the English explanations are translated and condensed from the
|
||||||
|
# worked solutions on 50ohm.de, which is CC BY 4.0 and requires both
|
||||||
|
# naming the author team and indicating that changes were made.
|
||||||
|
# DECK_ATTRIBUTION goes in every deck's description (the whole-deck
|
||||||
|
# notice); EXPLANATION_CC_BY_CREDIT is the per-card line appended to
|
||||||
|
# each 50ohm-derived explanation so the "modified" indication travels
|
||||||
|
# with the modified text itself.
|
||||||
|
EXPLANATION_CC_BY_CREDIT = (
|
||||||
|
"Explanation translated & condensed from the worked solution on "
|
||||||
|
'<a href="https://50ohm.de/">50ohm.de</a> — '
|
||||||
|
"© 50ohm.de-Autorenteam (AJW-Referat, DARC e. V.), "
|
||||||
|
'<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>. '
|
||||||
|
"Question © Bundesnetzagentur, "
|
||||||
|
'<a href="https://www.govdata.de/dl-de/by-2-0">DL-DE→BY-2.0</a>.'
|
||||||
|
)
|
||||||
|
|
||||||
|
DECK_ATTRIBUTION = (
|
||||||
|
"Amateurfunk-Lernkarten / amateur-radio exam flashcards.<br><br>"
|
||||||
|
"<b>Questions & figures:</b> official question catalog "
|
||||||
|
""Prüfungsfragen zum Erwerb von "
|
||||||
|
"Amateurfunkprüfungsbescheinigungen", Bundesnetzagentur, "
|
||||||
|
"3. Auflage, März 2024 "
|
||||||
|
"(https://www.bundesnetzagentur.de/amateurfunk). Licensed under "
|
||||||
|
"Datenlizenz Deutschland – Namensnennung – Version 2.0 "
|
||||||
|
"(https://www.govdata.de/dl-de/by-2-0).<br><br>"
|
||||||
|
"<b>Explanations:</b> written for this project. Where an explanation "
|
||||||
|
"is marked as derived from a 50ohm.de worked solution "
|
||||||
|
"("Lösungsweg"), that text is © 50ohm.de-Autorenteam, "
|
||||||
|
"koordiniert durch das AJW-Referat des DARC e. V., licensed under "
|
||||||
|
"CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/), and has "
|
||||||
|
"been modified — translated from German and shortened. Other "
|
||||||
|
"explanations cite their own sources.<br><br>"
|
||||||
|
"<b>Exam-aid references</b> cite the BNetzA "Hilfsmittel" "
|
||||||
|
"formula sheet.<br><br>"
|
||||||
|
"This deck is an independent project and is not endorsed by the "
|
||||||
|
"Bundesnetzagentur or by the DARC e. V."
|
||||||
|
)
|
||||||
|
|
||||||
# Exit codes. The builder is much simpler than the fetcher — there is
|
# Exit codes. The builder is much simpler than the fetcher — there is
|
||||||
# no "operator must resolve local state" case here, so two codes are
|
# no "operator must resolve local state" case here, so two codes are
|
||||||
# enough.
|
# enough.
|
||||||
@@ -103,6 +157,31 @@ CLASS_ORDER = [("1", "N"), ("2", "E"), ("3", "A")]
|
|||||||
# shared across all candidates and stay as a single deck each.
|
# shared across all candidates and stay as a single deck each.
|
||||||
TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
|
TECHNISCHE_SHORT_TITLE = "Technische Kenntnisse"
|
||||||
|
|
||||||
|
# License classes whose Technische package is built with one sub-deck
|
||||||
|
# per first-level catalog topic (the 11 subsections under the
|
||||||
|
# Prüfungsteil) instead of a single flat deck. The E (463) and A (716)
|
||||||
|
# pools are large enough that a single deck is unwieldy; N (195) stays
|
||||||
|
# a single flat deck. Still one `.apkg` per class — the sub-decks live
|
||||||
|
# inside the package as a deck tree.
|
||||||
|
TOPIC_SPLIT_CLASSES = frozenset({"E", "A"})
|
||||||
|
|
||||||
|
# The catalog's figures come in two kinds: vector SVGs (the bulk; mostly
|
||||||
|
# small — well under ~200 px) and a handful of raster PNGs (all 1024×685). We
|
||||||
|
# enlarge only the SVGs: they scale crisply, they're the ones that read tiny
|
||||||
|
# on a wide desktop card, and the PNGs are already at roughly the size we're
|
||||||
|
# aiming for. The enlargement is `MEDIA_SCALE`× the native size, with
|
||||||
|
# `MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` acting as an *enlargement ceiling* —
|
||||||
|
# it bounds how far a below-cap figure may grow (a figure already at/above a
|
||||||
|
# cap is left at native size; we never shrink). The ceiling is the PNGs'
|
||||||
|
# ballpark (1024×768) so the largest SVG schematics (up to 635×481 px in the
|
||||||
|
# 3rd edition: the NG20x block diagrams, EB309/EB310) grow to a comparable
|
||||||
|
# size rather than staying cramped, while folding in native *height* still
|
||||||
|
# stops tall-but-narrow figures from growing off the bottom of the card.
|
||||||
|
# See `MediaRegistry.image_html`.
|
||||||
|
MEDIA_SCALE = 2
|
||||||
|
MEDIA_MAX_WIDTH = 1024
|
||||||
|
MEDIA_MAX_HEIGHT = 768
|
||||||
|
|
||||||
# Fallback build epoch if neither the manifest nor `--epoch` supplies
|
# Fallback build epoch if neither the manifest nor `--epoch` supplies
|
||||||
# one. Picked as 0 so missing-metadata builds are still deterministic
|
# one. Picked as 0 so missing-metadata builds are still deterministic
|
||||||
# and obviously wrong (timestamps would all show 1970).
|
# and obviously wrong (timestamps would all show 1970).
|
||||||
@@ -145,13 +224,17 @@ class Category:
|
|||||||
`title` is the original German title (with the Prüfungsteil
|
`title` is the original German title (with the Prüfungsteil
|
||||||
prefix); `short_title` has the prefix stripped (used in the deck
|
prefix); `short_title` has the prefix stripped (used in the deck
|
||||||
name and the slug); `questions` is the flat list of every question
|
name and the slug); `questions` is the flat list of every question
|
||||||
that lives anywhere under this category.
|
that lives anywhere under this category. When `subdeck_by_topic`
|
||||||
|
is set, the package is built with one sub-deck per first-level
|
||||||
|
catalog topic instead of a single flat deck (see
|
||||||
|
`build_apkg_for_category`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
short_title: str
|
short_title: str
|
||||||
slug: str
|
slug: str
|
||||||
questions: list
|
questions: list
|
||||||
|
subdeck_by_topic: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -234,12 +317,15 @@ def load_explanations(path):
|
|||||||
def _validate_explanation(key, value):
|
def _validate_explanation(key, value):
|
||||||
"""Raise `AnkiBuildError` if one explanation entry is malformed.
|
"""Raise `AnkiBuildError` if one explanation entry is malformed.
|
||||||
|
|
||||||
Strict shape check: the four documented fields are required, no
|
Strict shape check: the four documented fields are required and the
|
||||||
extras allowed, and integer fields reject `bool` (which would
|
optional `provenance` field is allowed (and, if present, must be a
|
||||||
pass `isinstance(..., int)` because `bool` subclasses `int` in
|
string from `ALLOWED_PROVENANCE`); any *other* extra field is
|
||||||
Python). Extras are rejected so a stray `"note"` or `"author"`
|
rejected, so a stray `"note"` or `"author"` field can't accumulate
|
||||||
field can't accumulate silently and drift from the documented
|
silently and drift from the documented schema in EXPLANATIONS.md.
|
||||||
schema in EXPLANATIONS.md.
|
Integer fields reject `bool` (which would pass `isinstance(..., int)`
|
||||||
|
because `bool` subclasses `int` in Python), and every malformed
|
||||||
|
value — including an unhashable `provenance` — surfaces as an
|
||||||
|
`AnkiBuildError`, never a raw `TypeError`.
|
||||||
"""
|
"""
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise AnkiBuildError(
|
raise AnkiBuildError(
|
||||||
@@ -250,11 +336,24 @@ def _validate_explanation(key, value):
|
|||||||
raise AnkiBuildError(
|
raise AnkiBuildError(
|
||||||
f"explanation {key!r} missing required fields: {missing}"
|
f"explanation {key!r} missing required fields: {missing}"
|
||||||
)
|
)
|
||||||
extra = sorted(set(value) - set(EXPLANATION_FIELDS))
|
extra = sorted(
|
||||||
|
set(value) - set(EXPLANATION_FIELDS) - set(OPTIONAL_EXPLANATION_FIELDS)
|
||||||
|
)
|
||||||
if extra:
|
if extra:
|
||||||
raise AnkiBuildError(
|
raise AnkiBuildError(
|
||||||
f"explanation {key!r}: unknown fields {extra}"
|
f"explanation {key!r}: unknown fields {extra}"
|
||||||
)
|
)
|
||||||
|
if "provenance" in value and (
|
||||||
|
not isinstance(value["provenance"], str)
|
||||||
|
or value["provenance"] not in ALLOWED_PROVENANCE
|
||||||
|
):
|
||||||
|
# The isinstance check must come first: an unhashable JSON value
|
||||||
|
# (a list or object) would make the `not in` set test raise a raw
|
||||||
|
# TypeError instead of the documented AnkiBuildError.
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"explanation {key!r}: provenance must be one of "
|
||||||
|
f"{sorted(ALLOWED_PROVENANCE)}"
|
||||||
|
)
|
||||||
if type(value["revision"]) is not int or value["revision"] < 1:
|
if type(value["revision"]) is not int or value["revision"] < 1:
|
||||||
raise AnkiBuildError(
|
raise AnkiBuildError(
|
||||||
f"explanation {key!r}: revision must be a positive integer"
|
f"explanation {key!r}: revision must be a positive integer"
|
||||||
@@ -355,7 +454,10 @@ def _split_by_class(title, short_title, questions):
|
|||||||
|
|
||||||
The short title uses Anki's `::` deck-hierarchy separator so the
|
The short title uses Anki's `::` deck-hierarchy separator so the
|
||||||
three decks render as children of a shared parent in Anki's deck
|
three decks render as children of a shared parent in Anki's deck
|
||||||
browser (e.g. `Amateurfunk::Technische Kenntnisse::N`).
|
browser (e.g. `Amateurfunk::Technische Kenntnisse::N`). The classes
|
||||||
|
in `TOPIC_SPLIT_CLASSES` (E, A) stay a single package each but are
|
||||||
|
built with one sub-deck per first-level topic — see
|
||||||
|
`build_apkg_for_category` and the `subdeck_by_topic` flag.
|
||||||
"""
|
"""
|
||||||
base_slug = slugify(short_title)
|
base_slug = slugify(short_title)
|
||||||
for digit, letter in CLASS_ORDER:
|
for digit, letter in CLASS_ORDER:
|
||||||
@@ -368,6 +470,7 @@ def _split_by_class(title, short_title, questions):
|
|||||||
short_title=f"{short_title}::{letter}",
|
short_title=f"{short_title}::{letter}",
|
||||||
slug=f"{base_slug}-{letter.lower()}",
|
slug=f"{base_slug}-{letter.lower()}",
|
||||||
questions=subset,
|
questions=subset,
|
||||||
|
subdeck_by_topic=(letter in TOPIC_SPLIT_CLASSES),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -603,11 +706,23 @@ def render_explanation(explanation):
|
|||||||
'title="This explanation is weakly sourced or incomplete">'
|
'title="This explanation is weakly sourced or incomplete">'
|
||||||
'low confidence</span>'
|
'low confidence</span>'
|
||||||
)
|
)
|
||||||
|
# CC BY 4.0 requires naming the author and indicating modifications.
|
||||||
|
# Only explanations actually derived from a 50ohm.de worked solution
|
||||||
|
# carry this credit — gated on the explicit `provenance` marker, NOT
|
||||||
|
# on the `source` URL (a citation is not provenance; many records
|
||||||
|
# merely cite a 50ohm study page without being a derivative work).
|
||||||
|
credit = ""
|
||||||
|
if explanation.get("provenance") == PROVENANCE_50OHM_SOLUTION:
|
||||||
|
credit = (
|
||||||
|
'<div class="af-explanation-credit">'
|
||||||
|
f'{EXPLANATION_CC_BY_CREDIT}</div>'
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
'<div class="af-explanation">'
|
'<div class="af-explanation">'
|
||||||
f'<div class="af-explanation-header">Explanation{badge}</div>'
|
f'<div class="af-explanation-header">Explanation{badge}</div>'
|
||||||
f'<div class="af-explanation-body">{body}</div>'
|
f'<div class="af-explanation-body">{body}</div>'
|
||||||
f'<div class="af-explanation-source">Source: {source_html}</div>'
|
f'<div class="af-explanation-source">Source: {source_html}</div>'
|
||||||
|
f'{credit}'
|
||||||
'</div>'
|
'</div>'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -739,6 +854,8 @@ class MediaRegistry:
|
|||||||
self.used_paths = set()
|
self.used_paths = set()
|
||||||
# references that didn't resolve (recorded for diagnostics)
|
# references that didn't resolve (recorded for diagnostics)
|
||||||
self.missing = []
|
self.missing = []
|
||||||
|
# Path → intrinsic (w, h) in px (memoised; None when unreadable)
|
||||||
|
self._size_cache = {}
|
||||||
self._index_media_dir()
|
self._index_media_dir()
|
||||||
|
|
||||||
def _index_media_dir(self):
|
def _index_media_dir(self):
|
||||||
@@ -780,12 +897,43 @@ class MediaRegistry:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def image_html(self, reference):
|
def image_html(self, reference):
|
||||||
"""Return an `<img>` tag for `reference`, or `""` if unresolved."""
|
"""Return an `<img>` tag for `reference`, or `""` if unresolved.
|
||||||
|
|
||||||
|
Most catalog SVGs carry small intrinsic dimensions, so on a wide
|
||||||
|
desktop card they render barely legible (a small fixed-px image
|
||||||
|
occupies only a sliver of the column; `max-width: 100%` only
|
||||||
|
ever shrinks, never enlarges). We raise the *preferred* width
|
||||||
|
via an explicit `width` attribute so the figure grows on every
|
||||||
|
viewport until the card's `max-width: 100%` cap engages.
|
||||||
|
|
||||||
|
Only SVGs are scaled — the catalog's PNGs are already large
|
||||||
|
(1024×685), so `_intrinsic_svg_size` returns `None` for them and
|
||||||
|
they fall through to native size. The enlargement factor is
|
||||||
|
`scale_for_size` — `MEDIA_SCALE`×, clamped at the
|
||||||
|
`MEDIA_MAX_WIDTH` × `MEDIA_MAX_HEIGHT` enlargement ceiling.
|
||||||
|
`height: auto` in the CSS keeps the aspect ratio. When the size
|
||||||
|
can't be read (non-SVG, or an unparseable SVG) we fall back to
|
||||||
|
native.
|
||||||
|
"""
|
||||||
path = self.resolve(reference)
|
path = self.resolve(reference)
|
||||||
if path is None:
|
if path is None:
|
||||||
return ""
|
return ""
|
||||||
filename = html.escape(path.name, quote=True)
|
filename = html.escape(path.name, quote=True)
|
||||||
return f'<img src="{filename}" class="af-media">'
|
size = self._intrinsic_svg_size(path)
|
||||||
|
if size is None:
|
||||||
|
return f'<img src="{filename}" class="af-media">'
|
||||||
|
width, height = size
|
||||||
|
scale = scale_for_size(width, height)
|
||||||
|
if scale <= 1.0:
|
||||||
|
return f'<img src="{filename}" class="af-media">'
|
||||||
|
scaled = round(width * scale)
|
||||||
|
return f'<img src="{filename}" class="af-media" width="{scaled}">'
|
||||||
|
|
||||||
|
def _intrinsic_svg_size(self, path):
|
||||||
|
"""Return the SVG's `(width, height)` in px (memoised; SVG only)."""
|
||||||
|
if path not in self._size_cache:
|
||||||
|
self._size_cache[path] = intrinsic_svg_size(path)
|
||||||
|
return self._size_cache[path]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -843,8 +991,12 @@ def build_apkg_for_category(
|
|||||||
|
|
||||||
media = MediaRegistry(edition_dir / "svgs")
|
media = MediaRegistry(edition_dir / "svgs")
|
||||||
|
|
||||||
deck_name = f"Amateurfunk::{category.short_title}"
|
# The category's own deck is always present (even empty, it anchors
|
||||||
deck_id = stable_id("deck", deck_name)
|
# the tree). Sub-decks discovered per note are added on first use,
|
||||||
|
# in catalog order, so the package's deck list is deterministic.
|
||||||
|
root_deck_name = f"Amateurfunk::{category.short_title}"
|
||||||
|
root_deck_id = stable_id("deck", root_deck_name)
|
||||||
|
decks = {root_deck_name: root_deck_id}
|
||||||
model_id = stable_id("model", "Amateurfunk Multiple Choice")
|
model_id = stable_id("model", "Amateurfunk Multiple Choice")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -858,11 +1010,14 @@ def build_apkg_for_category(
|
|||||||
number = str(question.get("number", f"q{ordinal}"))
|
number = str(question.get("number", f"q{ordinal}"))
|
||||||
if number in explanations:
|
if number in explanations:
|
||||||
applied_explanations += 1
|
applied_explanations += 1
|
||||||
|
deck_short = _deck_short_title_for_item(category, item)
|
||||||
|
deck_name = f"Amateurfunk::{deck_short}"
|
||||||
|
deck_id = decks.setdefault(deck_name, stable_id("deck", deck_name))
|
||||||
note_id = stable_id("note", f"{category.slug}:{number}")
|
note_id = stable_id("note", f"{category.slug}:{number}")
|
||||||
card_id = stable_id("card", f"{category.slug}:{number}")
|
card_id = stable_id("card", f"{category.slug}:{number}")
|
||||||
fields = [
|
fields = [
|
||||||
number,
|
number,
|
||||||
category.short_title,
|
deck_short,
|
||||||
display_path(item.path),
|
display_path(item.path),
|
||||||
front,
|
front,
|
||||||
back,
|
back,
|
||||||
@@ -871,6 +1026,7 @@ def build_apkg_for_category(
|
|||||||
notes.append({
|
notes.append({
|
||||||
"note_id": note_id,
|
"note_id": note_id,
|
||||||
"card_id": card_id,
|
"card_id": card_id,
|
||||||
|
"deck_id": deck_id,
|
||||||
"guid": stable_guid(f"{category.slug}:{number}"),
|
"guid": stable_guid(f"{category.slug}:{number}"),
|
||||||
"fields": FIELD_SEP.join(fields),
|
"fields": FIELD_SEP.join(fields),
|
||||||
"sort": number,
|
"sort": number,
|
||||||
@@ -882,8 +1038,8 @@ def build_apkg_for_category(
|
|||||||
|
|
||||||
create_collection_db(
|
create_collection_db(
|
||||||
db_path,
|
db_path,
|
||||||
deck_id=deck_id,
|
decks=decks,
|
||||||
deck_name=deck_name,
|
cur_deck_id=root_deck_id,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
now=build_epoch,
|
now=build_epoch,
|
||||||
@@ -896,7 +1052,8 @@ def build_apkg_for_category(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"path": out_path,
|
"path": out_path,
|
||||||
"deck": deck_name,
|
"deck": root_deck_name,
|
||||||
|
"subdecks": len(decks) - 1,
|
||||||
"questions": len(category.questions),
|
"questions": len(category.questions),
|
||||||
"media": len(media_paths),
|
"media": len(media_paths),
|
||||||
"missing_media": sorted(set(media.missing)),
|
"missing_media": sorted(set(media.missing)),
|
||||||
@@ -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):
|
def write_apkg(out_path, db_path, media_paths, build_epoch):
|
||||||
"""Write the Anki package ZIP atomically.
|
"""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")
|
return svg_with_white_background(text).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def scale_for_size(width, height):
|
||||||
|
"""Return the display scale for a figure of native `width`×`height`.
|
||||||
|
|
||||||
|
`MEDIA_SCALE`× for legibility, but never so much that the enlarged
|
||||||
|
figure would grow past the `MEDIA_MAX_WIDTH` / `MEDIA_MAX_HEIGHT`
|
||||||
|
ceiling, and never below 1.0 (we only ever enlarge, never shrink —
|
||||||
|
shrinking is the CSS `max-width`'s job). A figure already at/above
|
||||||
|
either cap yields exactly 1.0, i.e. "render at native size" — so a
|
||||||
|
native figure larger than the ceiling is left as-is, not shrunk.
|
||||||
|
"""
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
return 1.0
|
||||||
|
scale = min(
|
||||||
|
MEDIA_SCALE,
|
||||||
|
MEDIA_MAX_WIDTH / width,
|
||||||
|
MEDIA_MAX_HEIGHT / height,
|
||||||
|
)
|
||||||
|
return max(scale, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _svg_dimension(svg_tag, name):
|
||||||
|
"""Parse a `width`/`height` length (px or unitless) off an `<svg>` tag.
|
||||||
|
|
||||||
|
The numeric pattern is a well-formed decimal (`12`, `12.7`, `.5`) so
|
||||||
|
malformed values like `1.2.3` or a lone `.` simply don't match and
|
||||||
|
yield `None` rather than reaching `float()`. We still guard the
|
||||||
|
conversion with `try/except` so no parse can ever abort the build.
|
||||||
|
"""
|
||||||
|
attr = re.search(
|
||||||
|
rf'\b{name}\s*=\s*"(\d+(?:\.\d+)?|\.\d+)\s*(?:px)?"',
|
||||||
|
svg_tag,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if not attr:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(attr.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def intrinsic_svg_size(path):
|
||||||
|
"""Return an SVG's intrinsic `(width, height)` in CSS px, or `None`.
|
||||||
|
|
||||||
|
Read from the `width`/`height` attributes on the root `<svg>` tag
|
||||||
|
(the catalog's SVGs use unitless user units, which map 1:1 to px).
|
||||||
|
Only `.svg` files are handled — we deliberately don't size raster
|
||||||
|
figures, because the catalog's PNGs are already large and only the
|
||||||
|
SVGs need enlarging. Anything we can't parse confidently — a non-SVG
|
||||||
|
file, a percentage dimension, a missing attribute — returns `None`
|
||||||
|
so the caller falls back to the image's native size.
|
||||||
|
"""
|
||||||
|
if path.suffix.lower() != ".svg":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
text = path.read_bytes().decode("utf-8-sig", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
match = re.search(r"<svg\b[^>]*>", text, flags=re.IGNORECASE)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
width = _svg_dimension(match.group(0), "width")
|
||||||
|
height = _svg_dimension(match.group(0), "height")
|
||||||
|
if width is None or height is None:
|
||||||
|
return None
|
||||||
|
return (width, height)
|
||||||
|
|
||||||
|
|
||||||
def svg_with_white_background(svg_text):
|
def svg_with_white_background(svg_text):
|
||||||
"""Inject a white background `<rect>` after the opening `<svg>`.
|
"""Inject a white background `<rect>` after the opening `<svg>`.
|
||||||
|
|
||||||
@@ -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.
|
"""Create the `collection.anki2` SQLite database for one package.
|
||||||
|
|
||||||
Uses the v11 schema, which modern Anki still understands (it
|
Uses the v11 schema, which modern Anki still understands (it
|
||||||
upgrades the collection on first open). We pre-write a single
|
upgrades the collection on first open). We pre-write a single
|
||||||
`col` row with the JSON config blobs, then insert one `notes`
|
`col` row with the JSON config blobs, then insert one `notes`
|
||||||
row and one `cards` row per question.
|
row and one `cards` row per question.
|
||||||
|
|
||||||
|
`decks` maps every deck name in this package to its id; a package
|
||||||
|
is usually a single deck, but the per-topic Technische package
|
||||||
|
carries one entry per sub-deck plus the anchoring parent.
|
||||||
|
`cur_deck_id` is the deck selected on open. Each note carries its
|
||||||
|
own `deck_id`, so cards land in the right sub-deck.
|
||||||
"""
|
"""
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
try:
|
try:
|
||||||
create_schema(conn)
|
create_schema(conn)
|
||||||
insert_collection_metadata(
|
insert_collection_metadata(
|
||||||
conn,
|
conn,
|
||||||
deck_id=deck_id,
|
decks=decks,
|
||||||
deck_name=deck_name,
|
cur_deck_id=cur_deck_id,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
now=now,
|
now=now,
|
||||||
)
|
)
|
||||||
@@ -1062,7 +1308,7 @@ def create_collection_db(db_path, deck_id, deck_name, model_id, notes, now):
|
|||||||
(
|
(
|
||||||
note["card_id"],
|
note["card_id"],
|
||||||
note["note_id"],
|
note["note_id"],
|
||||||
deck_id,
|
note["deck_id"],
|
||||||
0,
|
0,
|
||||||
now,
|
now,
|
||||||
-1,
|
-1,
|
||||||
@@ -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.
|
"""Write the single `col` row that carries the JSON config blobs.
|
||||||
|
|
||||||
`crt` is a seconds-epoch creation time; `mod` and `scm` are in
|
`crt` is a seconds-epoch creation time; `mod` and `scm` are in
|
||||||
milliseconds (Anki's mixed convention, not ours to fix).
|
milliseconds (Anki's mixed convention, not ours to fix). `decks`
|
||||||
|
maps deck name → id; every entry is written to `col.decks` so a
|
||||||
|
package can ship a deck tree, not just one deck. `cur_deck_id` is
|
||||||
|
the deck made current in `col.conf`.
|
||||||
"""
|
"""
|
||||||
|
# The attribution notice goes on the root (current) deck only, so it
|
||||||
|
# shows once for the package rather than on every sub-deck.
|
||||||
|
decks_json = {
|
||||||
|
str(deck_id): deck_json(
|
||||||
|
deck_id,
|
||||||
|
deck_name,
|
||||||
|
now,
|
||||||
|
desc=DECK_ATTRIBUTION if deck_id == cur_deck_id else "",
|
||||||
|
)
|
||||||
|
for deck_name, deck_id in decks.items()
|
||||||
|
}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO col
|
INSERT INTO col
|
||||||
@@ -1184,15 +1444,12 @@ def insert_collection_metadata(conn, deck_id, deck_name, model_id, now):
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
json.dumps(collection_conf(deck_id), separators=(",", ":")),
|
json.dumps(collection_conf(cur_deck_id), separators=(",", ":")),
|
||||||
json.dumps(
|
json.dumps(
|
||||||
{str(model_id): model_json(model_id, now)},
|
{str(model_id): model_json(model_id, now)},
|
||||||
separators=(",", ":"),
|
separators=(",", ":"),
|
||||||
),
|
),
|
||||||
json.dumps(
|
json.dumps(decks_json, separators=(",", ":")),
|
||||||
{str(deck_id): deck_json(deck_id, deck_name, now)},
|
|
||||||
separators=(",", ":"),
|
|
||||||
),
|
|
||||||
json.dumps(default_deck_conf(now), separators=(",", ":")),
|
json.dumps(default_deck_conf(now), separators=(",", ":")),
|
||||||
"{}",
|
"{}",
|
||||||
),
|
),
|
||||||
@@ -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`.
|
"""Return one deck entry for `col.decks`.
|
||||||
|
|
||||||
The `Amateurfunk::Foo` naming gives the user a single top-level
|
The `Amateurfunk::Foo` naming gives the user a single top-level
|
||||||
Amateurfunk node in Anki's deck browser with three child decks.
|
Amateurfunk node in Anki's deck browser with three child decks.
|
||||||
|
`desc` is the deck description shown in Anki's deck overview; we use
|
||||||
|
it on the root deck to carry the source-attribution notice (see
|
||||||
|
DECK_ATTRIBUTION).
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"id": deck_id,
|
"id": deck_id,
|
||||||
@@ -1247,7 +1507,7 @@ def deck_json(deck_id, deck_name, now):
|
|||||||
"browserCollapsed": False,
|
"browserCollapsed": False,
|
||||||
"dyn": 0,
|
"dyn": 0,
|
||||||
"conf": 1,
|
"conf": 1,
|
||||||
"desc": "",
|
"desc": desc,
|
||||||
"extendNew": 0,
|
"extendNew": 0,
|
||||||
"extendRev": 0,
|
"extendRev": 0,
|
||||||
}
|
}
|
||||||
@@ -1452,6 +1712,17 @@ CARD_CSS = """
|
|||||||
color: #1a73e8;
|
color: #1a73e8;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.af-explanation-credit {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.af-explanation-credit a {
|
||||||
|
color: #1a73e8;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
.nightMode .af-explanation,
|
.nightMode .af-explanation,
|
||||||
.card.nightMode .af-explanation {
|
.card.nightMode .af-explanation {
|
||||||
border-top-color: #555;
|
border-top-color: #555;
|
||||||
@@ -1469,6 +1740,14 @@ CARD_CSS = """
|
|||||||
.card.nightMode .af-explanation-source a {
|
.card.nightMode .af-explanation-source a {
|
||||||
color: #8ab4f8;
|
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,
|
.nightMode .af-explanation-low-confidence,
|
||||||
.card.nightMode .af-explanation-low-confidence {
|
.card.nightMode .af-explanation-low-confidence {
|
||||||
background: #4a3510;
|
background: #4a3510;
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build an Anki deck of Q-groups and operating abbreviations.
|
||||||
|
|
||||||
|
This is a sibling of `amateurfunk_anki.py`. Where that script turns the
|
||||||
|
BNetzA question catalog into multiple-choice decks, this one builds a
|
||||||
|
single reference deck from a hand-curated database (`shorthand.json`):
|
||||||
|
the Q-groups and operating abbreviations a candidate meets in the exam,
|
||||||
|
plus the most common ones used on the air that the exam never mentions
|
||||||
|
(so the deck teaches real operating knowledge, not just the test).
|
||||||
|
|
||||||
|
Design follows the four rules the deck was specified with:
|
||||||
|
|
||||||
|
1. Stable IDs. Every deck/model/note/card id and note GUID is hashed
|
||||||
|
from the displayed code form, so re-importing updates in place
|
||||||
|
instead of duplicating.
|
||||||
|
2. Two cards per code. Each note carries two templates — one prompts
|
||||||
|
for the meaning given the code, the reverse prompts for the code
|
||||||
|
given the meaning. Modelling them as a single *note* (rule 4) is
|
||||||
|
what lets one record drive both directions without us emitting two
|
||||||
|
hand-built cards.
|
||||||
|
3. Q-groups get one note per usage. A Q-group means one thing as a
|
||||||
|
statement (`QSO`) and another as a question (`QSO?`), so each
|
||||||
|
yields two notes — and therefore two forward/reverse pairs.
|
||||||
|
Plain abbreviations have only one form and yield one note.
|
||||||
|
|
||||||
|
Everything is stdlib only. The heavy lifting (SQLite schema, the apkg
|
||||||
|
ZIP writer, the deterministic timestamps, the ID hashing) is shared
|
||||||
|
with `amateurfunk_anki` by import, so the two stay in lockstep.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Reuse the low-level Anki package machinery rather than copying it.
|
||||||
|
# These helpers are pure and model-agnostic; only the note type, the
|
||||||
|
# card templates, and the card-per-note fan-out are specific to this
|
||||||
|
# deck and are defined below.
|
||||||
|
from amateurfunk_anki import (
|
||||||
|
AnkiBuildError,
|
||||||
|
DEFAULT_BUILD_EPOCH,
|
||||||
|
FIELD_SEP,
|
||||||
|
build_epoch_from_manifest,
|
||||||
|
checksum_sort_field,
|
||||||
|
collection_conf,
|
||||||
|
create_schema,
|
||||||
|
deck_json,
|
||||||
|
default_deck_conf,
|
||||||
|
field_json,
|
||||||
|
load_latest_catalog,
|
||||||
|
slugify,
|
||||||
|
stable_guid,
|
||||||
|
stable_id,
|
||||||
|
text_html,
|
||||||
|
write_apkg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Constants
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Curated source database. Editorial content tracked in git, not a
|
||||||
|
# build artifact — same status as `explanations.json` in the sibling
|
||||||
|
# script.
|
||||||
|
DEFAULT_SHORTHAND_PATH = Path("shorthand.json")
|
||||||
|
|
||||||
|
# Default location of the fetcher's output. Only consulted to borrow a
|
||||||
|
# deterministic build epoch from the catalog manifest; the deck content
|
||||||
|
# itself does not depend on the catalog, so a missing data dir is not
|
||||||
|
# fatal here (unlike in `amateurfunk_anki`).
|
||||||
|
DEFAULT_DATA_DIR = Path("data")
|
||||||
|
|
||||||
|
# Default destination for the generated `.apkg` (shared with the
|
||||||
|
# multiple-choice decks).
|
||||||
|
DEFAULT_OUT_DIR = Path("anki")
|
||||||
|
|
||||||
|
# Human-facing deck name. The `Amateurfunk::` prefix nests it under the
|
||||||
|
# same top-level node as the catalog decks in Anki's browser.
|
||||||
|
DECK_NAME = "Amateurfunk::Abkürzungen & Q-Gruppen"
|
||||||
|
|
||||||
|
# Note-type name. Distinct from the multiple-choice model so importing
|
||||||
|
# both decks never merges the two note types.
|
||||||
|
MODEL_NAME = "Amateurfunk Q-Gruppe / Abkürzung"
|
||||||
|
|
||||||
|
# User-facing kind labels, shown on the card and used to build the
|
||||||
|
# `q-gruppe` / `abkuerzung` filter tags.
|
||||||
|
KIND_QGROUP = "Q-Gruppe"
|
||||||
|
KIND_ABBREV = "Abkürzung"
|
||||||
|
|
||||||
|
# Usage-form labels for Q-groups. A Q-group statement (`QSO`) and the
|
||||||
|
# matching question (`QSO?`) become two separate notes (rule 3).
|
||||||
|
FORM_STATEMENT = "Aussageform"
|
||||||
|
FORM_QUESTION = "Frageform"
|
||||||
|
|
||||||
|
# Exit codes (mirrors the sibling script's two-state convention).
|
||||||
|
EXIT_OK = 0
|
||||||
|
EXIT_ERROR = 1
|
||||||
|
|
||||||
|
# Fields allowed on a Q-group entry / an abbreviation entry in
|
||||||
|
# `shorthand.json`. Extras are rejected so a typo can't silently shift
|
||||||
|
# meaning onto a card that never renders it.
|
||||||
|
QGROUP_REQUIRED = ("code", "question", "statement")
|
||||||
|
QGROUP_OPTIONAL = ("exam", "explanation", "example", "tags")
|
||||||
|
ABBREV_REQUIRED = ("code", "meaning")
|
||||||
|
ABBREV_OPTIONAL = ("exam", "explanation", "example", "tags")
|
||||||
|
|
||||||
|
# Inline `` `code` `` spans in the curated examples become <code> tags.
|
||||||
|
BACKTICK_SPAN_RE = re.compile(r"`([^`]+)`")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Loading and validating the curated database
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def load_shorthand(path):
|
||||||
|
"""Return the validated `(q_codes, abbreviations)` from a JSON file.
|
||||||
|
|
||||||
|
The file is editorial content with a strict shape (see the field
|
||||||
|
allow-lists above). Unlike the explanations database in the sibling
|
||||||
|
script, this file is mandatory: there is no deck without it, so a
|
||||||
|
missing or malformed file is a hard error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text("utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"could not read shorthand file {path}: {e}"
|
||||||
|
) from e
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"shorthand file {path} must contain a JSON object at the top level"
|
||||||
|
)
|
||||||
|
q_codes = raw.get("q_codes", [])
|
||||||
|
abbreviations = raw.get("abbreviations", [])
|
||||||
|
if not isinstance(q_codes, list) or not isinstance(abbreviations, list):
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"shorthand file {path}: 'q_codes' and 'abbreviations' must be lists"
|
||||||
|
)
|
||||||
|
for entry in q_codes:
|
||||||
|
validate_entry(entry, QGROUP_REQUIRED, QGROUP_OPTIONAL, "q_codes")
|
||||||
|
for entry in abbreviations:
|
||||||
|
validate_entry(entry, ABBREV_REQUIRED, ABBREV_OPTIONAL, "abbreviations")
|
||||||
|
_check_no_duplicate_codes(q_codes, abbreviations)
|
||||||
|
return q_codes, abbreviations
|
||||||
|
|
||||||
|
|
||||||
|
def validate_entry(entry, required, optional, where):
|
||||||
|
"""Raise `AnkiBuildError` if one database entry is malformed."""
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise AnkiBuildError(f"{where}: every entry must be a JSON object")
|
||||||
|
label = entry.get("code", "<no code>")
|
||||||
|
missing = [f for f in required if f not in entry]
|
||||||
|
if missing:
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"{where} entry {label!r} missing required fields: {missing}"
|
||||||
|
)
|
||||||
|
extra = sorted(set(entry) - set(required) - set(optional))
|
||||||
|
if extra:
|
||||||
|
raise AnkiBuildError(f"{where} entry {label!r}: unknown fields {extra}")
|
||||||
|
for field in required + ("explanation", "example"):
|
||||||
|
if field in entry and (
|
||||||
|
not isinstance(entry[field], str) or not entry[field].strip()
|
||||||
|
):
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"{where} entry {label!r}: {field!r} must be a non-empty string"
|
||||||
|
)
|
||||||
|
if "exam" in entry and not isinstance(entry["exam"], bool):
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"{where} entry {label!r}: 'exam' must be a boolean"
|
||||||
|
)
|
||||||
|
if "tags" in entry:
|
||||||
|
tags = entry["tags"]
|
||||||
|
if not isinstance(tags, list) or not all(
|
||||||
|
isinstance(t, str) and t.strip() for t in tags
|
||||||
|
):
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"{where} entry {label!r}: 'tags' must be a list of non-empty strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_no_duplicate_codes(q_codes, abbreviations):
|
||||||
|
"""Raise if any displayed code form would collide.
|
||||||
|
|
||||||
|
Displayed forms are the per-note keys that every stable ID and GUID
|
||||||
|
is hashed from, so a duplicate would silently overwrite another
|
||||||
|
note on import. Q-groups expand to `CODE` and `CODE?`; abbreviations
|
||||||
|
stay as `CODE`.
|
||||||
|
"""
|
||||||
|
seen = {}
|
||||||
|
forms = [entry["code"] for entry in q_codes]
|
||||||
|
forms += [entry["code"] + "?" for entry in q_codes]
|
||||||
|
forms += [entry["code"] for entry in abbreviations]
|
||||||
|
duplicates = sorted({form for form in forms if forms.count(form) > 1})
|
||||||
|
if duplicates:
|
||||||
|
raise AnkiBuildError(
|
||||||
|
"duplicate code form(s) in shorthand database: "
|
||||||
|
+ ", ".join(duplicates)
|
||||||
|
)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Note expansion
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_notes(q_codes, abbreviations):
|
||||||
|
"""Expand the curated entries into the flat list of notes to emit.
|
||||||
|
|
||||||
|
Each Q-group becomes two notes (statement form `QSO`, question form
|
||||||
|
`QSO?`); each abbreviation becomes one. Order is stable: Q-groups in
|
||||||
|
file order first (statement then question for each), abbreviations
|
||||||
|
after, so the deck's `due` ordering is deterministic.
|
||||||
|
"""
|
||||||
|
notes = []
|
||||||
|
for entry in q_codes:
|
||||||
|
notes.append(
|
||||||
|
_note_record(
|
||||||
|
code_display=entry["code"],
|
||||||
|
meaning=entry["statement"],
|
||||||
|
kind=KIND_QGROUP,
|
||||||
|
form=FORM_STATEMENT,
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notes.append(
|
||||||
|
_note_record(
|
||||||
|
code_display=entry["code"] + "?",
|
||||||
|
meaning=entry["question"],
|
||||||
|
kind=KIND_QGROUP,
|
||||||
|
form=FORM_QUESTION,
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for entry in abbreviations:
|
||||||
|
notes.append(
|
||||||
|
_note_record(
|
||||||
|
code_display=entry["code"],
|
||||||
|
meaning=entry["meaning"],
|
||||||
|
kind=KIND_ABBREV,
|
||||||
|
form="",
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def make_note(code_display, meaning, kind, form, explanation, example, tags, namespace):
|
||||||
|
"""Render one two-card note (forward + reverse) for a glossary deck.
|
||||||
|
|
||||||
|
Shared by every glossary-style deck (operating shorthand here, the
|
||||||
|
technical-abbreviation deck in `amateurfunk_technical.py`). All
|
||||||
|
identity (`note_id`, `guid`, and the per-template card ids) is
|
||||||
|
hashed from `code_display` under `namespace`, so the two decks get
|
||||||
|
independent, stable IDs that never collide on import. The fields are
|
||||||
|
stored pre-escaped; the card templates only lay them out.
|
||||||
|
"""
|
||||||
|
fields = [
|
||||||
|
html.escape(code_display),
|
||||||
|
text_html(meaning),
|
||||||
|
kind,
|
||||||
|
form,
|
||||||
|
text_html(explanation) if explanation else "",
|
||||||
|
render_example(example) if example else "",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"note_id": stable_id(f"{namespace}-note", code_display),
|
||||||
|
"guid": stable_guid(f"{namespace}:{code_display}"),
|
||||||
|
"fields": FIELD_SEP.join(fields),
|
||||||
|
"sort": code_display,
|
||||||
|
"tags": tags,
|
||||||
|
"card_ids": [
|
||||||
|
stable_id(f"{namespace}-card", f"{code_display}:{ord_}")
|
||||||
|
for ord_ in range(2)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _note_record(code_display, meaning, kind, form, entry):
|
||||||
|
"""Build one operating-deck note under the `shorthand` ID namespace.
|
||||||
|
|
||||||
|
`code_display` is unique across the database (enforced by
|
||||||
|
`_check_no_duplicate_codes`), so it's a safe identity key.
|
||||||
|
"""
|
||||||
|
return make_note(
|
||||||
|
code_display,
|
||||||
|
meaning,
|
||||||
|
kind,
|
||||||
|
form,
|
||||||
|
entry.get("explanation"),
|
||||||
|
entry.get("example"),
|
||||||
|
tags_for_entry(kind, entry),
|
||||||
|
namespace="shorthand",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_example(example):
|
||||||
|
"""Render an example string: HTML-escape, then `code` → <code>.
|
||||||
|
|
||||||
|
The curated examples use Markdown-style backticks around the bits
|
||||||
|
that are literally keyed on the air (`QRL?`, `de DL1ABC k`). We
|
||||||
|
escape first via `text_html` (so the surrounding prose is safe and
|
||||||
|
newlines survive) and only then turn backtick spans into <code>.
|
||||||
|
"""
|
||||||
|
return BACKTICK_SPAN_RE.sub(
|
||||||
|
lambda m: f"<code>{m.group(1)}</code>", text_html(example)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tags_for_entry(kind, entry):
|
||||||
|
"""Return the Anki tags field (space-padded) for one note.
|
||||||
|
|
||||||
|
A `q-gruppe`/`abkuerzung` kind tag, a `pruefung` tag when the code
|
||||||
|
appears in the exam catalog, and any extra editorial tags (e.g.
|
||||||
|
`prosign`, `notsignal`) carried on the entry.
|
||||||
|
"""
|
||||||
|
tags = ["q-gruppe" if kind == KIND_QGROUP else "abkuerzung"]
|
||||||
|
if entry.get("exam"):
|
||||||
|
tags.append("pruefung")
|
||||||
|
tags.extend(slugify(tag) for tag in entry.get("tags", []))
|
||||||
|
return " " + " ".join(tags) + " "
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Anki note type (two templates: forward + reverse)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def model_json(model_id, model_name, now):
|
||||||
|
"""Return the note-type entry for `col.models`.
|
||||||
|
|
||||||
|
Five fields and two templates. `Bedeutung` prompts for the meaning
|
||||||
|
given the code; `Kürzel` is the reverse. Modelling both directions
|
||||||
|
on one note type (rather than two single-template notes) is what
|
||||||
|
rule 4 asks for: one record, two cards. `req` records which field
|
||||||
|
each template needs so Anki never generates an empty card. The
|
||||||
|
technical deck reuses this same shape under its own `model_name`.
|
||||||
|
"""
|
||||||
|
fields = [
|
||||||
|
field_json("Code", 0),
|
||||||
|
field_json("Meaning", 1),
|
||||||
|
field_json("Kind", 2),
|
||||||
|
field_json("Form", 3),
|
||||||
|
field_json("Explanation", 4),
|
||||||
|
field_json("Example", 5),
|
||||||
|
]
|
||||||
|
extras = (
|
||||||
|
"{{#Explanation}}<div class=\"af-sh-explanation\">"
|
||||||
|
"{{Explanation}}</div>{{/Explanation}}\n"
|
||||||
|
"{{#Example}}<div class=\"af-sh-example\">{{Example}}</div>{{/Example}}"
|
||||||
|
)
|
||||||
|
front_meta = (
|
||||||
|
'<div class="af-sh-kind">{{Kind}}{{#Form}} · {{Form}}{{/Form}}</div>'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": model_id,
|
||||||
|
"name": model_name,
|
||||||
|
"type": 0,
|
||||||
|
"mod": now,
|
||||||
|
"usn": -1,
|
||||||
|
"sortf": 0,
|
||||||
|
"did": None,
|
||||||
|
"tmpls": [
|
||||||
|
{
|
||||||
|
"name": "Bedeutung",
|
||||||
|
"ord": 0,
|
||||||
|
"qfmt": (
|
||||||
|
'<div class="af-sh">' + front_meta
|
||||||
|
+ '<div class="af-sh-code">{{Code}}</div>'
|
||||||
|
+ '<div class="af-sh-prompt">Bedeutung?</div></div>'
|
||||||
|
),
|
||||||
|
"afmt": (
|
||||||
|
"{{FrontSide}}\n<hr id=answer>\n"
|
||||||
|
'<div class="af-sh-meaning">{{Meaning}}</div>\n' + extras
|
||||||
|
),
|
||||||
|
"did": None,
|
||||||
|
"bqfmt": "",
|
||||||
|
"bafmt": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kürzel",
|
||||||
|
"ord": 1,
|
||||||
|
"qfmt": (
|
||||||
|
'<div class="af-sh">' + front_meta
|
||||||
|
+ '<div class="af-sh-meaning">{{Meaning}}</div>'
|
||||||
|
+ '<div class="af-sh-prompt">Kürzel?</div></div>'
|
||||||
|
),
|
||||||
|
"afmt": (
|
||||||
|
"{{FrontSide}}\n<hr id=answer>\n"
|
||||||
|
'<div class="af-sh-code">{{Code}}</div>\n' + extras
|
||||||
|
),
|
||||||
|
"did": None,
|
||||||
|
"bqfmt": "",
|
||||||
|
"bafmt": "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"flds": fields,
|
||||||
|
"css": CARD_CSS,
|
||||||
|
"latexPre": "",
|
||||||
|
"latexPost": "",
|
||||||
|
# Template 0 needs Code (field 0); template 1 needs Meaning (1).
|
||||||
|
"req": [[0, "all", [0]], [1, "all", [1]]],
|
||||||
|
"vers": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SQLite collection writer (two cards per note)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def create_collection_db(db_path, deck_id, deck_name, model_id, model_name, notes, now):
|
||||||
|
"""Create the `collection.anki2` database for a glossary deck.
|
||||||
|
|
||||||
|
Same v11 schema as the sibling script, but each note fans out into
|
||||||
|
two cards (one per template `ord`). `due` increments across every
|
||||||
|
card so the deck has a stable introduction order. `deck_name` and
|
||||||
|
`model_name` are passed through so the technical deck can reuse this
|
||||||
|
with its own names.
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
create_schema(conn)
|
||||||
|
_insert_collection_metadata(
|
||||||
|
conn, deck_id, deck_name, model_id, model_name, now,
|
||||||
|
)
|
||||||
|
due = 0
|
||||||
|
for note in notes:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO notes
|
||||||
|
(id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
note["note_id"],
|
||||||
|
note["guid"],
|
||||||
|
model_id,
|
||||||
|
now,
|
||||||
|
-1,
|
||||||
|
note["tags"],
|
||||||
|
note["fields"],
|
||||||
|
note["sort"],
|
||||||
|
checksum_sort_field(note["sort"]),
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for ord_, card_id in enumerate(note["card_ids"]):
|
||||||
|
due += 1
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cards
|
||||||
|
(id, nid, did, ord, mod, usn, type, queue, due, ivl,
|
||||||
|
factor, reps, lapses, left, odue, odid, flags, data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
card_id,
|
||||||
|
note["note_id"],
|
||||||
|
deck_id,
|
||||||
|
ord_,
|
||||||
|
now,
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
due,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_collection_metadata(conn, deck_id, deck_name, model_id, model_name, now):
|
||||||
|
"""Write the single `col` row carrying the JSON config blobs.
|
||||||
|
|
||||||
|
A trimmed copy of the sibling script's metadata writer, differing
|
||||||
|
only in the model (two templates) and the single deck.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO col
|
||||||
|
(id, crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
now,
|
||||||
|
now * 1000,
|
||||||
|
now * 1000,
|
||||||
|
11,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
json.dumps(collection_conf(deck_id), separators=(",", ":")),
|
||||||
|
json.dumps(
|
||||||
|
{str(model_id): model_json(model_id, model_name, now)},
|
||||||
|
separators=(",", ":"),
|
||||||
|
),
|
||||||
|
json.dumps(
|
||||||
|
{str(deck_id): deck_json(deck_id, deck_name, now)},
|
||||||
|
separators=(",", ":"),
|
||||||
|
),
|
||||||
|
json.dumps(default_deck_conf(now), separators=(",", ":")),
|
||||||
|
"{}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Card styling
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
CARD_CSS = """
|
||||||
|
.card {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #111;
|
||||||
|
background: #fff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.af-sh-kind {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #777;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.af-sh-code {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
color: #0b3a6b;
|
||||||
|
}
|
||||||
|
.af-sh-meaning {
|
||||||
|
font-size: 22px;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.af-sh-prompt {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.af-sh-explanation {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.af-sh-example {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.af-sh-example code,
|
||||||
|
.af-sh-meaning code {
|
||||||
|
font-family: "Courier New", monospace;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.nightMode .af-sh-code,
|
||||||
|
.card.nightMode .af-sh-code {
|
||||||
|
color: #8ab4f8;
|
||||||
|
}
|
||||||
|
.nightMode .af-sh-explanation,
|
||||||
|
.card.nightMode .af-sh-explanation {
|
||||||
|
border-top-color: #555;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.nightMode .af-sh-example,
|
||||||
|
.card.nightMode .af-sh-example {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
.nightMode .af-sh-example code,
|
||||||
|
.nightMode .af-sh-meaning code,
|
||||||
|
.card.nightMode .af-sh-example code,
|
||||||
|
.card.nightMode .af-sh-meaning code {
|
||||||
|
background: #333;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Build orchestration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_build_epoch(data_dir, override_epoch):
|
||||||
|
"""Pick the package timestamp epoch.
|
||||||
|
|
||||||
|
Prefers `--epoch`, then the catalog manifest's `fetched_at` (so a
|
||||||
|
`make all` build stamps the shorthand deck consistently with the
|
||||||
|
catalog decks), and finally falls back to `DEFAULT_BUILD_EPOCH`.
|
||||||
|
The deck content is catalog-independent, so an absent data dir just
|
||||||
|
means "no manifest epoch available" rather than an error.
|
||||||
|
"""
|
||||||
|
if override_epoch is not None:
|
||||||
|
return int(override_epoch)
|
||||||
|
try:
|
||||||
|
_edition_dir, manifest, _catalog = load_latest_catalog(data_dir)
|
||||||
|
except AnkiBuildError:
|
||||||
|
return DEFAULT_BUILD_EPOCH
|
||||||
|
return build_epoch_from_manifest(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def write_glossary_deck(notes, deck_name, model_name, out_dir, build_epoch):
|
||||||
|
"""Write `notes` as one two-template `.apkg` and return its path.
|
||||||
|
|
||||||
|
Shared by both glossary decks (operating shorthand here, technical
|
||||||
|
abbreviations in `amateurfunk_technical.py`). The deck carries no
|
||||||
|
media — these cards are pure text. The output filename is derived
|
||||||
|
from the leaf of `deck_name` (`Amateurfunk::Foo` → `amateurfunk-foo`).
|
||||||
|
"""
|
||||||
|
slug = slugify(deck_name.split("::")[-1])
|
||||||
|
out_path = out_dir / f"amateurfunk-{slug}.apkg"
|
||||||
|
deck_id = stable_id("deck", deck_name)
|
||||||
|
model_id = stable_id("model", model_name)
|
||||||
|
|
||||||
|
tmp_dir = out_path.parent / f".{out_path.name}.tmp"
|
||||||
|
db_path = tmp_dir / "collection.anki2"
|
||||||
|
if tmp_dir.exists():
|
||||||
|
shutil.rmtree(tmp_dir)
|
||||||
|
tmp_dir.mkdir(parents=True)
|
||||||
|
try:
|
||||||
|
create_collection_db(
|
||||||
|
db_path, deck_id, deck_name, model_id, model_name, notes, build_epoch,
|
||||||
|
)
|
||||||
|
write_apkg(out_path, db_path, {}, build_epoch=build_epoch)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
def build_deck(shorthand_path, out_dir, data_dir, override_epoch=None):
|
||||||
|
"""Build the shorthand `.apkg` and return a small result dict."""
|
||||||
|
q_codes, abbreviations = load_shorthand(shorthand_path)
|
||||||
|
notes = build_notes(q_codes, abbreviations)
|
||||||
|
if not notes:
|
||||||
|
raise AnkiBuildError("shorthand database produced no notes")
|
||||||
|
build_epoch = resolve_build_epoch(data_dir, override_epoch)
|
||||||
|
out_path = write_glossary_deck(
|
||||||
|
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"path": out_path,
|
||||||
|
"deck": DECK_NAME,
|
||||||
|
"notes": len(notes),
|
||||||
|
"cards": sum(len(note["card_ids"]) for note in notes),
|
||||||
|
"q_codes": len(q_codes),
|
||||||
|
"abbreviations": len(abbreviations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main entry point
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args(argv):
|
||||||
|
"""Build the argparse object and parse `argv`."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Build an Anki deck of amateur-radio Q-groups and operating "
|
||||||
|
"abbreviations from shorthand.json."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--shorthand",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_SHORTHAND_PATH,
|
||||||
|
help="curated source database (default: ./shorthand.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_OUT_DIR,
|
||||||
|
help="output directory for the .apkg file (default: ./anki)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--data",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_DATA_DIR,
|
||||||
|
help=(
|
||||||
|
"fetch output dir; only used to borrow a deterministic build "
|
||||||
|
"epoch from the catalog manifest (default: ./data)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--epoch",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="override the package timestamp epoch (default: from manifest)",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
"""Top-level entry point. Returns an exit code; never raises."""
|
||||||
|
args = _parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = build_deck(
|
||||||
|
args.shorthand,
|
||||||
|
args.out,
|
||||||
|
args.data,
|
||||||
|
override_epoch=args.epoch,
|
||||||
|
)
|
||||||
|
except AnkiBuildError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return EXIT_ERROR
|
||||||
|
except Exception as e: # noqa: BLE001 (main() promises not to raise)
|
||||||
|
print(f"error: failed to build shorthand deck: {e}", file=sys.stderr)
|
||||||
|
return EXIT_ERROR
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"wrote {result['path']} "
|
||||||
|
f"({result['notes']} notes, {result['cards']} cards: "
|
||||||
|
f"{result['q_codes']} Q-groups, {result['abbreviations']} abbreviations)"
|
||||||
|
)
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build an Anki deck of technical and HAM abbreviations.
|
||||||
|
|
||||||
|
A third glossary deck, sibling to `amateurfunk_shorthand.py`. Where that
|
||||||
|
script covers *operating* shorthand (Q-groups, CQ, 73), this one covers
|
||||||
|
the *technical* vocabulary: modulation and modes (SSB, FM, CW), signal
|
||||||
|
domains (NF, HF, ZF), building blocks (VFO, PLL, AGC), components,
|
||||||
|
measurements (dB, SWR, PEP), propagation, digital modes, and the
|
||||||
|
organisations/regulations a candidate meets — the exam terms plus the
|
||||||
|
common HAM abbreviations beyond it (real knowledge, not just the test).
|
||||||
|
|
||||||
|
The card mechanics are identical to the operating deck — one note per
|
||||||
|
abbreviation with two templates (code → meaning and the reverse) — so
|
||||||
|
this script is deliberately thin: it loads and validates
|
||||||
|
`technical.json`, turns each entry into a note, and hands the rest to
|
||||||
|
the shared glossary machinery in `amateurfunk_shorthand`. Each entry
|
||||||
|
also carries a German `category` (Betriebsart, Bauteil, …) shown on the
|
||||||
|
card and used as a filter tag.
|
||||||
|
|
||||||
|
Stdlib only, like its siblings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from amateurfunk_anki import AnkiBuildError, slugify
|
||||||
|
|
||||||
|
# The glossary deck machinery is shared with the operating-shorthand
|
||||||
|
# script: the two-template note type, the two-cards-per-note collection
|
||||||
|
# writer, the deck packager, the build-epoch resolver, and the per-entry
|
||||||
|
# schema validator. Only the data shape, the deck/model names, and the
|
||||||
|
# tag scheme are specific to the technical deck.
|
||||||
|
from amateurfunk_shorthand import (
|
||||||
|
make_note,
|
||||||
|
resolve_build_epoch,
|
||||||
|
validate_entry,
|
||||||
|
write_glossary_deck,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Constants
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Curated source database (editorial content tracked in git).
|
||||||
|
DEFAULT_TECHNICAL_PATH = Path("technical.json")
|
||||||
|
|
||||||
|
# Catalog output dir, consulted only to borrow a deterministic build
|
||||||
|
# epoch from the manifest (the deck content is catalog-independent).
|
||||||
|
DEFAULT_DATA_DIR = Path("data")
|
||||||
|
|
||||||
|
# Shared output dir for every .apkg.
|
||||||
|
DEFAULT_OUT_DIR = Path("anki")
|
||||||
|
|
||||||
|
# Deck and note-type names. Both distinct from the operating deck so the
|
||||||
|
# two never merge on import, and the IDs live in their own namespace.
|
||||||
|
DECK_NAME = "Amateurfunk::Technische Abkürzungen"
|
||||||
|
MODEL_NAME = "Amateurfunk Technische Abkürzung"
|
||||||
|
|
||||||
|
# ID/GUID namespace for this deck (kept separate from the operating
|
||||||
|
# deck's "shorthand" namespace).
|
||||||
|
ID_NAMESPACE = "technical"
|
||||||
|
|
||||||
|
# Schema for one entry in `technical.json`. `category` is the German
|
||||||
|
# kind label shown on the card; it is required so nothing ships
|
||||||
|
# uncategorised.
|
||||||
|
TERM_REQUIRED = ("code", "category", "meaning")
|
||||||
|
TERM_OPTIONAL = ("exam", "explanation", "example", "tags")
|
||||||
|
|
||||||
|
EXIT_OK = 0
|
||||||
|
EXIT_ERROR = 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Loading and validating the curated database
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def load_technical(path):
|
||||||
|
"""Return the validated list of term entries from a JSON file.
|
||||||
|
|
||||||
|
Mandatory file with a strict shape (same contract as the operating
|
||||||
|
deck): a missing or malformed file is a hard error, and unknown
|
||||||
|
fields are rejected so a typo can't silently drop content.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text("utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
raise AnkiBuildError(f"could not read technical file {path}: {e}") from e
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise AnkiBuildError(
|
||||||
|
f"technical file {path} must contain a JSON object at the top level"
|
||||||
|
)
|
||||||
|
terms = data.get("terms", [])
|
||||||
|
if not isinstance(terms, list):
|
||||||
|
raise AnkiBuildError(f"technical file {path}: 'terms' must be a list")
|
||||||
|
for entry in terms:
|
||||||
|
validate_entry(entry, TERM_REQUIRED, TERM_OPTIONAL, "terms")
|
||||||
|
_check_no_duplicate_codes(terms)
|
||||||
|
return terms
|
||||||
|
|
||||||
|
|
||||||
|
def _check_no_duplicate_codes(terms):
|
||||||
|
"""Raise if two terms share a code — that would overwrite a note.
|
||||||
|
|
||||||
|
Every stable ID and GUID is hashed from the code, so a duplicate
|
||||||
|
would silently clobber the first note on import.
|
||||||
|
"""
|
||||||
|
codes = [entry["code"] for entry in terms]
|
||||||
|
duplicates = sorted({code for code in codes if codes.count(code) > 1})
|
||||||
|
if duplicates:
|
||||||
|
raise AnkiBuildError(
|
||||||
|
"duplicate code(s) in technical database: " + ", ".join(duplicates)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Note expansion
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_notes(terms):
|
||||||
|
"""Turn each term into one two-card note (forward + reverse).
|
||||||
|
|
||||||
|
The German `category` becomes the note's `Kind` (shown on the card);
|
||||||
|
there is no usage form, so `Form` is empty. Identity is hashed from
|
||||||
|
the code under this deck's own ID namespace.
|
||||||
|
"""
|
||||||
|
notes = []
|
||||||
|
for entry in terms:
|
||||||
|
notes.append(
|
||||||
|
make_note(
|
||||||
|
code_display=entry["code"],
|
||||||
|
meaning=entry["meaning"],
|
||||||
|
kind=entry["category"],
|
||||||
|
form="",
|
||||||
|
explanation=entry.get("explanation"),
|
||||||
|
example=entry.get("example"),
|
||||||
|
tags=tags_for_term(entry),
|
||||||
|
namespace=ID_NAMESPACE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def tags_for_term(entry):
|
||||||
|
"""Return the Anki tags field (space-padded) for one term.
|
||||||
|
|
||||||
|
A base `technik` tag, a `kategorie-<slug>` tag from the German
|
||||||
|
category, a `pruefung` tag when the term appears in the exam
|
||||||
|
catalog, and any extra editorial tags carried on the entry.
|
||||||
|
"""
|
||||||
|
tags = ["technik", f"kategorie-{slugify(entry['category'])}"]
|
||||||
|
if entry.get("exam"):
|
||||||
|
tags.append("pruefung")
|
||||||
|
tags.extend(slugify(tag) for tag in entry.get("tags", []))
|
||||||
|
return " " + " ".join(tags) + " "
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Build orchestration
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def build_deck(technical_path, out_dir, data_dir, override_epoch=None):
|
||||||
|
"""Build the technical `.apkg` and return a small result dict."""
|
||||||
|
terms = load_technical(technical_path)
|
||||||
|
notes = build_notes(terms)
|
||||||
|
if not notes:
|
||||||
|
raise AnkiBuildError("technical database produced no notes")
|
||||||
|
build_epoch = resolve_build_epoch(data_dir, override_epoch)
|
||||||
|
out_path = write_glossary_deck(
|
||||||
|
notes, DECK_NAME, MODEL_NAME, out_dir, build_epoch,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"path": out_path,
|
||||||
|
"deck": DECK_NAME,
|
||||||
|
"notes": len(notes),
|
||||||
|
"cards": sum(len(note["card_ids"]) for note in notes),
|
||||||
|
"terms": len(terms),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Main entry point
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args(argv):
|
||||||
|
"""Build the argparse object and parse `argv`."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Build an Anki deck of amateur-radio technical and HAM "
|
||||||
|
"abbreviations from technical.json."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--technical",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_TECHNICAL_PATH,
|
||||||
|
help="curated source database (default: ./technical.json)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--out",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_OUT_DIR,
|
||||||
|
help="output directory for the .apkg file (default: ./anki)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--data",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_DATA_DIR,
|
||||||
|
help=(
|
||||||
|
"fetch output dir; only used to borrow a deterministic build "
|
||||||
|
"epoch from the catalog manifest (default: ./data)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--epoch",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="override the package timestamp epoch (default: from manifest)",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
"""Top-level entry point. Returns an exit code; never raises."""
|
||||||
|
args = _parse_args(argv)
|
||||||
|
try:
|
||||||
|
result = build_deck(
|
||||||
|
args.technical,
|
||||||
|
args.out,
|
||||||
|
args.data,
|
||||||
|
override_epoch=args.epoch,
|
||||||
|
)
|
||||||
|
except AnkiBuildError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return EXIT_ERROR
|
||||||
|
except Exception as e: # noqa: BLE001 (main() promises not to raise)
|
||||||
|
print(f"error: failed to build technical deck: {e}", file=sys.stderr)
|
||||||
|
return EXIT_ERROR
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"wrote {result['path']} "
|
||||||
|
f"({result['notes']} notes, {result['cards']} cards: "
|
||||||
|
f"{result['terms']} technical abbreviations)"
|
||||||
|
)
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+4313
-4143
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
|||||||
|
# Hilfsmittel — official exam aid (Formelsammlung + tables)
|
||||||
|
|
||||||
|
Catalog of everything in the BNetzA exam aid sheet that candidates are
|
||||||
|
**allowed to use during the exam**, with the **printed page numbers** as
|
||||||
|
they appear on each page. This file is the source of truth for the
|
||||||
|
`<u>Hilfsmittel:</u>` notes appended to explanations (see
|
||||||
|
`EXPLANATIONS.md`): a note may only cite a formula/table that is listed
|
||||||
|
here, and should name it and its page.
|
||||||
|
|
||||||
|
- Source PDF: `Hilfsmittel_12062024.pdf`
|
||||||
|
(https://www.bundesnetzagentur.de/SharedDocs/Downloads/DE/Sachgebiete/Telekommunikation/Unternehmen_Institutionen/Frequenzen/Amateurfunk/AntraegeFormulare/Hilfsmittel_12062024.pdf?__blob=publicationFile&v=3)
|
||||||
|
- **Page numbering:** the printed page number = PDF page − 2 (the cover
|
||||||
|
and the "Hinweis" page are unnumbered). All "S. N" below are the
|
||||||
|
printed numbers.
|
||||||
|
- Errata (per the PDF "Hinweis" page): on S. 15 the parabolic-dish gain
|
||||||
|
formula, on S. 17 the SWR formula, and on S. 21 the specific-resistance
|
||||||
|
table (Zink → Zinn) were corrected against the 3rd-edition catalog.
|
||||||
|
|
||||||
|
If the fact a question needs is **not** in this list (e.g. diode forward
|
||||||
|
voltages ~0.6 V / ~0.3 V, tan δ = 1/Q, semiconductor behaviour,
|
||||||
|
definitions), it is a memory item — **do not** add a Hilfsmittel note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S. 1–3 — Frequenzbereichszuweisung (Anlage 1, Amateurfunkverordnung)
|
||||||
|
|
||||||
|
- **S. 1 — Nutzungsbedingungen (¶1–4):** general usage rules (incl. the
|
||||||
|
**50 W ERP** cap for remote/automatic terrestrial stations above 30 MHz,
|
||||||
|
with a possible **1000 W ERP** exception for links) and, in ¶3, the
|
||||||
|
**definitions** of a *primärer* and *sekundärer Funkdienst* and their
|
||||||
|
protection/priority rights. This is a *text* page, not a table. (No
|
||||||
|
750 W figure appears here — 750 W PEP is a class-A limit in the S. 2
|
||||||
|
table.)
|
||||||
|
- **S. 2 — Tabellarische Übersicht:** per frequency band: status
|
||||||
|
(primary/secondary), the allowed frequency ranges, max. **power per
|
||||||
|
licence class (A/E/N) — in PEP or ERP exactly as printed** (not EIRP),
|
||||||
|
and additional-usage references.
|
||||||
|
Note the units: class N on **10 m is 10 W ERP**, but on **2 m/70 cm it
|
||||||
|
is 6,1 W ERP** (= ≈10 W EIRP after ×1,64, see S. 15) — the table does
|
||||||
|
*not* print "10 W EIRP". Power limits are in PEP/ERP, never EIRP.
|
||||||
|
- **S. 3 — Zusätzliche Nutzungsbestimmungen (numbered conditions):**
|
||||||
|
max. occupied **bandwidth** per band (800 Hz, 2,7 kHz, 7 kHz, 40 kHz,
|
||||||
|
2 MHz …), and special power limits such as the 1247–1263 MHz
|
||||||
|
**3,05 W ERP** sub-band cap (= ≈5 W EIRP). The bandwidth values live in
|
||||||
|
these numbered conditions, not in the S. 2 table.
|
||||||
|
|
||||||
|
Not in Anlage 1: the general HF/VHF/UHF (KW/UKW) range *terminology*,
|
||||||
|
the ISM-band *definition*, and foreign-country prefixes (Landeskenner) —
|
||||||
|
those are memory items.
|
||||||
|
|
||||||
|
## S. 4–7 — Rufzeichenplan für den Amateurfunkdienst in Deutschland
|
||||||
|
|
||||||
|
- **S. 4** — intro: structure of German call signs (prefix DA–DR excl.
|
||||||
|
DE/DI, digit, 2–3-char suffix).
|
||||||
|
- **S. 5 — Tabelle "Rufzeichen mit 2- oder 3-buchstabigen Suffixen":**
|
||||||
|
the main **call-sign-series → class/use** table. DA0 = Klubstation;
|
||||||
|
DL1–DL9 = person-bound class A; DO1–DO9 = class E; DN9 = class N;
|
||||||
|
DP is intended for exterritorial-location call signs; DP0–DP1 =
|
||||||
|
class A with use categories KS, RL/FB and SZ; DP2 = class E with the
|
||||||
|
same use categories; DP8 = class N with the same use categories.
|
||||||
|
- **S. 6** — club-station suffixes (1-char) table.
|
||||||
|
- **S. 7** — club-station suffixes (4–7-digit); **§ 5** Kurzzeitzulassungen
|
||||||
|
ausländischer Funkamateure (visitors in Germany sign **DL/** class A,
|
||||||
|
**DO/** class E); **§ 6** Kennungen zum Betrieb leistungsschwacher Sender
|
||||||
|
(beacon/peil identifiers **MO, MOE, MOI, MOS, MOH, MO5**); §§ 7–8 rules.
|
||||||
|
|
||||||
|
## S. 8 — International gebräuchliche Rufzeichenzusätze / Ausbildung / Remote
|
||||||
|
|
||||||
|
- **§ 9** operating suffixes appended with `/`: `/m` (mobile, incl.
|
||||||
|
inland-waterway), `/mm` (maritime mobile), `/am` (aeronautical mobile),
|
||||||
|
`/p` (portable/temporary fixed; optional in Germany), etc.
|
||||||
|
- **§ 10** Ausbildungsfunkbetrieb (`/Trainee` speech, `/T` CW/digital).
|
||||||
|
- **§ 11** Remotebetrieb (`Remote` speech, `/R` CW/digital).
|
||||||
|
|
||||||
|
Foreign-country prefixes (e.g. Swiss HB3/HB9 for a German operating
|
||||||
|
*abroad*) are **not** in this plan — memory item.
|
||||||
|
|
||||||
|
## S. 9–10 — IARU-Bandplan
|
||||||
|
|
||||||
|
- **S. 9 — 2 m** and **S. 10 — 70 cm**: per segment, max bandwidth,
|
||||||
|
preferred mode and usage (CW, SSB, FM, digital, beacons, satellite…).
|
||||||
|
|
||||||
|
## S. 11 — Formelsammlung: Grundlagen / Widerstände
|
||||||
|
|
||||||
|
- Zehnerpotenzen & SI-Präfixe (p…T) — table.
|
||||||
|
- Zweierpotenzen / Bit (2^0…2^12) — table.
|
||||||
|
- **Ohmsches Gesetz:** `U = R·I`, `R = U/I`, `I = U/R`.
|
||||||
|
- **Innenwiderstand:** `R_i = ΔU/ΔI`.
|
||||||
|
- **Widerstand von Drähten:** `R = ρ·l/A_Dr`, `A_Dr = d²·π/4 = r²·π`.
|
||||||
|
- **Widerstands-Farbcode** — table (Farbe / Wert / Multiplikator / Toleranz).
|
||||||
|
|
||||||
|
## S. 12 — Widerstandsnetzwerke / Leistung / Wechselspannung
|
||||||
|
|
||||||
|
- **Reihenschaltung:** `R_G = R1+R2+…+R_N`; 2 R: `R_G = R1+R2`.
|
||||||
|
- **Parallelschaltung:** `1/R_G = 1/R1+1/R2+…`; 2 R: `R_G = R1·R2/(R1+R2)`.
|
||||||
|
- **Spannungsteiler (unbelastet):** `U_G = U1+U2`, `U1/U2 = R1/R2`,
|
||||||
|
`U2/U_G = R2/(R1+R2)`.
|
||||||
|
- **Stromteiler:** `I_G = I1+I2`, `I2/I1 = R1/R2`.
|
||||||
|
- **Vorzugsreihen** E6/E12/E24 — table.
|
||||||
|
- **Leistung:** `P = U·I = U²/R = I²·R`; `U = P/I = √(P·R)`;
|
||||||
|
`I = P/U = √(P/R)`.
|
||||||
|
- **Arbeit/Energie:** `W = P·t`.
|
||||||
|
- **Wirkungsgrad:** `η = P_ab/P_zu (·100 %)`; `P_ab = P_zu − P_V`.
|
||||||
|
- **Wechselspannung:** `Û = U_eff·√2`, `U_SS = 2·Û`, `ω = 2·π·f`,
|
||||||
|
`Z = √(R²+X²)`, `T = 1/f`, `f = 1/T`.
|
||||||
|
|
||||||
|
## S. 13 — Induktivität / Transformator / Kapazität
|
||||||
|
|
||||||
|
- **Induktiver Blindwiderstand:** `X_L = ω·L`.
|
||||||
|
- **L Reihe:** `L_G = L1+…+L_N`; **L Parallel:** `1/L_G = 1/L1+…`.
|
||||||
|
- **Ringspule / lange Zylinderspule:** `L = μ0·μr·N²·A_S / l(_m)`.
|
||||||
|
- **Ringkernspulen (auch mehrlagig):** `L = N²·A_L`.
|
||||||
|
- **Magnetische Feldstärke (Ringspule):** `H = I·N/l_m`.
|
||||||
|
- **Magnetische Flussdichte:** `B_m = μr·μ0·H`.
|
||||||
|
- **Transformator-Übersetzungsverhältnis:**
|
||||||
|
`ü = N_P/N_S = U_P/U_S = I_S/I_P = √(Z_P/Z_S)`.
|
||||||
|
- **Belastbarkeit von Wicklungen:** `I = S·A_Dr` mit `S ≈ 2,5 A/mm²`.
|
||||||
|
- **Kapazitiver Blindwiderstand:** `X_C = 1/(ω·C)`.
|
||||||
|
- **C Reihe:** `1/C_G = 1/C1+…`; **C Parallel:** `C_G = C1+…`.
|
||||||
|
- **E-Feld im homogenen Feld:** `E = U/d`.
|
||||||
|
- **Kapazität Kondensator:** `C = ε0·εr·A/d`.
|
||||||
|
|
||||||
|
## S. 14 — Filter / Schwingkreis / Transistor / ZF & Spiegelfrequenz
|
||||||
|
|
||||||
|
- **RC TP/HP:** `f_g = 1/(2π·R·C)`; **RL TP/HP:** `f_g = R/(2π·L)`
|
||||||
|
(`f_g` = −3 dB-Grenzfrequenz).
|
||||||
|
- **Schwingkreis:** `f0 = 1/(2π·√(L·C))`, Resonanz `X_C = X_L`.
|
||||||
|
- **Reihenschwingkreis:** `B = R_s/(2π·L)`, `Q = f0/B = X_L/R_s`.
|
||||||
|
- **Parallelschwingkreis:** `B = 1/(2π·R_p·C)`, `Q = f0/B = R_p/X_L`.
|
||||||
|
- **Transistor (DC):** `B = I_C/I_B`, `I_E = I_C+I_B`.
|
||||||
|
- **Transistor (AC):** `v_I = β = ΔI_C/ΔI_B`, `v_U = β = ΔU_CE/ΔU_BE`,
|
||||||
|
`v_P = β² = v_U·v_I`. *(Transcribed verbatim. The printed
|
||||||
|
`v_U = β` is physically dubious — voltage gain is not generally the
|
||||||
|
current gain — but the aid prints it this way; do not silently
|
||||||
|
"correct" it.)*
|
||||||
|
- **Zwischenfrequenz:** `f_ZF = |f_E − f_OSZ|` (the sheet notes that the
|
||||||
|
`f_ZF = f_E + f_OSZ` case is not considered).
|
||||||
|
- **Spiegelfrequenz:** `f_S = 2·f_OSZ − f_E`.
|
||||||
|
|
||||||
|
## S. 15 — Pegel / Antennen
|
||||||
|
|
||||||
|
- **Pegel:** `p = 10·log10(P/1mW)` dBm; `p = 10·log10(P/1W)` dBW;
|
||||||
|
`u = 20·log10(U/0,775V)` dBu.
|
||||||
|
- **Verstärkung/Gewinn:** `g = 10·log10(P2/P1)` dB;
|
||||||
|
`g = 20·log10(U2/U1)` dB.
|
||||||
|
- **Dämpfung/Verluste:** `a = 10·log10(P1/P2)`; `a = 20·log10(U1/U2)`.
|
||||||
|
- **dB ↔ Verhältnis-Tabelle** — exactly **11 rows** (Leistungs- /
|
||||||
|
Spannungsverhältnis): −20 (0,01/0,1), −10 (0,1/0,32), −6 (0,25/0,5),
|
||||||
|
−3 (0,5/0,71), −1 (0,79/0,89), 0 (1/1), +1 (1,26/1,12), +3 (2/1,41),
|
||||||
|
+6 (4/2), +10 (10/3,16), +20 (100/10). **There is no 16 dB row** (or
|
||||||
|
any value off this list): combine rows, e.g. 16 dB = +10 dB (×10) +
|
||||||
|
+6 dB (×4) → ×40.
|
||||||
|
- **ERP:** `p_ERP = p_S − a + g_d`; `P_ERP = P_S·10^((g_d−a)/10dB)`.
|
||||||
|
- **EIRP:** `p_EIRP = p_ERP + 2,15 dB`; `P_EIRP = P_ERP·1,64`.
|
||||||
|
- **Feldstärke Fernfeld:** `E = √(30Ω·P_A·G_i)/d = √(30Ω·P_EIRP)/d`
|
||||||
|
(ab `d > λ/(2π)`).
|
||||||
|
- **Gewinn:** `G_i = G_d·1,64`, `g_i = g_d + 2,15 dB`, `G = 10^(g/10dB)`.
|
||||||
|
- Halbwellendipol `G_i=1,64 / g_i=2,15 dB`; λ/4-Vertikal mit
|
||||||
|
Bodenreflexion `G_i=3,28 / g_i=5,15 dB`; Parabolspiegel
|
||||||
|
`g_i = 10·log10[(π·d/λ)²·η] dB`.
|
||||||
|
|
||||||
|
## S. 16 — Rauschen / Amplitudenmodulation / Frequenzmodulation
|
||||||
|
|
||||||
|
- **Thermisches Rauschen:** `P_R = k·T_K·B`; `Δp_R = 10·log10(B1/B2)`;
|
||||||
|
`U_R = 2·√(P_R·R)`.
|
||||||
|
- **Rauschzahl:** `F = (P_S/P_N)_Eingang/(P_S/P_N)_Ausgang`;
|
||||||
|
`a_F = 10·log10(F)`; `a_F = SNR_Eingang − SNR_Ausgang`.
|
||||||
|
- **SNR:** `SNR = 10·log10(P_S/P_N) = 20·log10(U_S/U_N)`.
|
||||||
|
- **Shannon-Hartley:** `C = B/1Hz · log2(1 + P_S/P_N)` bit/s.
|
||||||
|
- **log2:** `log2(x) = log10(x)/log10(2)`.
|
||||||
|
- **AM Modulationsgrad:** `m = Û_mod/Û_T`; **AM Bandbreite:**
|
||||||
|
`B = 2·f_mod,max`.
|
||||||
|
- **FM Modulationsindex:** `m = Δf_T/f_mod`; **Carson-Bandbreite:**
|
||||||
|
`B ≈ 2·(Δf_T + f_mod,max)`.
|
||||||
|
|
||||||
|
## S. 17 — Wellenlänge & Frequenz / Reflexion / Wellenwiderstand
|
||||||
|
|
||||||
|
- **c = f·λ**, `f = c/λ`, `λ = c/f`; `c0 ≈ 3·10⁸ m/s`;
|
||||||
|
`f[MHz] ≈ 300/λ[m]`, `λ[m] ≈ 300/f[MHz]`.
|
||||||
|
- **Verkürzungsfaktor:** `k_v = l_G/l_E = 1/√εr = c/c0`.
|
||||||
|
- **Stehwellenverhältnis (SWR):**
|
||||||
|
`s = U_max/U_min = (U_v+U_r)/(U_v−U_r) = (√P_v+√P_r)/(√P_v−√P_r) = (1+|r|)/(1−|r|)`;
|
||||||
|
`s = R2/Z` (R2>Z) bzw. `s = Z/R2` (R2<Z).
|
||||||
|
- **Reflexionsfaktor:** `|r| = (s−1)/(s+1) = |(R2−Z)/(R2+Z)| = |U_r/U_v| = √(P_r/P_v)`.
|
||||||
|
- **Rücklaufende Leistung:** `P_r = P_v·|r|²`.
|
||||||
|
- **An R2 abgegebene Leistung:** `P_ab = P_v·(1−|r|²)`.
|
||||||
|
- **Wellenwiderstand:** HF-Leitung `Z = √(L'/C')`; Koax
|
||||||
|
`Z = 60Ω/√εr·ln(D/d)`; symm. Zweidraht (a/d>2,5)
|
||||||
|
`Z = 120Ω/√εr·ln(2a/d)`; Viertelwellentransformator `Z = √(Z_E·Z_A)`.
|
||||||
|
|
||||||
|
## S. 18 — Weitere Formeln
|
||||||
|
|
||||||
|
- **MUF:** `MUF ≈ f_c/sin(α)`; `f_opt = MUF·0,85`.
|
||||||
|
- **Empfindlichkeit von Messsystemen:** `E_MESS = R_i/U_i = 1/I_i`.
|
||||||
|
- **Relativer maximaler Fehler:** `F_W = ±(G/100)·(W_E/W_M)`.
|
||||||
|
- **Abtasttheorem:** `f_abtast > 2·f_max`; für Nicht-Basisband-Signale
|
||||||
|
`f_abtast > 2·(f_max − f_min)` **wenn `f_abtast < f_min` oder
|
||||||
|
`f_abtast > f_max`**.
|
||||||
|
- **Datenübertragungs-/Symbolrate:** `C = R_S·n`.
|
||||||
|
|
||||||
|
## S. 19–22 — Konstanten und Tabellen
|
||||||
|
|
||||||
|
- **S. 19–20** — symbol/quantity glossary and physical constants
|
||||||
|
(k, c0, μ0, ε0, e, …).
|
||||||
|
- **S. 21** — material tables: **relative permittivity ε_r** and
|
||||||
|
**specific resistance ρ** (per the S. 21 erratum, Zink → Zinn).
|
||||||
|
- **S. 22 — Kabeldämpfungsdiagramm Koaxialkabel:** a chart of cable
|
||||||
|
attenuation in **dB per 100 m vs. frequency** for common coax types
|
||||||
|
(RG58 ≈ 4,95 mm, RG174 ≈ 2,8 mm, PE-foam 10,3/12,7 mm, …). Read the
|
||||||
|
per-100 m loss at the operating frequency, then **scale linearly with
|
||||||
|
cable length**. This chart *is* part of the aid — cable-loss questions
|
||||||
|
are Hilfsmittel lookups.
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
@@ -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 |
|
| 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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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 ...; 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` |
|
| `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` |
|
| `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` |
|
| `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 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` |
|
| `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` |
|
| `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` |
|
| `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 |
|
| 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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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
|
## Emergency and Safety Signals
|
||||||
|
|
||||||
|
|||||||
+559
@@ -0,0 +1,559 @@
|
|||||||
|
{
|
||||||
|
"q_codes": [
|
||||||
|
{
|
||||||
|
"code": "QRG",
|
||||||
|
"question": "Will you tell me my exact frequency?",
|
||||||
|
"statement": "Your exact frequency is ... kHz or MHz.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Formal frequency-reporting Q-code. German-speaking amateurs also use 'QRG' informally as a noun meaning operating frequency, for example 'auf dieser QRG'.",
|
||||||
|
"example": "DL1ABC: `QRG?` / DO2XYZ: `Your QRG is 7.042 MHz.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRL",
|
||||||
|
"question": "Are you busy?",
|
||||||
|
"statement": "I am busy; please do not interfere.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "In amateur operation, QRL? is the standard short CW check meaning 'Is this frequency in use?' Listen first, send it once or twice, and wait before calling CQ.",
|
||||||
|
"example": "DL1ABC: `QRL?` / DO2XYZ: `QRL, in QSO.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRM",
|
||||||
|
"question": "Is my transmission being interfered with?",
|
||||||
|
"statement": "Your transmission is being interfered with.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Interference from other stations or man-made sources, distinct from atmospheric static (QRN). In everyday amateur speech, 'I have QRM' is also common even though the formal statement addresses the other station's transmission.",
|
||||||
|
"example": "DL1ABC: `QRM?` / DO2XYZ: `QRM, pse repeat report.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRN",
|
||||||
|
"question": "Are you troubled by atmospheric noise?",
|
||||||
|
"statement": "I am troubled by atmospheric noise (static).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used when static crashes or atmospheric noise make reception difficult.",
|
||||||
|
"example": "DL1ABC: `QRN here, ur report?` / DO2XYZ: `RST 55 despite QRN.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRO",
|
||||||
|
"question": "Shall I increase transmitter power?",
|
||||||
|
"statement": "Increase transmitter power.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used when more power may be needed, usually after trying normal operating improvements first.",
|
||||||
|
"example": "DL1ABC: `QRO?` / DO2XYZ: `PSE QRO, ur weak.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRP",
|
||||||
|
"question": "Shall I decrease transmitter power?",
|
||||||
|
"statement": "Decrease transmitter power.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Formal request to reduce power. In everyday amateur use, QRP also describes low-power operation—typically no more than 5 W output for CW/data or 10 W PEP for SSB under the common award convention.",
|
||||||
|
"example": "DL1ABC: `PSE QRP.` / DO2XYZ: `QRP 5 W now.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRQ",
|
||||||
|
"question": "Shall I send faster?",
|
||||||
|
"statement": "Send faster (... words per minute).",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Requests a faster Morse sending speed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRS",
|
||||||
|
"question": "Shall I send more slowly?",
|
||||||
|
"statement": "Send more slowly (... words per minute).",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Requests a slower Morse sending speed; common courtesy to slower operators."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRT",
|
||||||
|
"question": "Shall I stop sending?",
|
||||||
|
"statement": "Stop sending; I am closing down.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used to close down transmission or ask another station to stop transmitting.",
|
||||||
|
"example": "DL1ABC: `I must QRT.` / DO2XYZ: `QSL, 73.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRV",
|
||||||
|
"question": "Are you ready?",
|
||||||
|
"statement": "I am ready.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used to ask if a station is ready, or to say that a station is available for traffic.",
|
||||||
|
"example": "DL1ABC: `QRV?` / DO2XYZ: `QRV, go ahead.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRX",
|
||||||
|
"question": "When will you call me again?",
|
||||||
|
"statement": "I will call you again at ... hours on ... kHz or MHz.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The formal code schedules a later call. In normal amateur operation, QRX followed by a duration—such as 'QRX 5'—usually means 'stand by for five minutes'.",
|
||||||
|
"example": "DL1ABC: `QRX 5 min.` / DO2XYZ: `QSL, standing by.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRZ",
|
||||||
|
"question": "Who is calling me?",
|
||||||
|
"statement": "You are being called by ...",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Use QRZ? when a calling station was not copied. DX and contest stations also say QRZ? to invite the next caller; that widespread pile-up use is still the question form, not a separate statement meaning.",
|
||||||
|
"example": "DL1ABC: `QRZ?` / DO2XYZ: `DO2XYZ calling.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QSB",
|
||||||
|
"question": "Is the strength of my signal fading?",
|
||||||
|
"statement": "Your signal strength is fading or fluctuating.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used when signal strength rises and falls because of propagation.",
|
||||||
|
"example": "DL1ABC: `QSB?` / DO2XYZ: `Yes, strong QSB, now 55.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QSK",
|
||||||
|
"question": "Can you hear me between your signals, and may I break in?",
|
||||||
|
"statement": "I can hear you between my signals; break in on my transmission.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Describes full break-in CW, where transmit/receive switching is fast enough to hear the band between one's own dots and dashes. Modern transceiver menus often label this feature QSK."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QSL",
|
||||||
|
"question": "Can you acknowledge receipt?",
|
||||||
|
"statement": "I acknowledge receipt.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "On air it confirms that information was copied. A paper QSL card or electronic QSL is the later confirmation of a completed contact; that noun usage is practical amateur shorthand, not the formal statement text.",
|
||||||
|
"example": "DL1ABC: `My QTH Berlin, QSL?` / DO2XYZ: `QSL, Berlin copied.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QSO",
|
||||||
|
"question": "Can you communicate with ... directly or by relay?",
|
||||||
|
"statement": "I can communicate with ... directly or by relay through ...",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The formal code concerns establishing communication. In everyday amateur speech and logging, a QSO simply means a completed or ongoing radio contact.",
|
||||||
|
"example": "DL1ABC: `Tnx for QSO.` / DO2XYZ: `Tnx also, 73.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QSY",
|
||||||
|
"question": "Shall I change to another frequency?",
|
||||||
|
"statement": "Change frequency; I am changing frequency.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used to move a contact away from a calling frequency or crowded channel.",
|
||||||
|
"example": "DL1ABC: `PSE QSY 145.525.` / DO2XYZ: `QSL, QSY now.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QTC",
|
||||||
|
"question": "How many messages have you to send?",
|
||||||
|
"statement": "I have ... messages to send.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Used in traffic handling and contests to announce a count of formal messages."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QTH",
|
||||||
|
"question": "What is your position or location?",
|
||||||
|
"statement": "My position or location is ...",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used to ask for or give the station location, often city, locator, or portable position.",
|
||||||
|
"example": "DL1ABC: `QTH?` / DO2XYZ: `QTH Munich, JN58.`"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"abbreviations": [
|
||||||
|
{
|
||||||
|
"code": "73",
|
||||||
|
"meaning": "Best regards (friendly sign-off).",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "The standard friendly close of a contact. Always singular: 'best 73', never '73s'.",
|
||||||
|
"example": "DL1ABC: `tnx QSO, 73!` / DO2XYZ: `73 es cuagn.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "88",
|
||||||
|
"meaning": "Love and kisses (affectionate sign-off).",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "An affectionate close, traditionally used towards a partner or in friendly mixed company."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AGN",
|
||||||
|
"meaning": "Again / please repeat.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Asks the other station to repeat something that was missed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ANT",
|
||||||
|
"meaning": "Antenna.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common CW and logging abbreviation, for example when exchanging antenna type, height or direction."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR",
|
||||||
|
"meaning": "End of message (prosign, sent as one run-together character).",
|
||||||
|
"exam": false,
|
||||||
|
"tags": ["prosign"],
|
||||||
|
"explanation": "Marks the end of a complete transmission before the callsign exchange and K."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AS",
|
||||||
|
"meaning": "Wait / stand by (prosign).",
|
||||||
|
"exam": false,
|
||||||
|
"tags": ["prosign"],
|
||||||
|
"explanation": "Asks the other station to wait briefly without ending the transmission."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BK",
|
||||||
|
"meaning": "Break — interrupt or quickly hand over a running transmission.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used in telegraphy to interrupt or quickly pass transmission without a formal over.",
|
||||||
|
"example": "DL1ABC: `... running 10 W BK` / DO2XYZ: `BK, my PWR 5 W.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BT",
|
||||||
|
"meaning": "Separator between parts of a message (prosign, written =).",
|
||||||
|
"exam": false,
|
||||||
|
"tags": ["prosign"],
|
||||||
|
"explanation": "Run-together dah-di-di-di-dah, used like a paragraph break inside a message."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BTU",
|
||||||
|
"meaning": "Back to you.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Informal voice and digital-text handover. In formal CW procedure, K or KN is clearer."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CFM",
|
||||||
|
"meaning": "Confirm / I confirm.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Confirms information such as a report, locator, or QSL."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL",
|
||||||
|
"meaning": "Closing down / clear (going off the air).",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Sent in CW to announce that the station is closing and will not remain available for another contact."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CQ",
|
||||||
|
"meaning": "General call to any station.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used to start a contact when no specific station is being called. A target narrows it, e.g. CQ DX or CQ DL.",
|
||||||
|
"example": "DL1ABC: `CQ CQ CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CPY",
|
||||||
|
"meaning": "Copy — receive or understand a signal.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common operating shorthand in questions such as 'HW CPY?' and replies such as 'CPY 100%'. It refers to successful reception, not making a duplicate document.",
|
||||||
|
"example": "DL1ABC: `HW CPY?` / DO2XYZ: `FB CPY, no QRM.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CUAGN",
|
||||||
|
"meaning": "See you again.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Compact friendly sign-off used in CW and digital text, often immediately before 73."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CUL",
|
||||||
|
"meaning": "See you later.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Friendly informal closing used mainly in CW and text modes."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DE",
|
||||||
|
"meaning": "From / this is (precedes the sending station's call sign).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used between the called station and own call sign, especially in CW.",
|
||||||
|
"example": "DL1ABC: `CQ de DL1ABC k` / DO2XYZ: `DL1ABC de DO2XYZ k`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DX",
|
||||||
|
"meaning": "Long distance / distant station.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used to seek or describe long-distance contacts. The required distance depends on band: intercontinental on HF, several hundred km on VHF/UHF.",
|
||||||
|
"example": "DL1ABC: `CQ DX de DL1ABC k` / PY2XYZ: `DL1ABC de PY2XYZ k`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ES",
|
||||||
|
"meaning": "And.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Telegraphy shorthand for 'and', from the Morse ampersand.",
|
||||||
|
"example": "DL1ABC: `73 es gud DX`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FB",
|
||||||
|
"meaning": "Fine business — excellent / understood well.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "A general expression of approval: good, excellent, all copied."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FD",
|
||||||
|
"meaning": "Field Day (portable contest operation).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used during Field Day contest operation to attract participating stations.",
|
||||||
|
"example": "DL1ABC/P: `CQ FD de DL1ABC/P TEST`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GA",
|
||||||
|
"meaning": "Good afternoon / go ahead.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Context decides the meaning: a greeting near the start of a contact, or an invitation to continue. Use K or KN for an unambiguous CW handover."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GE",
|
||||||
|
"meaning": "Good evening.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common greeting near the start of a CW or digital-text contact."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GM",
|
||||||
|
"meaning": "Good morning.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common greeting near the start of a CW or digital-text contact."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GN",
|
||||||
|
"meaning": "Good night.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Friendly closing, normally used at the end rather than as the formal end-of-contact prosign."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GL",
|
||||||
|
"meaning": "Good luck.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common friendly wish in contests, award chasing and ordinary CW or digital contacts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GUD",
|
||||||
|
"meaning": "Good.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common CW spelling reduction, as in 'gud sig' or 'gud copy'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HI",
|
||||||
|
"meaning": "Laughter (the Morse 'smiley').",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Written representation of laughter in telegraphy; its dot pattern sounds like a chuckle."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HR",
|
||||||
|
"meaning": "Here / hear.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "A context-dependent CW contraction: 'WX HR' means weather here, while 'HR U' means hear you."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HW",
|
||||||
|
"meaning": "How?",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Usually asks for reception or an opinion near the end of an exchange.",
|
||||||
|
"example": "DL1ABC: `HW CPY?` / DO2XYZ: `FB CPY.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "K",
|
||||||
|
"meaning": "Invitation to transmit — go ahead / over.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used at the end of a CW transmission to invite the other station to reply.",
|
||||||
|
"example": "DL1ABC: `DO2XYZ de DL1ABC k`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "KN",
|
||||||
|
"meaning": "Go ahead, named station only (no other stations break in).",
|
||||||
|
"exam": false,
|
||||||
|
"tags": ["prosign"],
|
||||||
|
"explanation": "Like K, but invites only the specific station addressed to reply."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MNI",
|
||||||
|
"meaning": "Many.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Frequent CW contraction, for example 'mni tnx' or 'mni QSOs'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NIL",
|
||||||
|
"meaning": "Nothing / nothing heard.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Used when no signal, traffic or result was obtained, for example 'NIL HRD' in a log or net report."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NR",
|
||||||
|
"meaning": "Number.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Used before a serial number, message number or other numbered item. Restricting the card to this common operational meaning avoids the ambiguous dictionary expansion 'near'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OM",
|
||||||
|
"meaning": "Old man — any male operator.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Friendly address for a male amateur, regardless of age."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OP",
|
||||||
|
"meaning": "Operator / the operator's name.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common when exchanging names, for example 'OP Anna', and in station descriptions such as single-op or multi-op."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PSE",
|
||||||
|
"meaning": "Please.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used as a polite request marker, e.g. PSE QRP or PSE QSY.",
|
||||||
|
"example": "DL1ABC: `PSE QSY 145.525` / DO2XYZ: `QSL, QSY.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PWR",
|
||||||
|
"meaning": "Power (transmitter output).",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Usually followed by the RF output power and unit, for example 'PWR 5 W'. Do not confuse transmitter output with ERP or EIRP."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "R",
|
||||||
|
"meaning": "Received — everything before it was copied correctly.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Sent at the start of a transmission to acknowledge full receipt of the previous one.",
|
||||||
|
"example": "DL1ABC: `R tnx report, ur RST 579` / DO2XYZ: `QSL, 73.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RIG",
|
||||||
|
"meaning": "Station equipment / transceiver.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Informal term for the radio equipment in use, often exchanged together with antenna and power."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RPT",
|
||||||
|
"meaning": "Repeat.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Requests retransmission of missed text. Signal report is better abbreviated RPRT, avoiding a meaning collision in the reverse card."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RPRT",
|
||||||
|
"meaning": "Report — usually a signal report.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Distinguishes the noun 'report' from RPT meaning 'repeat'. It is commonly used when asking for or sending an RST report."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RR73",
|
||||||
|
"meaning": "Report received; best regards — final FT8 acknowledgement.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "In an FT8 exchange, RR73 confirms receipt of the other station's report and closes the contact in one message. It is protocol text, not a general voice or CW abbreviation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RST",
|
||||||
|
"meaning": "Signal report: Readability, Strength, Tone.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Three-figure reception report. In SSB only R and S are normally given (Tone applies to CW).",
|
||||||
|
"example": "DL1ABC: `ur RST 599` / DO2XYZ: `tnx, ur 579.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RX",
|
||||||
|
"meaning": "Receiver.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Equipment and diagram abbreviation paired with TX. On repeater listings, RX denotes the frequency on which the station receives."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SK",
|
||||||
|
"meaning": "End of contact (prosign); also 'silent key', a deceased radio amateur.",
|
||||||
|
"exam": false,
|
||||||
|
"tags": ["prosign"],
|
||||||
|
"explanation": "Sent run-together to close out a complete contact; outside operating, a Silent Key is a ham who has died."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SRI",
|
||||||
|
"meaning": "Sorry.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Brief apology in CW or digital text, often followed by a correction or repeat."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SWL",
|
||||||
|
"meaning": "Short-Wave Listener — listener who receives but does not transmit.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "SWLs monitor amateur and broadcast stations, may submit reception reports, and do not need an amateur transmitting authorization merely to receive."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TEST",
|
||||||
|
"meaning": "Contest call indicator.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Used in contest calls to show the call is for contest contacts.",
|
||||||
|
"example": "DL1ABC: `CQ TEST de DL1ABC` / DO2XYZ: `DL1ABC de DO2XYZ 59 012`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TNX",
|
||||||
|
"meaning": "Thanks — three-letter telegraphic abbreviation.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Very common in CW and digital text; TKS is an equivalent spelling. The explicit three-letter wording distinguishes this reverse card from TU."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU",
|
||||||
|
"meaning": "Thank you — two-letter acknowledgement or sign-off.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Frequently used by contest or DX stations to acknowledge one caller and immediately continue, for example 'TU QRZ?'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TX",
|
||||||
|
"meaning": "Transmitter.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Equipment and diagram abbreviation paired with RX. On repeater listings, TX denotes the frequency on which the station transmits."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UR",
|
||||||
|
"meaning": "Your / you're.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Context-dependent CW and text contraction, as in 'UR RST 579' or 'UR FB'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UTC",
|
||||||
|
"meaning": "Coordinated Universal Time — koordinierte Weltzeit.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Use UTC for logs, schedules and QSL confirmations so both stations record the same date and time without daylight-saving ambiguity. Germany is UTC+1 in MEZ and UTC+2 in MESZ."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VY",
|
||||||
|
"meaning": "Very.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common CW contraction, for example 'vy gud' or 'vy strong'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "WX",
|
||||||
|
"meaning": "Weather.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common conversational topic in CW and digital contacts, for example 'WX HR SUNNY'."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "WPM",
|
||||||
|
"meaning": "Words Per Minute — Morse sending speed.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Standard practical speed unit for CW. Send no faster than you can reliably receive, and slow down to match a station that answers at a lower speed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "XYL",
|
||||||
|
"meaning": "Wife (ex-young-lady).",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Traditional amateur shorthand that is still encountered but can sound dated; use a person's name when known."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YL",
|
||||||
|
"meaning": "Young lady — any female operator.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Traditional term for a female radio amateur regardless of age. It remains common in names of clubs, nets and awards."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MAYDAY",
|
||||||
|
"meaning": "International distress signal (spoken). Must not be used in amateur radio traffic.",
|
||||||
|
"exam": true,
|
||||||
|
"tags": ["notsignal"],
|
||||||
|
"explanation": "Used in maritime or aeronautical distress traffic, not in normal amateur radio traffic.",
|
||||||
|
"example": "Vessel: `MAYDAY MAYDAY MAYDAY, this is DABC.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SOS",
|
||||||
|
"meaning": "International distress signal (Morse). Must not be used in amateur radio traffic.",
|
||||||
|
"exam": true,
|
||||||
|
"tags": ["notsignal"],
|
||||||
|
"explanation": "Used as an international distress signal, especially in Morse contexts outside amateur traffic.",
|
||||||
|
"example": "Station: `SOS SOS SOS de DABC`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PAN PAN",
|
||||||
|
"meaning": "International urgency signal. Must not be used in amateur radio traffic.",
|
||||||
|
"exam": true,
|
||||||
|
"tags": ["notsignal"],
|
||||||
|
"explanation": "Used outside amateur radio for urgent situations that are serious but not yet distress.",
|
||||||
|
"example": "Vessel: `PAN PAN, this is DABC, engine failure.`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SÉCURITÉ",
|
||||||
|
"meaning": "International safety signal. Must not be used in amateur radio traffic.",
|
||||||
|
"exam": true,
|
||||||
|
"tags": ["notsignal"],
|
||||||
|
"explanation": "Used outside amateur radio for safety information such as navigation or weather warnings.",
|
||||||
|
"example": "Coast station: `SECURITE, weather warning.`"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+642
@@ -0,0 +1,642 @@
|
|||||||
|
{
|
||||||
|
"terms": [
|
||||||
|
{
|
||||||
|
"code": "AM",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Amplitudenmodulation — amplitude modulation.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The carrier amplitude follows the audio signal. Both sidebands and the carrier are transmitted, so AM needs more bandwidth and power than SSB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FM",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Frequenzmodulation — frequency modulation.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The carrier frequency follows the audio signal at constant amplitude. Robust against amplitude noise; the usual mode for VHF/UHF (UKW) voice and repeaters."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PM",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Phasenmodulation — phase modulation.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The carrier phase follows the signal; closely related to FM and often used to generate it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DSB",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Doppelseitenbandmodulation — double-sideband modulation.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Both sidebands are transmitted. The carrier may be present, as in conventional AM, or suppressed, as in DSB-SC; suppressing one of the two sidebands produces SSB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SSB",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Einseitenbandmodulation — single sideband.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Only one sideband is transmitted and the carrier is normally suppressed. Compared with conventional AM, SSB uses about half the bandwidth and does not waste transmitter power on a full carrier, which is why it dominates analogue HF voice."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "USB",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "oberes Seitenband — upper sideband.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "SSB using the sideband above the suppressed carrier. Convention on HF above 10 MHz and on VHF/UHF."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LSB",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "unteres Seitenband — lower sideband.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "SSB using the sideband below the suppressed carrier. Convention on the HF bands below 10 MHz (160/80/40 m)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CW",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Continuous Wave — Morsetelegrafie (A1A).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "On/off keying of an unmodulated carrier. Very narrow bandwidth and excellent weak-signal readability."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FSK",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Frequency Shift Keying — Frequenzumtastung.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Digital keying that shifts the carrier between discrete frequencies; the basis of RTTY."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PSK",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Phase Shift Keying — Phasenumtastung.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Digital keying that encodes data in carrier phase shifts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ASK",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Amplitude Shift Keying — Amplitudenumtastung.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Digital symbols are represented by discrete carrier amplitudes. Simple on-off keying is the two-state special case; unlike FM or PSK, amplitude noise directly affects the keyed quantity."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QAM",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Quadraturamplitudenmodulation — quadrature amplitude modulation.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Combines amplitude and phase modulation on two quadrature carriers to carry several bits per symbol."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RTTY",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Radioteletype — Funkfernschreiben.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Classic teleprinter operation using frequency-shift keying and the five-bit ITA2 alphabet, commonly called Baudot. On amateur HF it is usually copied with software rather than a mechanical teleprinter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PSK31",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Phase Shift Keying at 31.25 baud — narrowband keyboard digital mode.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The name is conventionally rounded: the symbol rate is 31.25 baud. This live keyboard-to-keyboard HF mode is very narrow, but overdriving an SSB transmitter produces a needlessly wide, distorted signal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FT8",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "digitale Schwachsignal-Betriebsart (WSJT-X) — weak-signal digital mode.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "A structured weak-signal mode with synchronized 15-second periods and short, highly constrained exchanges. It can decode signals far below the audible noise floor, but it is intended for brief contacts rather than conversation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "WSPR",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Weak Signal Propagation Reporter — Bakenbetrieb zur Ausbreitungsforschung.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Very low-power beacon mode whose reception reports are pooled to map propagation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SSTV",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Slow Scan Television — Schmalband-Bildübertragung.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Sends still pictures slowly enough to fit a voice-bandwidth channel."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ATV",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Amateurfernsehen — (fast-scan) amateur television.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Real-time video, typically on the higher bands because of its wide bandwidth."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NBFM",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Schmalband-FM — narrowband FM.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "FM with limited frequency deviation and occupied bandwidth. It is the normal analogue voice mode on VHF/UHF repeaters and simplex channels; channel spacing and occupied bandwidth are related but are not the same quantity."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "MF",
|
||||||
|
"category": "Band",
|
||||||
|
"meaning": "Medium Frequency — 300 kHz to 3 MHz (Mittelwelle).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The ITU band containing the amateur 630 m and 160 m allocations. In practice these low bands need large or electrically shortened antennas and are strongly affected by ground loss and nighttime propagation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "KW",
|
||||||
|
"category": "Band",
|
||||||
|
"meaning": "Kurzwelle — shortwave, 3 to 30 MHz.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The German name for the ITU HF band. This is the classic long-distance amateur range, where ionospheric propagation can support regional or worldwide contacts."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UKW",
|
||||||
|
"category": "Band",
|
||||||
|
"meaning": "Ultrakurzwelle — German name for 30 to 300 MHz.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Mostly line-of-sight propagation; home of FM voice and repeaters. In the BNetzA band names UKW is exactly VHF; 300 MHz to 3 GHz is UHF / Dezimeterwelle. Colloquially 'UKW' is sometimes stretched to mean VHF and up, but that is not the exam definition."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VHF",
|
||||||
|
"category": "Band",
|
||||||
|
"meaning": "Very High Frequency — ITU band from 30 to 300 MHz.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The international designation corresponding to German UKW. The 2 m amateur band is the main practical example, used for local SSB/CW, FM simplex, repeaters, satellites and weak-signal work."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UHF",
|
||||||
|
"category": "Band",
|
||||||
|
"meaning": "Ultra High Frequency — 300 MHz to 3 GHz.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Includes the amateur 70 cm, 23 cm and 13 cm bands. Propagation is mainly line of sight, although repeaters, satellites, tropospheric enhancement and reflection can extend the range."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SHF",
|
||||||
|
"category": "Band",
|
||||||
|
"meaning": "Super High Frequency — 3 to 30 GHz (Mikrowellen).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Microwave range containing several amateur bands. Feed-line loss, connector quality and accurate antenna alignment become increasingly important, so the transverter or low-noise amplifier is often mounted near the antenna."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EHF",
|
||||||
|
"category": "Band",
|
||||||
|
"meaning": "Extremely High Frequency — 30 to 300 GHz.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Millimetre-wave range containing experimental amateur allocations. Operation is specialized because atmospheric absorption, mechanical tolerances and feed-line losses are severe."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "NF",
|
||||||
|
"category": "Signal & Frequenz",
|
||||||
|
"meaning": "Niederfrequenz — audio frequency (about 16 Hz to 20 kHz).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The baseband audio signal before modulation or after demodulation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HF",
|
||||||
|
"category": "Signal & Frequenz",
|
||||||
|
"meaning": "Hochfrequenz — German technical term for a radio-frequency signal.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "In German circuit descriptions, HF contrasts with NF and can mean radio-frequency energy generally. In the international ITU band names, however, HF specifically means 3 to 30 MHz; the German exam normally calls that range KW. Context decides which use is intended."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZF",
|
||||||
|
"category": "Signal & Frequenz",
|
||||||
|
"meaning": "Zwischenfrequenz — German abbreviation for intermediate frequency.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The fixed frequency a superheterodyne receiver mixes the signal to, where most selective amplification and filtering happens."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IF",
|
||||||
|
"category": "Signal & Frequenz",
|
||||||
|
"meaning": "Intermediate Frequency — English abbreviation for an intermediate frequency.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "English-language manuals and schematics use this where German material uses ZF. A superheterodyne receiver converts the wanted signal to this fixed frequency for most of its filtering and gain."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RF",
|
||||||
|
"category": "Signal & Frequenz",
|
||||||
|
"meaning": "Radio Frequency — English term for a radio-frequency signal.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "The usual English label for signals and circuitry at radio frequencies. German schematics commonly label the same signal domain HF."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "VFO",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "variabler Oszillator — variable frequency oscillator.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The tunable oscillator that sets the operating frequency of a transmitter or receiver."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VCO",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "spannungsgesteuerter Oszillator — voltage-controlled oscillator.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "An oscillator whose frequency follows a control voltage; the tunable element inside a PLL."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PLL",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "Phasenregelschleife — phase-locked loop.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Locks a VCO to a stable reference by comparing phase, forming the basis of modern frequency synthesizers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DDS",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "direkte digitale Synthese — direct digital synthesis.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Generates a signal numerically from a clock and a phase accumulator, giving fine, fast frequency steps."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BFO",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "Schwebungsoszillator — beat frequency oscillator.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Reinjects a carrier at the product detector so CW and SSB become audible."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LO",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "Lokaloszillator — local (mixing) oscillator.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Provides the frequency mixed with the received or transmitted signal. The sum or difference product is selected, for example to convert an amateur band to a fixed intermediate frequency."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AGC",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "automatische Verstärkungsregelung — automatic gain control.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Holds the receiver output roughly constant across strong and weak signals."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ALC",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "automatische Pegelregelung — automatic level control.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Limits transmitter drive to prevent overdrive and splatter."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PA",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "Endstufe / Leistungsverstärker — power amplifier.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The final transmitter stage that raises the RF signal to the required output power. It must be driven within its linear range for SSB and digital modes to avoid splatter and intermodulation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LNA",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "rauscharmer Vorverstärker — low-noise amplifier.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "A low-noise preamplifier, often at the antenna, to improve weak-signal reception on VHF and up."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ATU",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "Antennenanpassgerät — antenna tuning unit.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Matches the feed line to the transmitter so the PA sees a low SWR; it does not 'tune' the antenna itself."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TRX",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "Transceiver — combined transmitter and receiver.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Common shorthand in German amateur-radio diagrams and operating notes for a radio that shares controls and circuitry between transmit and receive."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VOX",
|
||||||
|
"category": "Baugruppe & Bedienung",
|
||||||
|
"meaning": "Voice-Operated Transmit — sprachgesteuerte Sendeumschaltung.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Switches a transceiver to transmit when microphone audio exceeds a threshold. Set gain, delay and anti-VOX carefully so loudspeaker audio or room noise does not key the transmitter accidentally."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "Balun",
|
||||||
|
"category": "Baugruppe",
|
||||||
|
"meaning": "Symmetrierglied — balanced-to-unbalanced transformer.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Interfaces a balanced load, such as a dipole, with an unbalanced feed line such as coax. Common-mode suppression depends on the balun type: a suitable current balun or choke is needed when feed-line current must be blocked."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LED",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "Leuchtdiode — light-emitting diode.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Emits light when forward biased and requires current limiting. In station equipment it is commonly used for status indication and display backlighting."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FET",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "Feldeffekttransistor — field-effect transistor.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "A voltage-controlled transistor with very high input impedance."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MOSFET",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "Metall-Oxid-Halbleiter-FET — metal-oxide-semiconductor FET.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "An insulated-gate FET; common in switching and RF power stages."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BJT",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "Bipolartransistor — bipolar junction transistor.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "A current-controlled transistor using both electron and hole conduction. NPN and PNP devices are used in switching and amplifier stages."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IC",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "integrierter Schaltkreis — integrated circuit.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Many components and circuit functions fabricated on one semiconductor chip, from simple voltage regulators to complete mixers, synthesizers and processors."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NTC",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "Heißleiter — thermistor with a negative temperature coefficient.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Its resistance falls as temperature rises; used for temperature sensing and inrush limiting."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PTC",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "Kaltleiter — thermistor with a positive temperature coefficient.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Its resistance rises as temperature rises; used as a resettable fuse and for protection."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LDR",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "Fotowiderstand — light-dependent resistor.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Its resistance decreases as illumination increases. It is useful for light sensing but is slower than a photodiode or phototransistor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VDR",
|
||||||
|
"category": "Bauteil",
|
||||||
|
"meaning": "spannungsabhängiger Widerstand (Varistor) — voltage-dependent resistor.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Resistance drops sharply above a threshold voltage; clamps surges and protects against overvoltage."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "dB",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Dezibel — logarithmic ratio.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "For power ratios use 10·log₁₀(P₂/P₁); for voltage or current ratios under the same impedance use 20·log₁₀. +3 dB is about twice the power and +10 dB is ten times. Plain dB is relative, unlike dBm or dBW."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dBi",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Gewinn über Isotropstrahler — gain relative to an isotropic radiator.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "An antenna gain reference, not transmitter power. Because a half-wave dipole has about 2.15 dBi gain, a value in dBi is 2.15 dB higher than the same gain stated in dBd."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dBd",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Gewinn über Dipol — gain relative to a half-wave dipole.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "0 dBd = 2.15 dBi; the dipole already has 2.15 dBi of gain over isotropic."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dBm",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Pegel bezogen auf 1 mW — power level referenced to 1 milliwatt.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "An absolute level: 0 dBm = 1 mW, 30 dBm = 1 W."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "dBW",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Pegel bezogen auf 1 W — power level referenced to 1 watt.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "An absolute logarithmic power level: 0 dBW = 1 W and 10 dBW = 10 W. To convert dBW to dBm, add 30 dB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SWR",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Stehwellenverhältnis — standing wave ratio.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Normally the ratio of maximum to minimum RF voltage along a feed line. 1:1 is a perfect match; a higher value indicates a larger reflection, but does not by itself identify whether the antenna, feed line or another component causes the mismatch."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ERP",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "effektive Strahlungsleistung — effective radiated power.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Radiated power referenced to a half-wave dipole. In linear units, multiply power delivered to the antenna by antenna gain relative to a dipole; in decibel units, subtract feed losses and add gain in dBd."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EIRP",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "äquivalente isotrope Strahlungsleistung — equivalent isotropically radiated power.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Radiated power referenced to an ideal isotropic radiator. For the same station, EIRP is 1.64 times ERP; when both powers are expressed logarithmically, EIRP is 2.15 dB higher."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PEP",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Spitzenleistung — peak envelope power.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The average power over one RF cycle at the peak of the modulation envelope; the standard rating for SSB transmitters."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RMS",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Effektivwert — root mean square.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The equivalent DC value that produces the same heating; for a sine wave it is the peak divided by √2."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "Q",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Güte — quality factor of a resonant circuit.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "At resonance, Q = 2π times the energy stored divided by the energy lost per cycle; equivalently it is approximately resonant frequency divided by bandwidth. Higher Q gives a narrower response but also a smaller usable bandwidth."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SNR",
|
||||||
|
"category": "Messgröße & Leistung",
|
||||||
|
"meaning": "Signal-Rausch-Verhältnis — signal-to-noise ratio.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Compares wanted signal power with noise power, normally in dB and for a stated bandwidth. Narrowing the receiver bandwidth reduces admitted noise and can improve readability even though the received carrier power is unchanged."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "AC",
|
||||||
|
"category": "Strom & Spannung",
|
||||||
|
"meaning": "Alternating Current — Wechselstrom; also used for alternating voltage.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Current whose direction changes periodically. Station mains supplies are AC; power supplies rectify and smooth it to provide the DC required by most transceivers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DC",
|
||||||
|
"category": "Strom & Spannung",
|
||||||
|
"meaning": "Direct Current — Gleichstrom; also used for direct voltage.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Current with constant polarity. Most mobile amateur equipment is designed for a nominal 13.8 V DC supply, where correct polarity and adequate cable size are essential."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "MUF",
|
||||||
|
"category": "Ausbreitung",
|
||||||
|
"meaning": "höchste brauchbare Frequenz — maximum usable frequency.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The highest frequency that is refracted back to Earth for a specified path and time. Above it, that ionospheric path normally fails; the value changes with distance, solar conditions, season and time of day."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LUF",
|
||||||
|
"category": "Ausbreitung",
|
||||||
|
"meaning": "niedrigste brauchbare Frequenz — lowest usable frequency.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The lowest frequency that still provides the required signal quality on a specified path. Below it, ionospheric absorption and atmospheric noise usually dominate; it is an operational limit, not a fixed band boundary."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NVIS",
|
||||||
|
"category": "Ausbreitung",
|
||||||
|
"meaning": "Steilstrahlung — near-vertical incidence skywave.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Uses high-angle HF radiation refracted back to Earth for regional communication, typically on 160, 80, 60 or 40 m depending on conditions. It can cover the skip zone of lower-angle paths, but coverage is never guaranteed."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "DMR",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Digital Mobile Radio — digitale Sprach- und Datenbetriebsart.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "An ETSI digital mobile-radio standard using two-slot TDMA on a 12.5 kHz carrier. Amateur Tier II operation is common on repeaters and hotspots; talkgroups and timeslots must be selected correctly."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "D-STAR",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Digital Smart Technologies for Amateur Radio — digital voice and data system.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "An open amateur-radio specification developed through JARL. Callsign routing and reflector linking are common in practice; Icom is a major equipment vendor, not part of the abbreviation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "C4FM",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Continuous 4-Level Frequency Modulation — four-level digital modulation.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Maps dibits to four frequency deviations. Yaesu System Fusion uses C4FM, but the modulation name and the complete network/system are not synonymous."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "APRS",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Automatic Packet Reporting System — Positions- und Telemetriedaten über Packet.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Shares position, status, telemetry, messages and objects, commonly as 1200-baud packet on 144.800 MHz in Europe. Use an appropriate path and beacon rate to avoid unnecessary channel congestion."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TNC",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Terminal Node Controller — Packet-Radio-Modem.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Interfaces a computer or terminal with a radio and handles packet framing and modulation. Modern APRS stations often implement the same functions in software."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AX.25",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Amateurfunk-Datenprotokoll — amateur packet-radio link protocol.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Amateur adaptation of X.25 used for packet radio and APRS frames. It carries amateur callsigns in the link-layer addresses and can support connected or unconnected operation."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CTCSS",
|
||||||
|
"category": "Signalisierung & Bedienung",
|
||||||
|
"meaning": "Continuous Tone-Coded Squelch System — subaudible tone squelch.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "A continuous low audio-frequency tone, typically 67 to 254 Hz, that allows a repeater or receiver to open only for the selected tone. 'Subaudible' means it is normally filtered from the loudspeaker audio, not that it lies below the human hearing range."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DTMF",
|
||||||
|
"category": "Signalisierung & Bedienung",
|
||||||
|
"meaning": "Dual-Tone Multi-Frequency — Mehrfrequenzwahlverfahren.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Each key sends one tone from a low group and one from a high group. Amateur stations use it mainly for repeater control, link commands and legacy telephone interconnects."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "DCS",
|
||||||
|
"category": "Signalisierung & Bedienung",
|
||||||
|
"meaning": "Digital-Coded Squelch — digitale Rauschsperrencodierung.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Sends a continuous low-rate digital code with analogue FM so a receiver opens only for the selected code. It serves a similar access-filtering purpose to CTCSS but is not a digital voice mode."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PTT",
|
||||||
|
"category": "Baugruppe & Bedienung",
|
||||||
|
"meaning": "Push To Talk — Sendetaste.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Keys the transmitter while pressed. Confirm that the frequency is clear before using it, and allow a brief pause after keying a repeater so the first syllable is not clipped."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SDR",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Software Defined Radio — softwaredefiniertes Funkgerät.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Implements functions such as mixing, filtering and demodulation in software or programmable logic. An SDR still needs suitable analogue RF filtering, gain control and conversion hardware."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RIT",
|
||||||
|
"category": "Baugruppe & Bedienung",
|
||||||
|
"meaning": "Receiver Incremental Tuning — Empfänger-Feinverstimmung.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Shifts only the receive frequency while leaving transmit frequency unchanged. It is useful when the other station is slightly off frequency; reset it afterward to avoid later tuning confusion."
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "ITU",
|
||||||
|
"category": "Organisation & Vorschrift",
|
||||||
|
"meaning": "Internationale Fernmeldeunion — International Telecommunication Union.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "UN agency that allocates spectrum worldwide and defines the three ITU regions."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IARU",
|
||||||
|
"category": "Organisation & Vorschrift",
|
||||||
|
"meaning": "Internationale Amateur-Radio-Union — International Amateur Radio Union.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Worldwide federation of national amateur-radio societies; coordinates band plans and represents amateurs at the ITU."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CEPT",
|
||||||
|
"category": "Organisation & Vorschrift",
|
||||||
|
"meaning": "Europäische Konferenz der Verwaltungen für Post und Telekommunikation.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Its recommendations let an amateur licensee operate temporarily in other member countries without applying for a separate licence."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BNetzA",
|
||||||
|
"category": "Organisation & Vorschrift",
|
||||||
|
"meaning": "Bundesnetzagentur — the German telecommunications regulator.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Issues amateur licences and call signs in Germany and supervises the service."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EMV",
|
||||||
|
"category": "Organisation & Vorschrift",
|
||||||
|
"meaning": "elektromagnetische Verträglichkeit — electromagnetic compatibility (EMC).",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "The ability of equipment to operate without causing or suffering undue interference."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EMVU",
|
||||||
|
"category": "Organisation & Vorschrift",
|
||||||
|
"meaning": "elektromagnetische Umweltverträglichkeit — EMF exposure compliance.",
|
||||||
|
"exam": true,
|
||||||
|
"explanation": "Assessment and limits for human exposure to the electromagnetic fields a station produces."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AFu",
|
||||||
|
"category": "Organisation & Vorschrift",
|
||||||
|
"meaning": "Amateurfunk — amateur radio.",
|
||||||
|
"exam": false,
|
||||||
|
"explanation": "Common German written abbreviation in terms such as AFuG and AFuV. It is mainly documentation shorthand rather than something spoken during a contact."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -193,6 +193,48 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
|
{"N": ["NA101"], "E": ["EA202"], "A": ["AA303"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_split_class_packages_ship_topic_subdeck_tree(self):
|
||||||
|
# The E and A packages each stay one file but carry a deck tree:
|
||||||
|
# the anchoring class deck plus one sub-deck per first-level
|
||||||
|
# topic, with each card filed under its topic sub-deck.
|
||||||
|
self._build_all()
|
||||||
|
for letter in ("e", "a"):
|
||||||
|
apkg = self.out_dir / f"amateurfunk-technische-kenntnisse-{letter}.apkg"
|
||||||
|
db_path, _media, _names = extract_collection(apkg, self.root / letter)
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
decks = json.loads(
|
||||||
|
conn.execute("select decks from col").fetchone()[0]
|
||||||
|
)
|
||||||
|
deck_names = {d["name"] for d in decks.values()}
|
||||||
|
id_to_name = {int(k): d["name"] for k, d in decks.items()}
|
||||||
|
card_decks = sorted(
|
||||||
|
id_to_name[row[0]]
|
||||||
|
for row in conn.execute("select did from cards")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
root = f"Amateurfunk::Technische Kenntnisse::{letter.upper()}"
|
||||||
|
self.assertEqual(deck_names, {root, f"{root}::Grundlagen"})
|
||||||
|
self.assertEqual(card_decks, [f"{root}::Grundlagen"])
|
||||||
|
|
||||||
|
def test_single_topic_classes_ship_one_flat_deck(self):
|
||||||
|
# N (and E) are not split: one deck, card filed directly in it.
|
||||||
|
self._build_all()
|
||||||
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
||||||
|
db_path, _media, _names = extract_collection(apkg, self.root / "n")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
decks = json.loads(
|
||||||
|
conn.execute("select decks from col").fetchone()[0]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(
|
||||||
|
{d["name"] for d in decks.values()},
|
||||||
|
{"Amateurfunk::Technische Kenntnisse::N"},
|
||||||
|
)
|
||||||
|
|
||||||
def test_apkg_contains_notes_cards_and_media(self):
|
def test_apkg_contains_notes_cards_and_media(self):
|
||||||
self._build_all()
|
self._build_all()
|
||||||
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-n.apkg"
|
||||||
@@ -378,6 +420,50 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
self.assertNotIn("af-explanation-low-confidence", back_high)
|
self.assertNotIn("af-explanation-low-confidence", back_high)
|
||||||
self.assertNotIn("low confidence", back_high)
|
self.assertNotIn("low confidence", back_high)
|
||||||
|
|
||||||
|
def test_cc_by_credit_gated_on_provenance_not_source_domain(self):
|
||||||
|
"""The CC BY derivative-work credit must follow explicit
|
||||||
|
provenance, not the citation domain: an explanation merely
|
||||||
|
citing a 50ohm.de page is NOT a derivative work and must not
|
||||||
|
carry the credit, while one marked as derived from a worked
|
||||||
|
solution must."""
|
||||||
|
derived = {
|
||||||
|
"revision": 2,
|
||||||
|
"explanation": "Worked-solution port.",
|
||||||
|
"source": "https://50ohm.de/NEA_x.html#AD106",
|
||||||
|
"confidence": 8,
|
||||||
|
"provenance": aa.PROVENANCE_50OHM_SOLUTION,
|
||||||
|
}
|
||||||
|
cited_only = {
|
||||||
|
"revision": 1,
|
||||||
|
"explanation": "Independently written, just cites a study page.",
|
||||||
|
"source": "https://50ohm.de/NEA_x.html#XX101",
|
||||||
|
"confidence": 8,
|
||||||
|
}
|
||||||
|
derived_html = aa.render_explanation(derived)
|
||||||
|
cited_html = aa.render_explanation(cited_only)
|
||||||
|
self.assertIn("af-explanation-credit", derived_html)
|
||||||
|
self.assertIn("CC BY 4.0", derived_html)
|
||||||
|
self.assertIn("50ohm.de-Autorenteam", derived_html)
|
||||||
|
self.assertNotIn("af-explanation-credit", cited_html)
|
||||||
|
self.assertNotIn("translated", cited_html)
|
||||||
|
|
||||||
|
def test_root_deck_description_carries_attribution(self):
|
||||||
|
self._build_all()
|
||||||
|
apkg = self.out_dir / "amateurfunk-betriebliche-kenntnisse.apkg"
|
||||||
|
db_path, _media, _names = extract_collection(apkg, self.root / "b")
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
descs = [d["desc"] for d in decks.values() if d["desc"]]
|
||||||
|
self.assertEqual(len(descs), 1) # only the root deck carries it
|
||||||
|
desc = descs[0]
|
||||||
|
self.assertIn("CC BY 4.0", desc)
|
||||||
|
self.assertIn("govdata.de/dl-de/by-2-0", desc)
|
||||||
|
self.assertIn("50ohm.de-Autorenteam", desc)
|
||||||
|
self.assertIn("Bundesnetzagentur", desc)
|
||||||
|
|
||||||
def test_missing_explanation_leaves_card_unchanged(self):
|
def test_missing_explanation_leaves_card_unchanged(self):
|
||||||
self._build_all()
|
self._build_all()
|
||||||
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
|
apkg = self.out_dir / "amateurfunk-technische-kenntnisse-e.apkg"
|
||||||
@@ -459,6 +545,48 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
self._build_all()
|
self._build_all()
|
||||||
self.assertIn("author", str(ctx.exception))
|
self.assertIn("author", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_schema_accepts_valid_provenance(self):
|
||||||
|
path = self.root / "exp_provenance.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"NA101": {
|
||||||
|
"revision": 1, "explanation": "ok", "source": "ok",
|
||||||
|
"confidence": 5, "provenance": aa.PROVENANCE_50OHM_SOLUTION,
|
||||||
|
}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
loaded = aa.load_explanations(path)
|
||||||
|
self.assertEqual(
|
||||||
|
loaded["NA101"]["provenance"], aa.PROVENANCE_50OHM_SOLUTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schema_rejects_unknown_provenance_value(self):
|
||||||
|
path = self.root / "exp_badprov.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"NA101": {
|
||||||
|
"revision": 1, "explanation": "ok", "source": "ok",
|
||||||
|
"confidence": 5, "provenance": "made-up",
|
||||||
|
}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with self.assertRaises(aa.AnkiBuildError):
|
||||||
|
aa.load_explanations(path)
|
||||||
|
|
||||||
|
def test_schema_rejects_unhashable_provenance(self):
|
||||||
|
# A malformed but valid-JSON provenance value (list/object) is
|
||||||
|
# unhashable; the validator must surface AnkiBuildError, never a
|
||||||
|
# raw TypeError from the set-membership test.
|
||||||
|
for bad in ([], {}):
|
||||||
|
path = self.root / "exp_unhashable.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"NA101": {
|
||||||
|
"revision": 1, "explanation": "ok", "source": "ok",
|
||||||
|
"confidence": 5, "provenance": bad,
|
||||||
|
}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with self.assertRaises(aa.AnkiBuildError):
|
||||||
|
aa.load_explanations(path)
|
||||||
|
|
||||||
def test_invalid_explanation_schema_raises_build_error(self):
|
def test_invalid_explanation_schema_raises_build_error(self):
|
||||||
self.explanations_path.write_text(
|
self.explanations_path.write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
@@ -535,6 +663,162 @@ class TestAnkiBuild(unittest.TestCase):
|
|||||||
self.assertFalse(all(byte_equal))
|
self.assertFalse(all(byte_equal))
|
||||||
|
|
||||||
|
|
||||||
|
class TestIntrinsicSvgSize(unittest.TestCase):
|
||||||
|
"""Parsing of native figure dimensions — SVG only."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
|
||||||
|
def _write(self, name, body):
|
||||||
|
path = self.root / name
|
||||||
|
path.write_text(body, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def test_svg_unitless_width_and_height(self):
|
||||||
|
p = self._write("a.svg", '<svg width="115.5" height="120"><g/></svg>')
|
||||||
|
self.assertEqual(aa.intrinsic_svg_size(p), (115.5, 120.0))
|
||||||
|
|
||||||
|
def test_svg_px_suffix_is_tolerated(self):
|
||||||
|
p = self._write("a.svg", '<svg width="200px" height="80px"></svg>')
|
||||||
|
self.assertEqual(aa.intrinsic_svg_size(p), (200.0, 80.0))
|
||||||
|
|
||||||
|
def test_svg_percentage_dimension_is_rejected(self):
|
||||||
|
p = self._write("a.svg", '<svg width="100%" height="100%"></svg>')
|
||||||
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
||||||
|
|
||||||
|
def test_svg_missing_dimension_returns_none(self):
|
||||||
|
p = self._write("a.svg", '<svg width="200"></svg>')
|
||||||
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
||||||
|
|
||||||
|
def test_svg_without_root_tag_returns_none(self):
|
||||||
|
p = self._write("a.svg", "not an svg at all")
|
||||||
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
||||||
|
|
||||||
|
def test_svg_leading_decimal_is_accepted(self):
|
||||||
|
p = self._write("a.svg", '<svg width=".5" height="10"></svg>')
|
||||||
|
self.assertEqual(aa.intrinsic_svg_size(p), (0.5, 10.0))
|
||||||
|
|
||||||
|
def test_svg_malformed_number_returns_none_not_raises(self):
|
||||||
|
# Permissive matching used to let "1.2.3" reach float() and abort
|
||||||
|
# the build; now it falls back to None like any other bad value.
|
||||||
|
for bad in ('1.2.3', '.', '1.', '1..2'):
|
||||||
|
p = self._write("a.svg", f'<svg width="{bad}" height="10"></svg>')
|
||||||
|
self.assertIsNone(aa.intrinsic_svg_size(p), bad)
|
||||||
|
|
||||||
|
def test_non_svg_is_not_sized(self):
|
||||||
|
# A PNG with a perfectly valid-looking name is still not sized:
|
||||||
|
# we deliberately only enlarge SVGs.
|
||||||
|
p = self.root / "a.png"
|
||||||
|
p.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
|
||||||
|
self.assertIsNone(aa.intrinsic_svg_size(p))
|
||||||
|
|
||||||
|
|
||||||
|
class TestScaleForSize(unittest.TestCase):
|
||||||
|
"""The clamped enlargement factor."""
|
||||||
|
|
||||||
|
def test_small_figure_gets_full_multiplier(self):
|
||||||
|
self.assertEqual(aa.scale_for_size(115, 120), aa.MEDIA_SCALE)
|
||||||
|
|
||||||
|
def test_wide_figure_is_clamped_by_max_width(self):
|
||||||
|
self.assertEqual(aa.scale_for_size(aa.MEDIA_MAX_WIDTH, 50), 1.0)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
aa.scale_for_size(aa.MEDIA_MAX_WIDTH / 2, 50), 2.0
|
||||||
|
)
|
||||||
|
# Between half-cap and cap: enlarged only until width hits the cap.
|
||||||
|
wide = aa.MEDIA_MAX_WIDTH * 0.75
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
aa.scale_for_size(wide, 50), aa.MEDIA_MAX_WIDTH / wide
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tall_figure_is_clamped_by_max_height(self):
|
||||||
|
self.assertEqual(aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT), 1.0)
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
aa.scale_for_size(50, aa.MEDIA_MAX_HEIGHT / 2), 2.0
|
||||||
|
)
|
||||||
|
tall = aa.MEDIA_MAX_HEIGHT * 0.75
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
aa.scale_for_size(50, tall), aa.MEDIA_MAX_HEIGHT / tall
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_large_catalog_schematic_grows_toward_cap(self):
|
||||||
|
# NG205-like (635x460): with the 1024x768 cap it now enlarges,
|
||||||
|
# bounded by width — up to 1024 px wide.
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
aa.scale_for_size(635, 460), aa.MEDIA_MAX_WIDTH / 635
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_never_shrinks_below_native(self):
|
||||||
|
self.assertEqual(aa.scale_for_size(5000, 5000), 1.0)
|
||||||
|
|
||||||
|
def test_degenerate_size_is_native(self):
|
||||||
|
self.assertEqual(aa.scale_for_size(0, 0), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestImageHtmlScaling(unittest.TestCase):
|
||||||
|
"""End-to-end `<img>` emission and the per-path size cache."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.media_dir = Path(self.tmp.name) / "svgs"
|
||||||
|
self.media_dir.mkdir()
|
||||||
|
|
||||||
|
def _write(self, name, body):
|
||||||
|
(self.media_dir / name).write_text(body, encoding="utf-8")
|
||||||
|
|
||||||
|
def test_small_figure_emits_doubled_width(self):
|
||||||
|
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
|
||||||
|
media = aa.MediaRegistry(self.media_dir)
|
||||||
|
self.assertIn('width="230"', media.image_html("S_q"))
|
||||||
|
|
||||||
|
def test_figure_at_cap_emits_no_width(self):
|
||||||
|
# Exactly at the cap → scale 1.0 → native size, no width.
|
||||||
|
self._write(
|
||||||
|
"L_q.svg",
|
||||||
|
f'<svg width="{aa.MEDIA_MAX_WIDTH}" '
|
||||||
|
f'height="{aa.MEDIA_MAX_HEIGHT}"></svg>',
|
||||||
|
)
|
||||||
|
media = aa.MediaRegistry(self.media_dir)
|
||||||
|
html = media.image_html("L_q")
|
||||||
|
self.assertIn('class="af-media"', html)
|
||||||
|
self.assertNotIn("width=", html)
|
||||||
|
|
||||||
|
def test_png_is_not_scaled(self):
|
||||||
|
# Only a PNG for this stem (no .svg sibling): emitted at native
|
||||||
|
# size with no width, because we never size raster figures.
|
||||||
|
(self.media_dir / "P_q.png").write_bytes(
|
||||||
|
b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
|
||||||
|
)
|
||||||
|
media = aa.MediaRegistry(self.media_dir)
|
||||||
|
html = media.image_html("P_q")
|
||||||
|
self.assertIn('src="P_q.png"', html)
|
||||||
|
self.assertNotIn("width=", html)
|
||||||
|
|
||||||
|
def test_unreadable_size_falls_back_to_no_width(self):
|
||||||
|
self._write("U_q.svg", '<svg width="100%" height="100%"></svg>')
|
||||||
|
media = aa.MediaRegistry(self.media_dir)
|
||||||
|
self.assertNotIn("width=", media.image_html("U_q"))
|
||||||
|
|
||||||
|
def test_malformed_number_falls_back_without_raising(self):
|
||||||
|
self._write("M_q.svg", '<svg width="1.2.3" height="10"></svg>')
|
||||||
|
media = aa.MediaRegistry(self.media_dir)
|
||||||
|
html = media.image_html("M_q") # must not raise
|
||||||
|
self.assertIn('src="M_q.svg"', html)
|
||||||
|
self.assertNotIn("width=", html)
|
||||||
|
|
||||||
|
def test_size_is_read_once_per_path(self):
|
||||||
|
self._write("S_q.svg", '<svg width="115" height="120"></svg>')
|
||||||
|
media = aa.MediaRegistry(self.media_dir)
|
||||||
|
with patch.object(
|
||||||
|
aa, "intrinsic_svg_size", wraps=aa.intrinsic_svg_size
|
||||||
|
) as spy:
|
||||||
|
media.image_html("S_q")
|
||||||
|
media.image_html("S_q")
|
||||||
|
self.assertEqual(spy.call_count, 1)
|
||||||
|
|
||||||
|
|
||||||
class TestRealExplanationsFile(unittest.TestCase):
|
class TestRealExplanationsFile(unittest.TestCase):
|
||||||
"""Validate the repo's actual explanations.json against the live catalog.
|
"""Validate the repo's actual explanations.json against the live catalog.
|
||||||
|
|
||||||
@@ -575,6 +859,34 @@ class TestRealExplanationsFile(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual([], offenders)
|
self.assertEqual([], offenders)
|
||||||
|
|
||||||
|
def test_safety_distance_block_uses_transmitter_power_not_erp(self):
|
||||||
|
"""AK106-AK112 must feed the EIRP formula with transmitter power.
|
||||||
|
|
||||||
|
The relation is `P_EIRP = P_S · 10^((g_d+2.15-a)/10)` with `P_S`
|
||||||
|
the transmitter (Sender) power; writing `P_EIRP = P_ERP · 10^…`
|
||||||
|
double-counts the antenna gain (ERP already includes it). The
|
||||||
|
upstream 50ohm solutions carry exactly this mislabel, so guard
|
||||||
|
the whole block against it regressing back in.
|
||||||
|
"""
|
||||||
|
repo = Path(__file__).resolve().parent
|
||||||
|
explanations_path = repo / "explanations.json"
|
||||||
|
if not explanations_path.exists():
|
||||||
|
self.skipTest("explanations.json not available")
|
||||||
|
explanations = aa.load_explanations(explanations_path)
|
||||||
|
block = [f"AK1{n:02d}" for n in range(6, 13)] # AK106..AK112
|
||||||
|
for key in block:
|
||||||
|
if key not in explanations:
|
||||||
|
continue
|
||||||
|
body = explanations[key]["explanation"]
|
||||||
|
self.assertIn(
|
||||||
|
"P_S", body,
|
||||||
|
f"{key}: EIRP should be computed from transmitter power P_S",
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
r"P_\mathrm{EIRP} = P_\mathrm{ERP}", body,
|
||||||
|
f"{key}: EIRP must not be derived by scaling ERP by the gain",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""Tests for amateurfunk_shorthand.
|
||||||
|
|
||||||
|
Like the multiple-choice tests, these inspect the generated `.apkg`
|
||||||
|
directly (ZIP entries plus the SQLite collection) and never require
|
||||||
|
Anki itself. They also validate the repo's real `shorthand.json`
|
||||||
|
against the schema so editorial mistakes surface in CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import amateurfunk_shorthand as sh
|
||||||
|
|
||||||
|
|
||||||
|
def make_shorthand():
|
||||||
|
return {
|
||||||
|
"q_codes": [
|
||||||
|
{
|
||||||
|
"code": "QSO",
|
||||||
|
"question": "Can you communicate directly with ...?",
|
||||||
|
"statement": "I can communicate directly with ...",
|
||||||
|
"exam": True,
|
||||||
|
"explanation": "The radio contact itself.",
|
||||||
|
"example": "DL1ABC: `Tnx for QSO.`",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QRG",
|
||||||
|
"question": "Will you tell me my exact frequency?",
|
||||||
|
"statement": "Your exact frequency is ... kHz.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"abbreviations": [
|
||||||
|
{
|
||||||
|
"code": "73",
|
||||||
|
"meaning": "Best regards.",
|
||||||
|
"exam": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SK",
|
||||||
|
"meaning": "End of contact; also silent key.",
|
||||||
|
"tags": ["prosign"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_shorthand(root: Path, data=None):
|
||||||
|
path = root / "shorthand.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(data if data is not None else make_shorthand()),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def extract_collection(apkg_path: Path, tmp: Path):
|
||||||
|
with zipfile.ZipFile(apkg_path) as zf:
|
||||||
|
zf.extract("collection.anki2", tmp)
|
||||||
|
media = json.loads(zf.read("media").decode("utf-8"))
|
||||||
|
names = set(zf.namelist())
|
||||||
|
return tmp / "collection.anki2", media, names
|
||||||
|
|
||||||
|
|
||||||
|
class TestShorthandBuild(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
self.shorthand_path = write_shorthand(self.root)
|
||||||
|
self.out_dir = self.root / "anki"
|
||||||
|
|
||||||
|
def _build(self, **kwargs):
|
||||||
|
return sh.build_deck(
|
||||||
|
self.shorthand_path,
|
||||||
|
self.out_dir,
|
||||||
|
self.root / "data", # absent → epoch fallback
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _collection(self):
|
||||||
|
apkg = next(self.out_dir.glob("*.apkg"))
|
||||||
|
return extract_collection(apkg, self.root)
|
||||||
|
|
||||||
|
def test_qgroup_yields_two_notes_abbrev_yields_one(self):
|
||||||
|
result = self._build()
|
||||||
|
# 2 Q-groups → 4 notes, 2 abbreviations → 2 notes.
|
||||||
|
self.assertEqual(result["notes"], 6)
|
||||||
|
# Two cards per note (forward + reverse).
|
||||||
|
self.assertEqual(result["cards"], 12)
|
||||||
|
self.assertEqual(result["q_codes"], 2)
|
||||||
|
self.assertEqual(result["abbreviations"], 2)
|
||||||
|
|
||||||
|
def test_qgroup_statement_and_question_become_distinct_notes(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
# sfld has INTEGER affinity, so numeric codes (e.g. 73) come
|
||||||
|
# back as ints; stringify before comparing.
|
||||||
|
sflds = sorted(str(r[0]) for r in conn.execute("select sfld from notes"))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertIn("QSO", sflds)
|
||||||
|
self.assertIn("QSO?", sflds)
|
||||||
|
|
||||||
|
def test_each_note_has_two_cards_one_per_template(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"select sfld, ord from notes join cards on cards.nid = notes.id"
|
||||||
|
" order by sfld, ord"
|
||||||
|
).fetchall()
|
||||||
|
ords_for_qso = sorted(o for s, o in rows if s == "QSO")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(ords_for_qso, [0, 1])
|
||||||
|
|
||||||
|
def test_model_has_two_templates_and_per_template_requirements(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
models = json.loads(
|
||||||
|
conn.execute("select models from col").fetchone()[0]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
model = next(iter(models.values()))
|
||||||
|
self.assertEqual([t["name"] for t in model["tmpls"]], ["Bedeutung", "Kürzel"])
|
||||||
|
# Forward template needs Code (field 0); reverse needs Meaning (1).
|
||||||
|
self.assertEqual(model["req"], [[0, "all", [0]], [1, "all", [1]]])
|
||||||
|
|
||||||
|
def test_tags_mark_kind_exam_and_extra_editorial_tags(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
tags = dict(
|
||||||
|
conn.execute("select sfld, tags from notes").fetchall()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertIn(" q-gruppe ", tags["QSO"])
|
||||||
|
self.assertIn(" pruefung ", tags["QSO"])
|
||||||
|
self.assertIn(" abkuerzung ", tags["SK"])
|
||||||
|
self.assertIn(" prosign ", tags["SK"])
|
||||||
|
# QRG is not an exam code → no pruefung tag.
|
||||||
|
self.assertNotIn("pruefung", tags["QRG"])
|
||||||
|
|
||||||
|
def test_no_media_entries_in_package(self):
|
||||||
|
self._build()
|
||||||
|
_db, media, names = self._collection()
|
||||||
|
self.assertEqual(media, {})
|
||||||
|
self.assertEqual(names, {"collection.anki2", "media"})
|
||||||
|
|
||||||
|
def test_example_backticks_become_code_and_text_is_escaped(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
flds = next(
|
||||||
|
r[0] for r in conn.execute("select flds from notes")
|
||||||
|
if r[0].startswith("QSO" + sh.FIELD_SEP)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertIn("<code>Tnx for QSO.</code>", flds)
|
||||||
|
|
||||||
|
def test_build_is_byte_deterministic_for_same_input(self):
|
||||||
|
out_a = self.root / "a"
|
||||||
|
out_b = self.root / "b"
|
||||||
|
sh.build_deck(self.shorthand_path, out_a, self.root / "data", override_epoch=42)
|
||||||
|
sh.build_deck(self.shorthand_path, out_b, self.root / "data", override_epoch=42)
|
||||||
|
for first in sorted(out_a.glob("*.apkg")):
|
||||||
|
second = out_b / first.name
|
||||||
|
self.assertEqual(first.read_bytes(), second.read_bytes(), first.name)
|
||||||
|
|
||||||
|
def test_stable_ids_are_unchanged_across_builds(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _m, _n = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
first = sorted(r[0] for r in conn.execute("select id from notes"))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
# Rebuild into a fresh dir; ids derive from the code form only.
|
||||||
|
out_b = self.root / "again"
|
||||||
|
sh.build_deck(self.shorthand_path, out_b, self.root / "data")
|
||||||
|
db_b, _m, _n = extract_collection(next(out_b.glob("*.apkg")), out_b)
|
||||||
|
conn = sqlite3.connect(db_b)
|
||||||
|
try:
|
||||||
|
second = sorted(r[0] for r in conn.execute("select id from notes"))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
def test_duplicate_code_form_is_rejected(self):
|
||||||
|
data = make_shorthand()
|
||||||
|
data["abbreviations"].append({"code": "73", "meaning": "duplicate"})
|
||||||
|
path = write_shorthand(self.root, data)
|
||||||
|
with self.assertRaises(sh.AnkiBuildError) as ctx:
|
||||||
|
sh.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
self.assertIn("73", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_qgroup_question_form_collides_with_abbrev_question_mark(self):
|
||||||
|
# A Q-group `QRX` expands to `QRX?`; an abbreviation literally
|
||||||
|
# named `QRX?` would collide and must be rejected.
|
||||||
|
data = make_shorthand()
|
||||||
|
data["q_codes"].append(
|
||||||
|
{"code": "QRX", "question": "When?", "statement": "Wait."}
|
||||||
|
)
|
||||||
|
data["abbreviations"].append({"code": "QRX?", "meaning": "clash"})
|
||||||
|
path = write_shorthand(self.root, data)
|
||||||
|
with self.assertRaises(sh.AnkiBuildError):
|
||||||
|
sh.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
|
||||||
|
def test_missing_required_field_is_rejected(self):
|
||||||
|
data = make_shorthand()
|
||||||
|
del data["q_codes"][0]["statement"]
|
||||||
|
path = write_shorthand(self.root, data)
|
||||||
|
with self.assertRaises(sh.AnkiBuildError) as ctx:
|
||||||
|
sh.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
self.assertIn("statement", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_unknown_field_is_rejected(self):
|
||||||
|
data = make_shorthand()
|
||||||
|
data["abbreviations"][0]["author"] = "claude"
|
||||||
|
path = write_shorthand(self.root, data)
|
||||||
|
with self.assertRaises(sh.AnkiBuildError) as ctx:
|
||||||
|
sh.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
self.assertIn("author", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_non_bool_exam_is_rejected(self):
|
||||||
|
data = make_shorthand()
|
||||||
|
data["abbreviations"][0]["exam"] = "yes"
|
||||||
|
path = write_shorthand(self.root, data)
|
||||||
|
with self.assertRaises(sh.AnkiBuildError):
|
||||||
|
sh.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
|
||||||
|
def test_missing_file_is_a_hard_error(self):
|
||||||
|
with self.assertRaises(sh.AnkiBuildError):
|
||||||
|
sh.build_deck(
|
||||||
|
self.root / "nope.json", self.out_dir, self.root / "data"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_main_returns_success(self):
|
||||||
|
rc = sh.main([
|
||||||
|
"--shorthand", str(self.shorthand_path),
|
||||||
|
"--out", str(self.out_dir),
|
||||||
|
"--data", str(self.root / "data"),
|
||||||
|
])
|
||||||
|
self.assertEqual(rc, sh.EXIT_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealShorthandFile(unittest.TestCase):
|
||||||
|
"""Validate the repo's actual shorthand.json against the schema."""
|
||||||
|
|
||||||
|
def test_repo_shorthand_builds(self):
|
||||||
|
repo = Path(__file__).resolve().parent
|
||||||
|
path = repo / "shorthand.json"
|
||||||
|
if not path.exists():
|
||||||
|
self.skipTest("shorthand.json not available")
|
||||||
|
q_codes, abbreviations = sh.load_shorthand(path)
|
||||||
|
notes = sh.build_notes(q_codes, abbreviations)
|
||||||
|
self.assertEqual(len(notes), 2 * len(q_codes) + len(abbreviations))
|
||||||
|
# Every note carries exactly two cards.
|
||||||
|
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Tests for amateurfunk_technical.
|
||||||
|
|
||||||
|
Like the other deck tests, these inspect the generated `.apkg` directly
|
||||||
|
(ZIP entries plus the SQLite collection) and never require Anki. They
|
||||||
|
also validate the repo's real `technical.json` against the schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import amateurfunk_technical as tech
|
||||||
|
from amateurfunk_anki import FIELD_SEP
|
||||||
|
|
||||||
|
|
||||||
|
def make_technical():
|
||||||
|
return {
|
||||||
|
"terms": [
|
||||||
|
{
|
||||||
|
"code": "SSB",
|
||||||
|
"category": "Betriebsart",
|
||||||
|
"meaning": "Single sideband.",
|
||||||
|
"exam": True,
|
||||||
|
"explanation": "One sideband, carrier suppressed.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NF",
|
||||||
|
"category": "Signal & Frequenz",
|
||||||
|
"meaning": "Niederfrequenz — audio frequency.",
|
||||||
|
"exam": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SDR",
|
||||||
|
"category": "Digital & Daten",
|
||||||
|
"meaning": "Software defined radio.",
|
||||||
|
"tags": ["modern"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_technical(root: Path, data=None):
|
||||||
|
path = root / "technical.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(data if data is not None else make_technical()),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def extract_collection(apkg_path: Path, tmp: Path):
|
||||||
|
with zipfile.ZipFile(apkg_path) as zf:
|
||||||
|
zf.extract("collection.anki2", tmp)
|
||||||
|
media = json.loads(zf.read("media").decode("utf-8"))
|
||||||
|
names = set(zf.namelist())
|
||||||
|
return tmp / "collection.anki2", media, names
|
||||||
|
|
||||||
|
|
||||||
|
class TestTechnicalBuild(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.tmp.cleanup)
|
||||||
|
self.root = Path(self.tmp.name)
|
||||||
|
self.path = write_technical(self.root)
|
||||||
|
self.out_dir = self.root / "anki"
|
||||||
|
|
||||||
|
def _build(self, **kwargs):
|
||||||
|
return tech.build_deck(self.path, self.out_dir, self.root / "data", **kwargs)
|
||||||
|
|
||||||
|
def _collection(self):
|
||||||
|
apkg = next(self.out_dir.glob("*.apkg"))
|
||||||
|
return extract_collection(apkg, self.root)
|
||||||
|
|
||||||
|
def test_one_note_two_cards_per_term(self):
|
||||||
|
result = self._build()
|
||||||
|
self.assertEqual(result["terms"], 3)
|
||||||
|
self.assertEqual(result["notes"], 3)
|
||||||
|
self.assertEqual(result["cards"], 6)
|
||||||
|
|
||||||
|
def test_deck_and_model_are_distinct_from_operating_deck(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
decks = json.loads(conn.execute("select decks from col").fetchone()[0])
|
||||||
|
models = json.loads(conn.execute("select models from col").fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
self.assertIn(tech.DECK_NAME, [d["name"] for d in decks.values()])
|
||||||
|
self.assertEqual(
|
||||||
|
next(iter(models.values()))["name"], tech.MODEL_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_category_becomes_kind_field_and_tag(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = dict(
|
||||||
|
(r[0], (r[1], r[2]))
|
||||||
|
for r in conn.execute("select sfld, flds, tags from notes")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
flds, tags = rows["SSB"]
|
||||||
|
# Kind field (index 2) carries the German category.
|
||||||
|
self.assertEqual(flds.split(FIELD_SEP)[2], "Betriebsart")
|
||||||
|
self.assertIn(" technik ", tags)
|
||||||
|
self.assertIn(" kategorie-betriebsart ", tags)
|
||||||
|
self.assertIn(" pruefung ", tags)
|
||||||
|
# A non-exam term carries no pruefung tag but keeps extra tags.
|
||||||
|
_flds, sdr_tags = rows["SDR"]
|
||||||
|
self.assertNotIn("pruefung", sdr_tags)
|
||||||
|
self.assertIn(" modern ", sdr_tags)
|
||||||
|
|
||||||
|
def test_each_note_has_forward_and_reverse_card(self):
|
||||||
|
self._build()
|
||||||
|
db_path, _media, _names = self._collection()
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"select sfld, ord from notes join cards on cards.nid = notes.id"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
ords_for_ssb = sorted(o for s, o in rows if s == "SSB")
|
||||||
|
self.assertEqual(ords_for_ssb, [0, 1])
|
||||||
|
|
||||||
|
def test_no_media_in_package(self):
|
||||||
|
self._build()
|
||||||
|
_db, media, names = self._collection()
|
||||||
|
self.assertEqual(media, {})
|
||||||
|
self.assertEqual(names, {"collection.anki2", "media"})
|
||||||
|
|
||||||
|
def test_build_is_byte_deterministic_for_same_input(self):
|
||||||
|
out_a = self.root / "a"
|
||||||
|
out_b = self.root / "b"
|
||||||
|
tech.build_deck(self.path, out_a, self.root / "data", override_epoch=99)
|
||||||
|
tech.build_deck(self.path, out_b, self.root / "data", override_epoch=99)
|
||||||
|
for first in sorted(out_a.glob("*.apkg")):
|
||||||
|
self.assertEqual(
|
||||||
|
first.read_bytes(), (out_b / first.name).read_bytes(), first.name
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ids_are_namespaced_apart_from_operating_deck(self):
|
||||||
|
# A code that exists in both decks (e.g. a hypothetical clash)
|
||||||
|
# must not share a GUID across the two namespaces.
|
||||||
|
from amateurfunk_shorthand import make_note as sh_make_note
|
||||||
|
op = sh_make_note("SSB", "x", "k", "", None, None, " t ", "shorthand")
|
||||||
|
te = tech.build_notes(
|
||||||
|
[{"code": "SSB", "category": "Betriebsart", "meaning": "x"}]
|
||||||
|
)[0]
|
||||||
|
self.assertNotEqual(op["guid"], te["guid"])
|
||||||
|
self.assertNotEqual(op["note_id"], te["note_id"])
|
||||||
|
|
||||||
|
def test_duplicate_code_is_rejected(self):
|
||||||
|
data = make_technical()
|
||||||
|
data["terms"].append(
|
||||||
|
{"code": "SSB", "category": "Betriebsart", "meaning": "dup"}
|
||||||
|
)
|
||||||
|
path = write_technical(self.root, data)
|
||||||
|
with self.assertRaises(tech.AnkiBuildError) as ctx:
|
||||||
|
tech.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
self.assertIn("SSB", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_missing_category_is_rejected(self):
|
||||||
|
data = make_technical()
|
||||||
|
del data["terms"][0]["category"]
|
||||||
|
path = write_technical(self.root, data)
|
||||||
|
with self.assertRaises(tech.AnkiBuildError) as ctx:
|
||||||
|
tech.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
self.assertIn("category", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_unknown_field_is_rejected(self):
|
||||||
|
data = make_technical()
|
||||||
|
data["terms"][0]["author"] = "claude"
|
||||||
|
path = write_technical(self.root, data)
|
||||||
|
with self.assertRaises(tech.AnkiBuildError) as ctx:
|
||||||
|
tech.build_deck(path, self.out_dir, self.root / "data")
|
||||||
|
self.assertIn("author", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_missing_file_is_a_hard_error(self):
|
||||||
|
with self.assertRaises(tech.AnkiBuildError):
|
||||||
|
tech.build_deck(self.root / "nope.json", self.out_dir, self.root / "data")
|
||||||
|
|
||||||
|
def test_main_returns_success(self):
|
||||||
|
rc = tech.main([
|
||||||
|
"--technical", str(self.path),
|
||||||
|
"--out", str(self.out_dir),
|
||||||
|
"--data", str(self.root / "data"),
|
||||||
|
])
|
||||||
|
self.assertEqual(rc, tech.EXIT_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealTechnicalFile(unittest.TestCase):
|
||||||
|
"""Validate the repo's actual technical.json against the schema."""
|
||||||
|
|
||||||
|
def test_repo_technical_builds(self):
|
||||||
|
repo = Path(__file__).resolve().parent
|
||||||
|
path = repo / "technical.json"
|
||||||
|
if not path.exists():
|
||||||
|
self.skipTest("technical.json not available")
|
||||||
|
terms = tech.load_technical(path)
|
||||||
|
notes = tech.build_notes(terms)
|
||||||
|
self.assertEqual(len(notes), len(terms))
|
||||||
|
self.assertTrue(all(len(n["card_ids"]) == 2 for n in notes))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user