Pull explanations from 50ohm.de when available
This commit is contained in:
+17
-7
@@ -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
|
||||||
|
|||||||
+121
-10
@@ -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.
|
||||||
@@ -246,12 +300,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(
|
||||||
@@ -262,11 +319,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"
|
||||||
@@ -619,11 +689,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>'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1218,8 +1300,15 @@ def insert_collection_metadata(conn, decks, cur_deck_id, model_id, now):
|
|||||||
package can ship a deck tree, not just one deck. `cur_deck_id` is
|
package can ship a deck tree, not just one deck. `cur_deck_id` is
|
||||||
the deck made current in `col.conf`.
|
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 = {
|
decks_json = {
|
||||||
str(deck_id): deck_json(deck_id, deck_name, now)
|
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()
|
for deck_name, deck_id in decks.items()
|
||||||
}
|
}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -1278,11 +1367,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,
|
||||||
@@ -1297,7 +1389,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,
|
||||||
}
|
}
|
||||||
@@ -1502,6 +1594,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;
|
||||||
@@ -1519,6 +1622,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;
|
||||||
|
|||||||
+680
-510
File diff suppressed because it is too large
Load Diff
@@ -420,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"
|
||||||
@@ -501,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({
|
||||||
@@ -617,6 +703,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()
|
||||||
|
|||||||
Reference in New Issue
Block a user