From 10240f4d0b45d66c12ed53abedf0bcc058e6069e Mon Sep 17 00:00:00 2001 From: Joseph <162703152+josephnef@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:19:09 +0300 Subject: [PATCH] Add pre-modulator subcarrier precoder PoC Userspace tooling that turns devourer's TX path into a coarse, packet-bounded frequency-domain transmitter: choose what each OFDM data subcarrier carries (+-1 BPSK) and the encoder solves for the PSDU bytes that make the chip emit it, by inverting the deterministic scrambler -> BCC -> interleaver -> BPSK pipeline. No 8051 firmware change. Scope: RTL8812AU/8821AU/8811AU, single-stream BPSK, BCC, legacy 6 Mbps OFDM, 20 MHz. (send_packet never wires the HT MCS index, so an HT-MCS frame would TX as 1 Mbps CCK; the PoC uses the legacy 6M OFDM path, which is honoured and is the same BPSK r=1/2 modulation. See tools/precoder/README.md.) - tools/precoder/ (uv project): encode_subcarriers.py (forward model + exact Viterbi inverse handling cross-symbol state, legacy + HT numerology), seed_probe.py, fft_capture.py (SDR Phase-B scaffold + software self-test), test_pipeline.py (31 known-answer + round-trip tests), README.md (the verification-tier analysis: only data-symbol Y(k) observers prove control). - txdemo/precoder_demo/main.cpp: PrecoderDemo target -- transmits a shaped PSDU as a legacy-6M-OFDM probe request carrying the canonical SA. - demo/main.cpp + src/FrameParser.{h,cpp}: DEVOURER_DUMP_SCRAMBLER and DEVOURER_DUMP_BODY RX hooks (surface the descrambler seed, rate, per-stream RSSI/EVM/SNR, and the received body for round-trip checks). - tests/precoder_roundtrip.py: two-adapter, no-SDR round-trip harness (transport + 6M-OFDM-rate + byte round-trip + link-health diagnostics). - tests/precoder_smoke.py: repo-level smoke test (skips without numpy). Verified: 31 DSP + 5 smoke tests pass; hardware round-trip PASS both directions (RTL8812AU <-> RTL8821AU, ch6) with an AR9271 confirming both chips on-air at 6 Mbps OFDM. Per-subcarrier IQ control is intentionally not claimed -- it needs the BB debug port or an SDR (a bit-level RX cannot observe Y(k)). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 + CMakeLists.txt | 8 + demo/main.cpp | 39 ++ src/FrameParser.cpp | 18 + src/FrameParser.h | 8 + tests/precoder_roundtrip.py | 255 ++++++++++++ tests/precoder_smoke.py | 61 +++ tools/precoder/README.md | 188 +++++++++ tools/precoder/encode_subcarriers.py | 557 +++++++++++++++++++++++++++ tools/precoder/fft_capture.py | 233 +++++++++++ tools/precoder/pyproject.toml | 27 ++ tools/precoder/seed_probe.py | 172 +++++++++ tools/precoder/test_pipeline.py | 217 +++++++++++ tools/precoder/uv.lock | 429 +++++++++++++++++++++ txdemo/precoder_demo/main.cpp | 252 ++++++++++++ 15 files changed, 2466 insertions(+) create mode 100644 tests/precoder_roundtrip.py create mode 100644 tests/precoder_smoke.py create mode 100644 tools/precoder/README.md create mode 100644 tools/precoder/encode_subcarriers.py create mode 100644 tools/precoder/fft_capture.py create mode 100644 tools/precoder/pyproject.toml create mode 100644 tools/precoder/seed_probe.py create mode 100644 tools/precoder/test_pipeline.py create mode 100644 tools/precoder/uv.lock create mode 100644 txdemo/precoder_demo/main.cpp diff --git a/.gitignore b/.gitignore index 69f0dc5..11c66dc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ build .cache __pycache__/ *.pyc +.venv/ +.pytest_cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 5a1d9a0..c3f0b0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -96,3 +96,11 @@ add_executable(WiFiDriverTxDemo txdemo/main.cpp ) target_link_libraries(WiFiDriverTxDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) + +# Pre-modulator subcarrier PoC: transmits PSDU bytes shaped by +# tools/precoder/encode_subcarriers.py so chosen OFDM data subcarriers carry +# chosen bits. Single-stream BPSK / HT MCS0 / BCC on 8812/8821/8811. +add_executable(PrecoderDemo + txdemo/precoder_demo/main.cpp +) +target_link_libraries(PrecoderDemo PUBLIC WiFiDriver PRIVATE PkgConfig::libusb) diff --git a/demo/main.cpp b/demo/main.cpp index b633573..2e1a384 100644 --- a/demo/main.cpp +++ b/demo/main.cpp @@ -43,6 +43,45 @@ static void packetProcessor(const Packet &packet) { hits, g_rx_count, packet.Data.size()); fflush(stdout); } + /* DEVOURER_DUMP_SCRAMBLER=1: print the descrambler seed the chip + * recovered from this frame's SERVICE field. Consumed by + * tools/precoder/seed_probe.py --mode rx to learn the seed a precoder TX + * chip uses. CAVEAT: the seed is only trustworthy when *this* RX adapter + * is an RTL8814AU — the 8812/8821 RX descriptor doesn't expose it there + * (see FrameParser.cpp). On 8812/8821 prefer seed_probe.py --mode + * bruteforce. Gated + SA-filtered so it doesn't flood. */ + static const bool dump_scrambler = + std::getenv("DEVOURER_DUMP_SCRAMBLER") != nullptr; + if (dump_scrambler && (hits <= 20 || hits % 100 == 0)) { + printf("seed=0x%02x rate=%u hits=%d len=%zu\n", + packet.RxAtrib.scrambler, packet.RxAtrib.data_rate, hits, + packet.Data.size()); + fflush(stdout); + } + /* DEVOURER_DUMP_BODY=1: print the RX rate index (DESC_RATE*: 0x04=6M + * OFDM, 0x00=1M CCK, 0x0c+=HT/VHT MCS) and the 802.11 frame body + * (everything after the 24-byte mgmt header) as hex. Consumed by + * tests/precoder_roundtrip.py to confirm a PrecoderDemo frame flew as + * 6M OFDM and that its shaped PSDU bytes round-tripped intact — the + * two-adapter, no-SDR verification. First few hits only. */ + static const bool dump_body = std::getenv("DEVOURER_DUMP_BODY") != nullptr; + if (dump_body && hits <= 5) { + /* Tier-2 health diagnostics alongside the byte mirror: rate (0x04 = + * 6M OFDM), per-stream RSSI/EVM/SNR (link quality — content-blind), + * crc (always 0: CRC-failed frames are dropped upstream, so reaching + * here is itself the decode-sanity signal). Then the body hex. */ + printf("rate=%u rssi=%d,%d evm=%d,%d snr=%d,%d crc=%d " + "len=%zu body=", + packet.RxAtrib.data_rate, packet.RxAtrib.rssi[0], + packet.RxAtrib.rssi[1], packet.RxAtrib.evm[0], + packet.RxAtrib.evm[1], packet.RxAtrib.snr[0], + packet.RxAtrib.snr[1], packet.RxAtrib.crc_err ? 1 : 0, + packet.Data.size()); + for (size_t i = 24; i < packet.Data.size(); ++i) + printf("%02x", packet.Data[i]); + printf("\n"); + fflush(stdout); + } } } } diff --git a/src/FrameParser.cpp b/src/FrameParser.cpp index 8c2721c..566ec02 100644 --- a/src/FrameParser.cpp +++ b/src/FrameParser.cpp @@ -113,6 +113,18 @@ static rx_pkt_attrib rtl8812_query_rx_desc_status(uint8_t *pdesc) { pattrib.stbc = GET_RX_STATUS_DESC_STBC_8812(pdesc); pattrib.bw = GET_RX_STATUS_DESC_BW_8812(pdesc); + /* Descrambler seed recovered by the chip from the RX SERVICE field. This is + * read at the RTL8814AU descriptor offset (DWORD4 bits 9-15, i.e. + * GET_RX_STATUS_DESC_RX_SCRAMBLER_8814A in hal/rtl8814a_recv.h). NOTE: the + * 8812/8821 RX status descriptor does NOT lay the scrambler out here — that + * region holds the rate-info bits read just above (SPLCP/LDPC/STBC/BW occupy + * bits 0-5). The field is therefore only meaningful when the RX chip is an + * RTL8814AU; on 8812/8821 it reflects reserved/other bits and must not be + * trusted. Used solely by the DEVOURER_DUMP_SCRAMBLER diagnostic — for the + * precoder PoC (which targets 8812/8821 TX) the brute-force seed search in + * tools/precoder/seed_probe.py is the reliable path. */ + pattrib.scrambler = (uint8_t)LE_BITS_TO_4BYTE(pdesc + 16, 9, 7); + /* Offset 20 */ /* pattrib.tsfl=(byte)GET_RX_STATUS_DESC_TSFL_8812(pdesc); */ @@ -192,6 +204,12 @@ std::vector FrameParser::recvbuf2recvframe(std::span ptr) { * 8814AU. */ ret.back().RxAtrib.snr[2] = static_cast(driver_data.csi_current[0]); ret.back().RxAtrib.snr[3] = static_cast(driver_data.csi_current[1]); + /* Per-stream RX EVM: streams 1/2 in rxevm, 3/4 in rxevm_cd (8814 only; + * 0 on 8812/8811). Link-quality only — see rx_pkt_attrib::evm. */ + ret.back().RxAtrib.evm[0] = driver_data.rxevm[0]; + ret.back().RxAtrib.evm[1] = driver_data.rxevm[1]; + ret.back().RxAtrib.evm[2] = driver_data.rxevm_cd[0]; + ret.back().RxAtrib.evm[3] = driver_data.rxevm_cd[1]; } else { /* pkt_rpt_type == TX_REPORT1-CCX, TX_REPORT2-TX RTP,HIS_REPORT-USB HISR * RTP */ diff --git a/src/FrameParser.h b/src/FrameParser.h index 77caf8d..280ad0a 100644 --- a/src/FrameParser.h +++ b/src/FrameParser.h @@ -290,11 +290,19 @@ struct rx_pkt_attrib uint8_t stbc; uint8_t ldpc; uint8_t sgi; + /* Descrambler seed the chip recovered from this frame's SERVICE field. + * Trustworthy only on RTL8814AU (see FrameParser.cpp); surfaced for the + * DEVOURER_DUMP_SCRAMBLER hook in demo/main.cpp. */ + uint8_t scrambler; /* RSSI / SNR per RF path: A, B (Jaguar 8812/8811) plus C, D (8814AU). On * non-8814 chips the [2..3] slots are zero — the upstream RX phy-status * report reserves those bytes when only 2 paths are active. */ uint8_t rssi[4]; int8_t snr[4]; + /* Per-stream RX EVM (A,B on 8812/8811; plus C,D on 8814). Raw RX-status + * bytes — a link-quality metric, NOT a per-subcarrier or content signal. + * Surfaced for the DEVOURER_DUMP_BODY Tier-2 health diagnostic. */ + int8_t evm[4]; RX_PACKET_TYPE pkt_rpt_type; }; diff --git a/tests/precoder_roundtrip.py b/tests/precoder_roundtrip.py new file mode 100644 index 0000000..728a8d7 --- /dev/null +++ b/tests/precoder_roundtrip.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Two-adapter, no-SDR round-trip test for the precoder PoC. + +TX one devourer adapter (PrecoderDemo, shaped PSDU) and RX a second devourer +adapter (WiFiDriverDemo, monitor mode) on the same channel, then check: + + 1. TRANSPORT - the canonical-SA frame is received at all (hits > 0). + 2. PHY RATE - it flew as 6 Mbps legacy OFDM (RX rate index == DESC_RATE6M + = 0x04), NOT the 1 Mbps CCK fallback (0x00) that an HT-MCS + radiotap would cause (see the precoder README). This is the + on-hardware check for the legacy-vs-CCK correction. + 3. BYTES - the received PSDU body equals the encoder's shaped bytes. + +WHAT THIS DOES *NOT* PROVE: per-subcarrier IQ. A bit-level Wi-Fi RX demodulates +to bytes (it runs the same descramble/Viterbi/deinterleave that inverts whatever +the chip did), so the bytes always round-trip regardless of whether our +subcarrier model matches the chip. Confirming that chosen OFDM data subcarriers +carry chosen values needs the IQ domain — i.e. the SDR FFT (fft_capture.py) or +a CSI-capable rig. Two Wi-Fi adapters verify the *transport + rate* precondition +for that, not the subcarrier shaping itself. + +Targets RTL8812AU / RTL8821AU / RTL8811AU at 2.4 GHz (devourer RX is solid +there). Run as root, two adapters plugged: + + # Generate the shaped PSDU as your normal user (uv-managed deps): + cd tools/precoder && uv run python encode_subcarriers.py \\ + --pattern target.txt --scrambler-seed 0x5d --psdu-out /tmp/shaped.bin + # Then run the round-trip as root: + sudo python3 tests/precoder_roundtrip.py \\ + --tx-pid 0x8812 --rx-pid 0x8813 --psdu /tmp/shaped.bin --channel 6 + +Omit --psdu and the script will try to generate one via `uv run` in +tools/precoder (needs the uv env; awkward under sudo — prefer pre-generating). +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import threading +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parent +PRECODER = REPO / "tools" / "precoder" + +DESC_RATE6M = 0x04 # legacy OFDM 6 Mbps; CCK is 0x00-0x03, HT/VHT MCS is 0x0c+ +N_SD_LEGACY = 48 + +_HIT_RE = re.compile(r"") +# Tolerant of the Tier-2 health fields inserted between rate= and len=. +_BODY_RE = re.compile(r"rate=(\d+).*? len=(\d+) body=([0-9a-fA-F]*)") +_HEALTH_RE = re.compile(r"rssi=([-\d,]+) evm=([-\d,]+) snr=([-\d,]+) crc=(\d+)") + + +def rate_name(idx: int) -> str: + if idx <= 0x03: + return f"CCK {[1, 2, 5.5, 11][idx]}M (DSSS — NOT OFDM!)" + if idx <= 0x0B: + return f"legacy OFDM {[6, 9, 12, 18, 24, 36, 48, 54][idx - 4]}M" + return f"HT/VHT MCS (idx 0x{idx:02x})" + + +def make_pattern(path: Path, n_sym: int, n_sd: int = N_SD_LEGACY) -> None: + """Deterministic ±1 pattern file (stdlib only — no numpy in the harness).""" + import random + rng = random.Random(0xC0DE) + vals = [rng.choice((1, -1)) for _ in range(n_sym * n_sd)] + path.write_text("# deterministic precoder_roundtrip pattern\n" + + "\n".join(str(v) for v in vals) + "\n") + + +def gen_shaped(out: Path, seed: int, n_sym: int) -> bytes: + """Generate a shaped PSDU via the uv-managed encoder; return its bytes.""" + pat = out.with_suffix(".pattern.txt") + make_pattern(pat, n_sym) + cmd = ["uv", "run", "python", "encode_subcarriers.py", + "--pattern", str(pat), "--phy", "legacy", + "--scrambler-seed", f"0x{seed:02x}", "--psdu-out", str(out)] + print(f"[gen] {' '.join(cmd)} (cwd={PRECODER})") + subprocess.run(cmd, cwd=PRECODER, check=True) + return out.read_bytes() + + +class Reader(threading.Thread): + """Drain a subprocess' stdout into a line list (so readline can't wedge us).""" + + def __init__(self, proc: subprocess.Popen): + super().__init__(daemon=True) + self.proc = proc + self.lines: list[str] = [] + self._stop = False + + def run(self) -> None: + assert self.proc.stdout is not None + for line in self.proc.stdout: + self.lines.append(line) + if self._stop: + break + + def stop(self) -> None: + self._stop = True + + +def launch(binary: str, pid: str, vid: str, channel: int, extra_env: dict, + args_list: "list[str] | None" = None) -> subprocess.Popen: + env = dict(os.environ, DEVOURER_PID=pid, DEVOURER_VID=vid, + DEVOURER_CHANNEL=str(channel), DEVOURER_USB_QUIET="1", **extra_env) + return subprocess.Popen([binary, *(args_list or [])], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1) + + +def run_test(args) -> int: + # 1. shaped PSDU bytes (the expected body). + if args.psdu: + expected = Path(args.psdu).read_bytes() + print(f"[gen] using supplied PSDU {args.psdu} ({len(expected)} bytes)") + else: + expected = gen_shaped(Path(args.workdir) / "shaped.bin", + args.seed, args.n_sym) + shaped_path = args.psdu or str(Path(args.workdir) / "shaped.bin") + + if args.dry_run: + print(f"[dry-run] would TX {args.tx_bin} --psdu {shaped_path} on " + f"pid={args.tx_pid}, RX {args.rx_bin} on pid={args.rx_pid}, " + f"channel {args.channel}, {args.duration}s; expect rate=0x04 " + f"(6M OFDM) and body[:{len(expected)}] == shaped.") + return 0 + + # 2. RX first, give it time to bring the radio up. + print(f"[rx] launching {args.rx_bin} vid={args.rx_vid} pid={args.rx_pid} " + f"ch{args.channel}") + rx = launch(args.rx_bin, args.rx_pid, args.rx_vid, args.channel, + {"DEVOURER_DUMP_BODY": "1"}) + rx_reader = Reader(rx) + rx_reader.start() + time.sleep(args.rx_warmup) + + # 3. TX. + print(f"[tx] launching {args.tx_bin} --psdu {shaped_path} vid={args.tx_vid} " + f"pid={args.tx_pid}") + tx_env = dict(os.environ, DEVOURER_PID=args.tx_pid, DEVOURER_VID=args.tx_vid, + DEVOURER_CHANNEL=str(args.channel), DEVOURER_USB_QUIET="1") + tx = subprocess.Popen([args.tx_bin, "--psdu", shaped_path], env=tx_env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # 4. collect for the duration. + deadline = time.monotonic() + args.duration + try: + while time.monotonic() < deadline: + if any(_BODY_RE.search(l) for l in rx_reader.lines): + time.sleep(1.0) # let a couple more land + break + time.sleep(0.5) + finally: + for p in (tx, rx): + p.terminate() + rx_reader.stop() + for p in (tx, rx): + try: + p.wait(timeout=3) + except subprocess.TimeoutExpired: + p.kill() + + # 5. verdict. + hits = sum(1 for l in rx_reader.lines if _HIT_RE.search(l)) + body_lines = [l for l in rx_reader.lines if _BODY_RE.search(l)] + bodies = [_BODY_RE.search(l) for l in body_lines] + if args.keep: + log = Path(args.workdir) / "rx.log" + log.write_text("".join(rx_reader.lines)) + print(f"[keep] RX log -> {log}") + + print(f"\n--- precoder round-trip ({args.tx_pid} TX -> {args.rx_pid} RX) ---") + ok = True + + print(f"[1/3] transport: {hits} canonical-SA frame(s) received", + "PASS" if hits else "FAIL") + ok &= hits > 0 + + if not bodies: + print("[2/3] phy rate: no line — FAIL") + print("[3/3] bytes: n/a — FAIL") + return 1 if not args.allow_fail else 0 + + m = bodies[0] + rate = int(m.group(1)) + rx_body = bytes.fromhex(m.group(3)) + print(f"[2/3] phy rate: idx 0x{rate:02x} = {rate_name(rate)} — " + + ("PASS" if rate == DESC_RATE6M else "FAIL")) + ok &= rate == DESC_RATE6M + + # Tier-2 link-health diagnostics (info only — content-blind, never a + # per-subcarrier or control signal; see the precoder README). + h = _HEALTH_RE.search(body_lines[0]) + if h: + print(f"[ -- ] link health (info, not control): rssi={h.group(1)} " + f"evm={h.group(2)} snr={h.group(3)} crc_err={h.group(4)}") + + n = min(len(expected), len(rx_body)) + match = n > 0 and rx_body[:n] == expected[:n] + print(f"[3/3] bytes: {n}/{len(expected)} body bytes compared, " + f"{'identical' if match else 'DIVERGENT'} — " + + ("PASS" if match else "FAIL")) + ok &= match + if not match and n: + diff = next((i for i in range(n) if rx_body[i] != expected[i]), None) + print(f" first diff at byte {diff}: rx={rx_body[diff]:#04x} " + f"expected={expected[diff]:#04x}") + + print("RESULT:", "PASS" if ok else "FAIL") + return 0 if ok or args.allow_fail else 1 + + +def main(argv: "list[str] | None" = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--tx-pid", required=True, help="DEVOURER_PID of the TX adapter") + ap.add_argument("--rx-pid", required=True, help="DEVOURER_PID of the RX adapter") + ap.add_argument("--tx-vid", default="0x0bda", help="DEVOURER_VID of TX adapter " + "(default 0x0bda; e.g. 0x2357 for a TP-Link T2U Plus)") + ap.add_argument("--rx-vid", default="0x0bda", help="DEVOURER_VID of RX adapter") + ap.add_argument("--channel", type=int, default=6) + ap.add_argument("--psdu", help="pre-generated shaped PSDU (recommended); " + "omit to auto-generate via uv") + ap.add_argument("--seed", type=lambda s: int(s, 0), default=0x5D, + help="scrambler seed for auto-generation") + ap.add_argument("--n-sym", type=int, default=8, + help="OFDM symbols of shaped payload (auto-generation)") + ap.add_argument("--tx-bin", default=str(REPO / "build" / "PrecoderDemo")) + ap.add_argument("--rx-bin", default=str(REPO / "build" / "WiFiDriverDemo")) + ap.add_argument("--duration", type=float, default=20.0) + ap.add_argument("--rx-warmup", type=float, default=12.0, + help="seconds to let the RX radio come up before TX. Some " + "chips are slow to init (RTL8812AU RX needed ~15s on " + "the bench; 10s was flaky) — raise this if transport " + "reports 0 frames") + ap.add_argument("--workdir", default="/tmp/precoder-roundtrip") + ap.add_argument("--keep", action="store_true", help="save the RX log") + ap.add_argument("--dry-run", action="store_true", + help="generate the shaped PSDU and print the plan; no USB") + ap.add_argument("--allow-fail", action="store_true", + help="always exit 0 (report only)") + args = ap.parse_args(argv) + os.makedirs(args.workdir, exist_ok=True) + return run_test(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/precoder_smoke.py b/tests/precoder_smoke.py new file mode 100644 index 0000000..a630df6 --- /dev/null +++ b/tests/precoder_smoke.py @@ -0,0 +1,61 @@ +"""Smoke test for the pre-modulator subcarrier encoder (tools/precoder). + +End-to-end round-trip the PoC plan calls for, wired into the repo's pytest +suite. Runs on any host with numpy and skips cleanly without it — no USB, no +SDR, no hardware. The exhaustive DSP known-answer tests live in +tools/precoder/test_pipeline.py and run under that subtree's uv env: + + cd tools/precoder && uv venv && uv sync && uv run pytest +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +np = pytest.importorskip("numpy") + +PRECODER = Path(__file__).resolve().parent.parent / "tools" / "precoder" +sys.path.insert(0, str(PRECODER)) +import encode_subcarriers as enc # noqa: E402 + +PHYS = [enc.phy_params("ht"), enc.phy_params("legacy")] +PHY_IDS = ["ht", "legacy"] + + +@pytest.mark.parametrize("phy", PHYS, ids=PHY_IDS) +def test_representable_pattern_roundtrips_exactly(phy): + """encode -> emulate chip pipeline -> constellation == input, bit-exact.""" + rng = np.random.default_rng(0) + seed = enc.DEFAULT_SEED + # A reachable target: random info -> forward model -> use its constellation. + info = rng.integers(0, 2, size=phy.n_dbps, dtype=np.uint8) + psdu_bits = enc.apply_scrambler(info, seed) + target = enc.emulate_chip(psdu_bits, seed, phy, n_sym=1) + + res = enc.encode_pattern(target, seed=seed, phy=phy) + assert res.representable and res.hamming_distance == 0 + back = enc.emulate_chip(res.psdu_bits, seed, phy, n_sym=1) + assert np.array_equal(back, target) + + +@pytest.mark.parametrize("phy", PHYS, ids=PHY_IDS) +def test_alternating_example_reports_nearest(phy): + """The plan's [+1,-1,...] example: generally not a codeword, so the encoder + must report a nearest-match distance and the emulation must differ from the + request in exactly that many subcarriers.""" + target = enc.alternating_pattern(phy.n_sd) + res = enc.encode_pattern(target, phy=phy) # non-strict + back = enc.emulate_chip(res.psdu_bits, res.seed, phy, n_sym=1) + assert int(np.count_nonzero(back != target)) == res.hamming_distance + + +def test_psdu_bytes_are_emitted(): + """The deliverable: encode_pattern yields packable PSDU bytes.""" + phy = enc.phy_params("ht") + target = enc.alternating_pattern(phy.n_sd, n_sym=2) + res = enc.encode_pattern(target, phy=phy) + assert isinstance(res.psdu_bytes, (bytes, bytearray)) + assert len(res.psdu_bytes) == (2 * phy.n_dbps + 7) // 8 diff --git a/tools/precoder/README.md b/tools/precoder/README.md new file mode 100644 index 0000000..6a1aca4 --- /dev/null +++ b/tools/precoder/README.md @@ -0,0 +1,188 @@ +# precoder — pre-modulator subcarrier encoder + +Proof-of-concept that turns devourer's TX path into a coarse, packet-bounded +frequency-domain transmitter: choose what each OFDM **data subcarrier** carries +(±1 BPSK), and the encoder solves for the PSDU bytes that make the chip emit it +— no 8051 firmware change. The chip's pipeline is fixed but fully deterministic: + +``` +PSDU bytes → scrambler (x⁷+x⁴+1) → BCC K=7 (133,171) r=½ + → interleaver → BPSK map (0→+1, 1→−1) → pilots → IFFT → CP → DAC +``` + +Every stage above the constellation is invertible (or, for the rate-½ code, +a linear map onto a 2ᵏ subspace), so we run it backwards. + +Scope (locked by the plan): RTL8812AU / RTL8821AU / RTL8811AU, single-stream +BPSK, BCC (not LDPC), long GI, 20 MHz. RTL8814AU is out of scope (its TX is +unreliable after USB passthrough cycles). + +## Setup (uv) + +```sh +cd tools/precoder +uv venv +uv sync # numpy + pytest (dev) +uv run pytest # 31 DSP known-answer + round-trip tests +``` + +Phase-B SDR extra (optional): `uv sync --extra sdr`. + +## Files + +| file | what | +|------|------| +| `encode_subcarriers.py` | the encoder: forward model **and** exact inverse, plus CLI | +| `seed_probe.py` | discover / characterise the chip scrambler seed (rx + bruteforce) | +| `fft_capture.py` | Phase-B per-subcarrier IQ verification (+ runnable `--self-test`) | +| `test_pipeline.py` | pytest: scrambler/BCC/interleaver KATs + pipeline round-trips | + +The repo-level end-to-end smoke is `tests/precoder_smoke.py` (skips without numpy). + +## End-to-end recipe + +```sh +# 0. Build the C++ side from the repo root. +cmake -S . -B build && cmake --build build -j # -> build/PrecoderDemo + +# 1. Characterise the chip's scrambler seed (does it re-seed per frame?). +# Run an RX adapter at the precoder's TX channel: +uv run python seed_probe.py --mode rx --rx-pid 0x8813 --channel 6 +# CONSTANT seed -> one shaped PSDU works; VARYING -> use bruteforce (below). + +# 2. Encode a target subcarrier pattern (one ±1 per data subcarrier per line; +# 48 values/symbol for legacy). Default --phy legacy matches PrecoderDemo. +uv run python encode_subcarriers.py --pattern target.txt \ + --scrambler-seed 0x5d --psdu-out shaped.bin + +# 3. Transmit it. +DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 ./build/PrecoderDemo --psdu shaped.bin + +# 4. Phase A — two adapters, no SDR (transport + PHY rate + byte round-trip): +sudo python3 ../../tests/precoder_roundtrip.py \ + --tx-pid 0x8812 --rx-pid 0x8813 --psdu shaped.bin --channel 6 +# 5. Phase B (rigorous, gated on A): capture 20 MHz IQ on a >=20 Msps SDR and +uv run python fft_capture.py --iq frame.cf32 --pattern target.txt +``` + +## Verification tiers — what each one actually proves + +The sorting question for **any** observable is: *does it expose the received +**data-symbol** value `Y(k)` per subcarrier?* Only things that do can confirm +per-subcarrier control. Everything else is computed *after* the chip already +decided what each subcarrier was, so it's blind to our shaping. + +| Tier | Observable | Exposes `Y(k)`? | Proves | +|------|-----------|:---:|--------| +| 0 | offline KATs | n/a | model vs **spec** (software) | +| 1 | decoded bytes | ❌ | transport + PHY rate | +| 2 | RSSI/EVM/SNR | ❌ | link health only | +| 3 | CSI `H(k)` | ❌ | the **preamble** channel (not data) | +| 4 | BB dbgport post-FFT IQ | ✅ | **per-subcarrier control** (no SDR) | +| 5 | IQK TX→RX loopback | ✅* | clean `Y(k)` feed into Tier 4 | +| — | SDR | ✅ | per-subcarrier control (off-chip) | + +**Tier 0 — offline KATs** (`test_pipeline.py`, no hardware). The encoder's +`inverse ∘ forward = identity` and the scrambler/BCC/interleaver match the +*standard*. **This is where the model is validated — against the spec, in +software.** The chip is not involved. + +**Tier 1 — bit-level loopback, two Wi-Fi adapters, no SDR** +(`tests/precoder_roundtrip.py`). TX one devourer adapter, RX a second; checks +(1) transport, (2) PHY rate `0x04` = 6M OFDM not `0x00` = 1M CCK, (3) bytes +round-trip, and *(optional, 8814 RX only)* the scrambler seed matches. + +> ⚠️ Tier 1 does **not** validate the model or prove per-subcarrier content. +> The RX inverts the chip with the **chip's own** tables — never ours — so a +> wrong interleaver/BCC/scrambler table in our code leaves the air subcarriers +> wrong **but the bytes still round-trip**. A wrong table is invisible to a +> byte mirror. Tier 1 proves the *precondition* (it's really 6M OFDM, bytes +> survive), not the shaping. + +**Tier 2 — RX-status health diagnostics, free with Tier 1** +(`DEVOURER_DUMP_BODY=1`: rate + per-stream RSSI/EVM/SNR + CRC). EVM is already +parsed (`FrameParser.cpp`), so this is ~free. ⚠️ **Link-health only, never a +content signal:** BPSK ±1 are equal-power and equal-power-allocated, so +per-subcarrier SNR is flat for *any* pattern, and EVM is measured against the +*decoded* symbol — both stay good whether or not our intended value landed. +Also, RX phy-status EVM/SNR is **per-stream, not per-subcarrier**. Useful for +"is the link clean / is it really 6M OFDM", not for shaping. + +**Tier 3 — CSI `H(k)`** (PicoScenes-style H2C dump). ❌ Not on the control path: +`H(k)` is estimated from the **L-LTF preamble**, which the chip generates and we +can't shape, so CSI is *invariant* to our data shaping and never yields `Y(k)`. +A channel-characterisation tool, not a mirror. (Also verify the "RTL8812AU is a +first-class PicoScenes target" claim before investing — Realtek CSI support is +far less mature than Intel/Atheros/SDR.) + +**Tier 4 — BB debug-port post-FFT IQ (the real no-SDR path).** Routes the +post-FFT per-subcarrier IQ to a readable register → the actual `Y(k)` on the +leading data symbol(s); with a flat/known rig channel ≈ `X(k)`. Grounded in the +tree: `0x8FC` dbgport is already poked at `HalModule.cpp:2167`, and `0x95C` +(`rDMA_trigger_Jaguar2`, "ADC sample mode") is in `Hal8814PhyReg.h:190`. +**Research spike** — undocumented register dance, BB-state brick risk. + +**Tier 5 — IQK TX→RX loopback** (`*`if it carries a full PPDU). The path-enable +dance already lives in `Iqk8812a.cpp`/`Iqk8814a.cpp` (the `0x77…` writes). The +risk isn't the regs — it's that IQK loopback carries the calibration *tone* via +the cal state machine, not a descriptor-driven PPDU through normal +preamble/AGC/sync. If a data PPDU survives it (cheap 30-min experiment), 5→4 is +the cleanest channel- and noise-free mirror. + +**SDR** (`fft_capture.py` + ≥20 Msps SDR). The lowest-effort *reliable* `Y(k)` +observer; the off-chip counterpart to Tier 4. + +## Things the plan got slightly wrong (and how this resolves them) + +* **HT MCS 0 doesn't reach the air (decisive).** The plan selected the rate via + `DEVOURER_TX_MCS=0`, but `RtlJaguarDevice::send_packet` only sets the TX rate + from the radiotap RATE field (legacy) or the VHT field — it **never reads the + HT MCS index**. An HT-MCS frame with no RATE field therefore transmits at the + `MGN_1M` default = **1 Mbps CCK** (DSSS, no OFDM subcarriers). So this PoC uses + **legacy 6 Mbps OFDM** (BPSK r=½), which `send_packet` honours and which the + chip really modulates as OFDM. (Fixing the HT path is a one-liner in + `send_packet`, but it changes shared core TX behaviour the regression matrix + depends on — deliberately out of scope here.) + +* **Numerology.** Consequently the on-air rate is legacy 802.11a/g 6 Mbps: + **48 data subcarriers, 24 info bits, a 16×3 interleaver** — exactly the plan's + prose. `--phy legacy` (default) matches it; `--phy ht` (52 SD, 13×4) is correct + math kept for when the HT path is wired up. + +* **Scrambler RX read is 8814-specific.** `GET_RX_STATUS_DESC_RX_SCRAMBLER_8814A` + reads `desc+16 bits 9–15`; on the 8812/8821 RX descriptor that region holds + SPLCP/LDPC/STBC/BW, **not** the seed. Since the PoC *targets* 8812/8821, the + `--mode rx` seed read is only trustworthy with an RTL8814AU sniffer; + `--mode bruteforce` is the reliable on-air path. + +* **Per-PPDU re-seeding.** 802.11 picks a fresh scrambler seed per frame. If the + chip does that (seed_probe tells you), a single pre-descrambled PSDU can't + pin the pattern — brute-force 128 seed variants and the matching frames carry + the target. Only a chip that re-uses a constant seed lets one PSDU work. + +* **RTL-SDR can't do 20 MHz.** It tops out ~2.4 MS/s; an 802.11 20 MHz capture + needs a ≥20 Msps SDR (HackRF/USRP/Lime/bladeRF). `fft_capture.py` reads an IQ + file or a SoapySDR device, not an RTL-SDR at 20 MHz. + +* **"Data frame".** Data/ToDS frames get NAK'd by the chip in monitor mode + (`txdemo/main.cpp`), so `PrecoderDemo` injects a **probe-request** with the + canonical SA — the SA matcher recognises it identically. + +## Real-frame placement (offset + entry_state) + +The encoder's contract is purely bit-domain: given a target for a contiguous run +of OFDM data symbols, starting from BCC `entry_state` at scrambler phase +`offset`, it emits the bytes that reproduce it. In a real frame the scrambled +stream is `[SERVICE(16b) | MAC header | body | tail | pad]`, so the body does +**not** start at the head: + +* `PrecoderDemo` prepends a 24-byte MAC header → body starts at scrambled-bit + offset `16 + 24·8 = 208`. For legacy (`N_DBPS=24`), 208 is **not** a symbol + boundary (208 = 8·24 + 16), so the body starts mid-symbol-8; the first fully + controllable symbol begins at bit 216 (= 9·24), i.e. one byte into the body. +* For **exact** per-subcarrier control of a body symbol, encode with the + matching `--offset` and `entry_state` (the BCC state the SERVICE+header + leave). Use `encode_subcarriers.bcc_final_state(scrambled_prefix_bits)` to + compute the state; pad the body so the target lands on a 24-bit boundary. +* For the **byte-level** round-trip (Phase A) the offset is irrelevant — the + bytes come back verbatim regardless. diff --git a/tools/precoder/encode_subcarriers.py b/tools/precoder/encode_subcarriers.py new file mode 100644 index 0000000..4cf9923 --- /dev/null +++ b/tools/precoder/encode_subcarriers.py @@ -0,0 +1,557 @@ +#!/usr/bin/env python3 +"""Pre-modulator subcarrier encoder for devourer. + +The Realtek Jaguar TX pipeline for a single-stream BPSK / BCC frame is fully +deterministic: + + PSDU bytes -> scrambler (x^7+x^4+1) -> BCC K=7 (133,171) r=1/2 + -> interleaver(MCS,BW) -> BPSK map (0->+1, 1->-1) + -> pilot insert -> IFFT -> CP -> DAC + +Because every stage above the constellation map is an invertible, memoryless- +or-linear transform, we can run the chain *backwards*: pick what each OFDM +data subcarrier should transmit (a +-1 BPSK target), and solve for the PSDU +bytes that make the chip emit it. The chip needs no firmware change — it +faithfully encodes whatever bytes we hand it. + +This module implements the forward model AND its exact inverse, so the +round-trip (target -> encode -> emulate chip -> constellation) is bit-exact for +any target that is representable by the rate-1/2 code (see `bcc_viterbi_preimage` +for why only a 2^k subspace is reachable; non-representable targets get the +nearest valid pattern + a reported Hamming distance). + +PHY MODE — legacy vs HT (IMPORTANT) +------------------------------------ +The originating plan's prose ("48 data + 4 pilot", "24 input bits") describes +*legacy* 802.11a OFDM (6 Mbps BPSK). It also said to select the rate via the HT +radiotap MCS field (`DEVOURER_TX_MCS=0`) — but HT MCS 0 is a DIFFERENT +numerology (52 SD, 26 info bits, 13x4 interleaver) AND, decisively, +`RtlJaguarDevice::send_packet` never wires the HT MCS *index* into the TX rate: +an HT-MCS frame with no RATE field transmits at the MGN_1M default = 1 Mbps +CCK, which is DSSS — no OFDM subcarriers at all. So the working OFDM-BPSK path +is legacy 6 Mbps (honoured via the radiotap RATE field), which is what +`PrecoderDemo` transmits. Both numerologies are implemented: + + --phy legacy (default) N_SD=48 N_CBPS=48 N_DBPS=24 interleaver 16x3 + --phy ht N_SD=52 N_CBPS=52 N_DBPS=26 interleaver 13x4 + +`legacy` is the default to match `PrecoderDemo`'s 6 Mbps OFDM frame (and the +plan's prose). `--phy ht` is correct math but won't reach the air until +send_packet's HT-MCS-index gap is fixed AND PrecoderDemo emits an HT radiotap. + +SCOPE / ASSUMPTIONS (documented, deliberate for a PoC) +------------------------------------------------------ +* Single spatial stream, BPSK, rate 1/2, BCC (not LDPC), long GI, 20 MHz. + These are the locked-in scope decisions from the plan. +* The encoder's contract is purely bit-domain: "given a target for a + contiguous run of OFDM *data* symbols, starting from BCC state 0 and + scrambler phase `offset`, produce the bytes that reproduce it." It does NOT + model the 16-bit SERVICE prefix, the MAC header, the tail/pad bits, or where + the controlled symbol physically lands in a real MPDU. Those are an + *application* concern: see README.md for the offset arithmetic needed to + place a fully-controlled symbol in a real frame (a symbol that straddles the + fixed MAC header is only partially controllable). +* Byte packing is LSB-first within each octet (the 802.11 PPDU convention: + the first PSDU bit is bit 0 of octet 0). +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass + +import numpy as np + +# Convolutional code generators, K=7, rate 1/2 (IEEE 802.11 §17.3.5.6). +# Octal 133/171; output order is A (133) then B (171) per input bit. +G0 = 0o133 # 0b1011011 +G1 = 0o171 # 0b1111001 + +# Scrambler seed used in the IEEE worked example (Annex I) and referenced by +# the plan's CLI example. 1011101b. Any nonzero 7-bit value is valid; the chip +# picks its own per-frame (discover it with seed_probe.py). +DEFAULT_SEED = 0x5D + + +# --------------------------------------------------------------------------- # +# PHY parameters +# --------------------------------------------------------------------------- # +@dataclass(frozen=True) +class PhyParams: + """OFDM / coding parameters for one (PHY, MCS, BW) point. + + For BPSK 1-stream: N_CBPS == N_SD (one coded bit per data subcarrier) and + N_DBPS == N_CBPS // 2 (rate 1/2). The interleaver reduces to its first + permutation for BPSK (the second permutation is identity when s=1, and the + HT third permutation is identity for a single spatial stream). + """ + + name: str + n_sd: int # data subcarriers per OFDM symbol + n_cbps: int # coded bits per symbol + n_dbps: int # data (info) bits per symbol + n_bpscs: int # coded bits per subcarrier per stream (BPSK = 1) + i_col: int # interleaver columns + i_row: int # interleaver rows + n_ss: int = 1 # spatial streams (PoC = 1) + + +_LEGACY_BPSK = PhyParams( + name="legacy-6Mbps-BPSK", n_sd=48, n_cbps=48, n_dbps=24, n_bpscs=1, + i_col=16, i_row=3, +) +_HT_MCS0 = PhyParams( + name="ht-mcs0-BPSK", n_sd=52, n_cbps=52, n_dbps=26, n_bpscs=1, + i_col=13, i_row=4, +) + + +def phy_params(mode: str = "legacy", mcs: int = 0, bw: int = 20) -> PhyParams: + """Resolve PHY parameters. PoC supports BPSK rate-1/2 at 20 MHz only.""" + if bw != 20: + raise NotImplementedError(f"only 20 MHz supported in this PoC (got {bw})") + if mode == "legacy": + return _LEGACY_BPSK + if mode == "ht": + if mcs != 0: + raise NotImplementedError( + f"only HT MCS 0 (BPSK r=1/2) supported (got MCS {mcs}); " + "higher MCS use QAM and/or >1 stream, out of PoC scope" + ) + return _HT_MCS0 + raise ValueError(f"unknown phy mode {mode!r} (expected 'ht' or 'legacy')") + + +# --------------------------------------------------------------------------- # +# Scrambler — x^7 + x^4 + 1 +# --------------------------------------------------------------------------- # +def scrambler_sequence(seed: int, n: int) -> np.ndarray: + """`n` bits of the 802.11 scrambling sequence for 7-bit LFSR `seed`. + + Taps at x7 (bit 6) and x4 (bit 3); feedback shifts into bit 0. The pure + LFSR output therefore satisfies the recurrence o[m] = o[m-4] ^ o[m-7] + (= the polynomial 1 + x^4 + x^7) and is maximal-length (period 127). + """ + reg = seed & 0x7F + out = np.empty(n, dtype=np.uint8) + for i in range(n): + fb = ((reg >> 6) ^ (reg >> 3)) & 1 + out[i] = fb + reg = ((reg << 1) | fb) & 0x7F + return out + + +def apply_scrambler(bits: np.ndarray, seed: int, offset: int = 0) -> np.ndarray: + """XOR `bits` with the scrambler sequence starting at phase `offset`. + + The scrambler is its own inverse, so this is both `scramble` and + `descramble`. `offset` is the bit position of `bits[0]` within the frame's + scrambled stream (the scrambler runs continuously from the SERVICE field). + """ + bits = np.asarray(bits, dtype=np.uint8) + seq = scrambler_sequence(seed, offset + len(bits))[offset:] + return (bits ^ seq).astype(np.uint8) + + +# `descramble` is identical to scrambling (XOR); alias for readability at the +# call sites that conceptually "remove" the chip's scrambling. +descramble = apply_scrambler + + +# --------------------------------------------------------------------------- # +# Binary convolutional code (BCC), K=7, (133,171), rate 1/2 +# --------------------------------------------------------------------------- # +def _parity(x: int) -> int: + return bin(x).count("1") & 1 + + +def bcc_encode(info_bits: np.ndarray, init_state: int = 0) -> np.ndarray: + """Encode info bits with the 802.11 K=7 (133,171) r=1/2 code. + + Returns 2*len(info_bits) coded bits in A0,B0,A1,B1,... order. `init_state` + is the 6-bit encoder state (the 6 preceding input bits); 0 = the all-zero + start state used at the head of an 802.11 frame (no tail-biting). A nonzero + `init_state` continues a stream mid-frame — see `bcc_final_state`. + """ + info_bits = np.asarray(info_bits, dtype=np.uint8) + out = np.empty(2 * len(info_bits), dtype=np.uint8) + state = init_state & 0x3F + for t, u in enumerate(info_bits): + w = (int(u) << 6) | state # 7-bit window: bit6 = current input + out[2 * t] = _parity(w & G0) + out[2 * t + 1] = _parity(w & G1) + state = w >> 1 + return out + + +def bcc_final_state(info_bits: np.ndarray, init_state: int = 0) -> int: + """The 6-bit BCC encoder state after encoding `info_bits` from `init_state`. + + Use this to compute the state entering a mid-frame symbol: encode the + scrambled SERVICE field + MAC-header bits that precede the controlled + region, then pass the result as `entry_state` to `encode_pattern`. + """ + info_bits = np.asarray(info_bits, dtype=np.uint8) + state = init_state & 0x3F + for u in info_bits: + state = ((int(u) << 6) | state) >> 1 + return state + + +# Precomputed trellis: for (state, input) -> (next_state, expected (A,B)). +def _build_trellis() -> tuple[np.ndarray, np.ndarray]: + next_state = np.empty((64, 2), dtype=np.int64) + out_ab = np.empty((64, 2, 2), dtype=np.uint8) + for state in range(64): + for u in (0, 1): + w = (u << 6) | state + next_state[state, u] = w >> 1 + out_ab[state, u, 0] = _parity(w & G0) + out_ab[state, u, 1] = _parity(w & G1) + return next_state, out_ab + + +_TRELLIS_NEXT, _TRELLIS_OUT = _build_trellis() + + +def bcc_viterbi_preimage( + coded_bits: np.ndarray, entry_state: int = 0 +) -> tuple[np.ndarray, int]: + """Find the info bits whose BCC encoding is closest to `coded_bits`. + + Hard-decision Viterbi over the 64-state trellis, starting at `entry_state` + (0 = frame head), no termination (free end state). Returns + (info_bits, hamming_distance). + + A rate-1/2 BCC is a (2k, k) linear code, so only 2^k of the 2^(2k) coded + patterns are exactly representable. When `coded_bits` is one of them the + distance is 0 and re-encoding the returned info reproduces it exactly; + otherwise the result is the maximum-likelihood (nearest) codeword's input. + """ + coded_bits = np.asarray(coded_bits, dtype=np.uint8) + if len(coded_bits) % 2 != 0: + raise ValueError("coded_bits length must be even (rate 1/2)") + n_pairs = len(coded_bits) // 2 + + INF = 1 << 30 + metric = np.full(64, INF, dtype=np.int64) + metric[entry_state & 0x3F] = 0 + back_state = np.empty((n_pairs, 64), dtype=np.int64) + back_input = np.empty((n_pairs, 64), dtype=np.uint8) + + for t in range(n_pairs): + a = int(coded_bits[2 * t]) + b = int(coded_bits[2 * t + 1]) + new_metric = np.full(64, INF, dtype=np.int64) + for state in range(64): + m = metric[state] + if m >= INF: + continue + for u in (0, 1): + ea = _TRELLIS_OUT[state, u, 0] + eb = _TRELLIS_OUT[state, u, 1] + bm = (ea ^ a) + (eb ^ b) + ns = int(_TRELLIS_NEXT[state, u]) + cost = m + bm + if cost < new_metric[ns]: + new_metric[ns] = cost + back_state[t, ns] = state + back_input[t, ns] = u + metric = new_metric + + end = int(np.argmin(metric)) + dist = int(metric[end]) + info = np.empty(n_pairs, dtype=np.uint8) + s = end + for t in range(n_pairs - 1, -1, -1): + info[t] = back_input[t, s] + s = int(back_state[t, s]) + return info, dist + + +# Plan's name for the inverse step. +bcc_preimage = bcc_viterbi_preimage + + +# --------------------------------------------------------------------------- # +# Interleaver +# --------------------------------------------------------------------------- # +def interleaver_perm(p: PhyParams) -> np.ndarray: + """Permutation `perm[k] = j`: coded-bit index k -> subcarrier index j. + + First permutation (block, i_col x i_row) followed by the second + permutation. For BPSK (n_bpscs=1) s = max(n_bpscs//2, 1) = 1, so the second + permutation is the identity; the formula is kept general for clarity. + """ + k = np.arange(p.n_cbps) + i = p.i_row * (k % p.i_col) + (k // p.i_col) + s = max(p.n_bpscs // 2, 1) + j = s * (i // s) + (i + p.n_cbps - ((p.i_col * i) // p.n_cbps)) % s + if p.n_ss != 1: + raise NotImplementedError("HT frequency rotation (n_ss>1) out of scope") + return j.astype(np.int64) + + +def interleave(coded: np.ndarray, p: PhyParams) -> np.ndarray: + """coded bits (BCC output order) -> subcarrier-order bits.""" + coded = np.asarray(coded, dtype=np.uint8) + perm = interleaver_perm(p) + sub = np.empty(p.n_cbps, dtype=np.uint8) + sub[perm] = coded + return sub + + +def deinterleave(sub: np.ndarray, p: PhyParams) -> np.ndarray: + """subcarrier-order bits -> coded bits (BCC output order).""" + sub = np.asarray(sub, dtype=np.uint8) + return sub[interleaver_perm(p)] + + +# --------------------------------------------------------------------------- # +# BPSK constellation +# --------------------------------------------------------------------------- # +def bpsk_map(bits: np.ndarray) -> np.ndarray: + """0 -> +1, 1 -> -1.""" + return (1 - 2 * np.asarray(bits, dtype=np.int8)).astype(np.int8) + + +def bpsk_demap(target_pm1: np.ndarray) -> np.ndarray: + """+1 -> 0, -1 -> 1 (sign slicer).""" + return (np.asarray(target_pm1) < 0).astype(np.uint8) + + +# --------------------------------------------------------------------------- # +# Byte <-> bit packing (LSB-first within each octet, 802.11 PPDU order) +# --------------------------------------------------------------------------- # +def bits_to_bytes(bits: np.ndarray) -> bytes: + bits = np.asarray(bits, dtype=np.uint8) + pad = (-len(bits)) % 8 + if pad: + bits = np.concatenate([bits, np.zeros(pad, dtype=np.uint8)]) + return np.packbits(bits, bitorder="little").tobytes() + + +def bytes_to_bits(data: bytes, n: int | None = None) -> np.ndarray: + bits = np.unpackbits(np.frombuffer(data, dtype=np.uint8), bitorder="little") + return bits if n is None else bits[:n] + + +# --------------------------------------------------------------------------- # +# High-level encode / emulate +# --------------------------------------------------------------------------- # +@dataclass +class EncodeResult: + psdu_bits: np.ndarray # the controllable info bits, descrambled (PSDU domain) + psdu_bytes: bytes # psdu_bits packed LSB-first (last octet zero-padded) + hamming_distance: int # subcarriers that differ from the target (0 = exact) + representable: bool # hamming_distance == 0 + n_sym: int + phy: PhyParams + seed: int + offset: int + + +def encode_pattern( + targets: np.ndarray, + seed: int = DEFAULT_SEED, + phy: PhyParams = _LEGACY_BPSK, + offset: int = 0, + strict: bool = False, + entry_state: int = 0, +) -> EncodeResult: + """Invert the pipeline: target BPSK subcarriers -> PSDU bytes. + + `targets` is shape (n_sym, N_SD) of +-1 (or a flat (N_SD,) for one symbol). + `entry_state` is the BCC state entering the controlled region (0 at frame + head; compute mid-frame values with `bcc_final_state`). `offset` is the + scrambler phase of the first emitted bit. Returns an EncodeResult; if + `strict` and the target is not exactly representable by the rate-1/2 code, + raises ValueError instead of returning the nearest pattern. + """ + targets = np.atleast_2d(np.asarray(targets)) + if targets.shape[1] != phy.n_sd: + raise ValueError( + f"each symbol must have {phy.n_sd} subcarriers for {phy.name}, " + f"got {targets.shape[1]}" + ) + + coded_all = np.concatenate( + [deinterleave(bpsk_demap(sym), phy) for sym in targets] + ) + info_scrambled, dist = bcc_viterbi_preimage(coded_all, entry_state) + if strict and dist != 0: + raise ValueError( + f"target not exactly representable by the rate-1/2 code " + f"(nearest differs in {dist} subcarrier(s)); drop --strict to " + f"accept the nearest pattern" + ) + + psdu_bits = apply_scrambler(info_scrambled, seed, offset) + return EncodeResult( + psdu_bits=psdu_bits, + psdu_bytes=bits_to_bytes(psdu_bits), + hamming_distance=dist, + representable=(dist == 0), + n_sym=targets.shape[0], + phy=phy, + seed=seed, + offset=offset, + ) + + +def emulate_chip( + psdu_bits: np.ndarray, + seed: int, + phy: PhyParams, + n_sym: int, + offset: int = 0, + entry_state: int = 0, +) -> np.ndarray: + """Forward model: PSDU info bits -> per-symbol BPSK subcarriers (+-1). + + The exact inverse of `encode_pattern` (for a representable target). Used by + the round-trip / smoke tests and as a software stand-in for the chip. + Returns shape (n_sym, N_SD). + """ + info_scrambled = apply_scrambler(psdu_bits, seed, offset) + coded = bcc_encode(info_scrambled, entry_state) + expected = n_sym * phy.n_cbps + if len(coded) < expected: + raise ValueError( + f"need {expected} coded bits for {n_sym} symbol(s), got {len(coded)}" + ) + out = [] + for s in range(n_sym): + chunk = coded[s * phy.n_cbps:(s + 1) * phy.n_cbps] + out.append(bpsk_map(interleave(chunk, phy))) + return np.array(out, dtype=np.int8) + + +# --------------------------------------------------------------------------- # +# Pattern file IO +# --------------------------------------------------------------------------- # +def parse_pattern_file(path: str, n_sd: int) -> np.ndarray: + """Read a +-1 pattern file. Each non-comment token is +1/-1 (or 1/-1). + + Tokens may be whitespace- or newline-separated. The total count must be a + positive multiple of `n_sd`; the result is reshaped to (n_sym, n_sd). + """ + vals: list[int] = [] + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.split("#", 1)[0].strip() + if not line: + continue + for tok in line.replace(",", " ").split(): + v = int(tok) + if v not in (1, -1): + raise ValueError(f"{path}: token {tok!r} is not +1 or -1") + vals.append(v) + if not vals or len(vals) % n_sd != 0: + raise ValueError( + f"{path}: got {len(vals)} values, expected a positive multiple of " + f"N_SD={n_sd}" + ) + return np.array(vals, dtype=np.int8).reshape(-1, n_sd) + + +def alternating_pattern(n_sd: int, n_sym: int = 1) -> np.ndarray: + """The plan's example pattern: [+1, -1, +1, -1, ...]. Generally NOT a + codeword, so it exercises the nearest-match path.""" + row = np.where(np.arange(n_sd) % 2 == 0, 1, -1).astype(np.int8) + return np.tile(row, (n_sym, 1)) + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def _seed_int(s: str) -> int: + v = int(s, 0) + if not (0 <= v <= 0x7F): + raise argparse.ArgumentTypeError("scrambler seed must be a 7-bit value (0..127)") + return v + + +def build_argparser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser( + description="Encode a target OFDM subcarrier pattern into PSDU bytes by " + "inverting the 802.11 BPSK/BCC TX pipeline." + ) + ap.add_argument("--pattern", help="file of +-1 values (N_SD per symbol). " + "Omit to use the built-in alternating example.") + ap.add_argument("--scrambler-seed", type=_seed_int, default=DEFAULT_SEED, + help=f"chip scrambler seed, 7-bit (default 0x{DEFAULT_SEED:02x}); " + "discover the real one with seed_probe.py") + ap.add_argument("--phy", choices=("ht", "legacy"), default="legacy", + help="legacy = 802.11a/g 6 Mbps BPSK (48 SD, default, " + "matches PrecoderDemo's on-air rate); ht = HT MCS0 " + "(52 SD, correct math but not reachable on air — see " + "module docstring)") + ap.add_argument("--mcs", type=int, default=0, + help="HT MCS index (only 0 supported)") + ap.add_argument("--offset", type=int, default=0, + help="bit offset of the first emitted bit within the frame's " + "scrambled stream (default 0; see README for real-frame " + "placement)") + ap.add_argument("--strict", action="store_true", + help="fail if the target is not exactly representable instead " + "of emitting the nearest pattern") + ap.add_argument("--psdu-out", help="write packed PSDU bytes here") + ap.add_argument("--bruteforce", action="store_true", + help="emit 128 PSDUs (one per candidate seed) to " + ".seed_NN.bin — single-adapter seed-search " + "fallback (see seed_probe.py --mode bruteforce)") + return ap + + +def main(argv: list[str] | None = None) -> int: + args = build_argparser().parse_args(argv) + phy = phy_params(args.phy, args.mcs) + + if args.pattern: + targets = parse_pattern_file(args.pattern, phy.n_sd) + else: + targets = alternating_pattern(phy.n_sd) + print(f"[encode] no --pattern given; using built-in alternating " + f"example ({phy.n_sd} subcarriers)", file=sys.stderr) + + if args.bruteforce: + if not args.psdu_out: + print("--bruteforce requires --psdu-out (used as a filename prefix)", + file=sys.stderr) + return 2 + worst = 0 + for seed in range(128): + res = encode_pattern(targets, seed=seed, phy=phy, offset=args.offset) + worst = max(worst, res.hamming_distance) + with open(f"{args.psdu_out}.seed_{seed:02x}.bin", "wb") as fh: + fh.write(res.psdu_bytes) + print(f"[encode] wrote 128 candidate PSDUs to {args.psdu_out}.seed_*.bin " + f"({phy.name}, {targets.shape[0]} symbol(s), " + f"max nearest-distance {worst})", file=sys.stderr) + return 0 + + res = encode_pattern(targets, seed=args.scrambler_seed, phy=phy, + offset=args.offset, strict=args.strict) + status = "EXACT" if res.representable else f"NEAREST (+{res.hamming_distance})" + print(f"[encode] {phy.name} seed=0x{res.seed:02x} offset={res.offset} " + f"symbols={res.n_sym} info_bits={len(res.psdu_bits)} " + f"bytes={len(res.psdu_bytes)} -> {status}", file=sys.stderr) + if not res.representable: + print(f"[encode] target not a codeword: emitted pattern differs from the " + f"request in {res.hamming_distance} subcarrier(s) (use --strict to " + f"refuse)", file=sys.stderr) + + if args.psdu_out: + with open(args.psdu_out, "wb") as fh: + fh.write(res.psdu_bytes) + print(f"[encode] wrote {len(res.psdu_bytes)} bytes to {args.psdu_out}", + file=sys.stderr) + else: + sys.stdout.buffer.write(res.psdu_bytes) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/precoder/fft_capture.py b/tools/precoder/fft_capture.py new file mode 100644 index 0000000..f3b094d --- /dev/null +++ b/tools/precoder/fft_capture.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Phase-B RF verification for the precoder PoC: per-subcarrier IQ inspection. + +GATING: run this only after Phase A (byte-level round-trip via a partner +monitor adapter) passes — proving per-subcarrier control is pointless if the +scrambler/BCC/interleaver model is wrong, and the SDR stack is the expensive +part to stand up. + +What it does: take a 20 MHz baseband IQ capture of a precoder frame, recover +the OFDM data-subcarrier constellation symbol-by-symbol (FFT, channel-estimate +off the L-LTF, pilot phase-track, BPSK slice), and compare it to the target +pattern the encoder was asked for. Pattern in == pattern out is the proof. + +SDR NOTE (correcting the plan): an RTL-SDR tops out near ~2.4 MS/s, so it +CANNOT capture a 20 MHz 802.11 signal — you need a >=20 MS/s SDR (HackRF @ +20 Msps, USRP, LimeSDR, bladeRF). Capture is therefore done via SoapySDR +(device-agnostic) or from a pre-recorded IQ file. The OFDM math below is the +reusable core; full on-air frame synchronisation is scaffolded and flagged +where it needs bench validation. + +Runnable without hardware: + uv run python fft_capture.py --self-test # synthesise -> demod -> verify + +With a capture file (complex64 little-endian, 20 Msps, channel-centred): + uv run python fft_capture.py --iq frame.cf32 --pattern target.txt --phy ht +""" + +from __future__ import annotations + +import argparse +import sys + +import numpy as np + +import encode_subcarriers as enc + +FFT_N = 64 # 20 MHz OFDM +CP_LEN = 16 # long guard interval +SYM_LEN = FFT_N + CP_LEN +PILOTS = (-21, -7, 7, 21) + + +def data_subcarrier_offsets(phy: enc.PhyParams) -> np.ndarray: + """FFT bin offsets (-N..+N) carrying DATA, in ascending = logical order. + + This is the order the interleaver maps coded bits onto, so element k of the + encoder's per-symbol target corresponds to this list's element k. Excludes + DC (0), the four pilots, and the guard bands. + """ + edge = 28 if phy.n_sd == 52 else 26 # HT uses +-28, legacy +-26 + offs = [k for k in range(-edge, edge + 1) if k != 0 and k not in PILOTS] + assert len(offs) == phy.n_sd, (len(offs), phy.n_sd) + return np.array(offs, dtype=int) + + +def _bin_index(offset: int) -> int: + """Map an FFT bin offset (-32..31) to a numpy fft output index (0..63).""" + return offset % FFT_N + + +def modulate_symbol(data_pm1: np.ndarray, phy: enc.PhyParams, + pilot_pm1: float = 1.0) -> np.ndarray: + """Build one time-domain OFDM symbol (CP + 64 samples) from data subcarriers. + + Inverse of `demod_symbol`; used by --self-test and as ground truth. Pilots + are set to a constant BPSK value (good enough for the self-test's equaliser). + """ + freq = np.zeros(FFT_N, dtype=complex) + for off, val in zip(data_subcarrier_offsets(phy), data_pm1): + freq[_bin_index(off)] = val + for off in PILOTS: + freq[_bin_index(off)] = pilot_pm1 + time = np.fft.ifft(freq) * FFT_N / np.sqrt(FFT_N) + return np.concatenate([time[-CP_LEN:], time]) # prepend cyclic prefix + + +def demod_symbol(samples: np.ndarray, chan_est: np.ndarray | None = None + ) -> np.ndarray: + """FFT one received OFDM symbol (drop CP) -> 64 equalised frequency bins.""" + assert len(samples) >= SYM_LEN + body = samples[CP_LEN:SYM_LEN] + freq = np.fft.fft(body) / np.sqrt(FFT_N) + if chan_est is not None: + with np.errstate(divide="ignore", invalid="ignore"): + freq = np.where(np.abs(chan_est) > 1e-9, freq / chan_est, 0) + return freq + + +def pilot_phase_correct(freq: np.ndarray, ref_pm1: float = 1.0) -> np.ndarray: + """Derotate a symbol's bins by the average pilot phase error (residual CFO/SFO).""" + pil = np.array([freq[_bin_index(p)] for p in PILOTS]) + err = np.angle(np.sum(pil * np.conj(ref_pm1))) + return freq * np.exp(-1j * err) + + +def slice_data(freq: np.ndarray, phy: enc.PhyParams) -> np.ndarray: + """BPSK hard-slice the data subcarriers of an equalised symbol -> +-1.""" + offs = data_subcarrier_offsets(phy) + vals = np.array([freq[_bin_index(o)] for o in offs]) + return np.where(vals.real >= 0, 1, -1).astype(np.int8) + + +def compare(recovered: np.ndarray, target: np.ndarray) -> dict: + recovered = np.atleast_2d(recovered) + target = np.atleast_2d(target) + n = min(len(recovered), len(target)) + recovered, target = recovered[:n], target[:n] + mism = int(np.count_nonzero(recovered != target)) + total = target.size + return {"symbols": n, "subcarriers": total, "mismatches": mism, + "match_pct": 100.0 * (total - mism) / total if total else 0.0} + + +# --------------------------------------------------------------------------- # +# IQ input +# --------------------------------------------------------------------------- # +def load_iq(path: str) -> np.ndarray: + """Load complex baseband. .cf32 = float32 I,Q interleaved; .cs16 = int16.""" + if path.endswith(".cs16"): + raw = np.fromfile(path, dtype=np.int16).astype(np.float32) / 32768.0 + return raw[0::2] + 1j * raw[1::2] + raw = np.fromfile(path, dtype=np.float32) + return (raw[0::2] + 1j * raw[1::2]).astype(complex) + + +def find_symbol_starts(iq: np.ndarray, n_sym: int, threshold: float = 0.0 + ) -> int: + """Coarse energy-based burst start (first sample above threshold). + + NB: a production receiver syncs on the L-STF/L-LTF (autocorrelation + + cross-correlation) for sample-accurate timing and CFO; this energy gate is + a placeholder that needs bench validation on real captures. For the + --self-test path the start is known exactly. + """ + if threshold <= 0: + threshold = 0.5 * np.mean(np.abs(iq) ** 2) + above = np.where(np.abs(iq) ** 2 > threshold)[0] + return int(above[0]) if len(above) else 0 + + +# --------------------------------------------------------------------------- # +# Self-test: synthesise -> demod -> verify (no hardware) +# --------------------------------------------------------------------------- # +def self_test(phy: enc.PhyParams, snr_db: float = 25.0, n_sym: int = 3, + seed_rng: int = 0) -> int: + rng = np.random.default_rng(seed_rng) + target = rng.choice([1, -1], size=(n_sym, phy.n_sd)).astype(np.int8) + + # Build a faux PPDU: one LTF symbol (all data bins = +1 for a flat channel + # estimate) followed by the data symbols, with a benign multipath-ish gain. + ltf = modulate_symbol(np.ones(phy.n_sd), phy) + syms = [modulate_symbol(t, phy) for t in target] + tx = np.concatenate([ltf, *syms]) + + chan = 0.8 * np.exp(1j * 0.4) # flat complex channel gain + rx = tx * chan + sig_p = np.mean(np.abs(rx) ** 2) + noise = (rng.standard_normal(len(rx)) + 1j * rng.standard_normal(len(rx))) + rx = rx + noise * np.sqrt(sig_p / (2 * 10 ** (snr_db / 10))) + + # Channel estimate from the LTF symbol (known all-ones data bins + pilots). + ltf_freq = demod_symbol(rx[:SYM_LEN]) + ref_freq = np.fft.fft(ltf[CP_LEN:SYM_LEN]) / np.sqrt(FFT_N) + with np.errstate(divide="ignore", invalid="ignore"): + chan_est = np.where(np.abs(ref_freq) > 1e-9, ltf_freq / ref_freq, 1.0) + + recovered = [] + for s in range(n_sym): + start = SYM_LEN * (1 + s) + freq = demod_symbol(rx[start:start + SYM_LEN], chan_est) + freq = pilot_phase_correct(freq) + recovered.append(slice_data(freq, phy)) + recovered = np.array(recovered, dtype=np.int8) + + rep = compare(recovered, target) + ok = rep["mismatches"] == 0 + print(f"[self-test] {phy.name} snr={snr_db}dB symbols={n_sym}: " + f"{rep['match_pct']:.1f}% subcarriers recovered " + f"({rep['mismatches']} mismatch(es)) -> {'PASS' if ok else 'FAIL'}") + return 0 if ok else 1 + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def main(argv: "list[str] | None" = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--self-test", action="store_true", + help="synthesise an OFDM frame and verify the demod (no SDR)") + ap.add_argument("--iq", help="capture file (.cf32 float32 I/Q, or .cs16)") + ap.add_argument("--pattern", help="+-1 target pattern to compare against") + ap.add_argument("--phy", choices=("ht", "legacy"), default="legacy") + ap.add_argument("--n-sym", type=int, default=3, help="data symbols to demod") + ap.add_argument("--snr-db", type=float, default=25.0, help="self-test SNR") + args = ap.parse_args(argv) + phy = enc.phy_params(args.phy) + + if args.self_test: + return self_test(phy, snr_db=args.snr_db, n_sym=args.n_sym) + + if not args.iq: + print("need --iq (or --self-test). SDR live capture via " + "SoapySDR is a documented TODO — record to a .cf32 first with " + "your >=20 Msps SDR's own tooling.", file=sys.stderr) + return 2 + + iq = load_iq(args.iq) + start = find_symbol_starts(iq, args.n_sym) + # The L-LTF is two repeats; use the second as the channel reference. This + # alignment is the part that needs bench validation (see find_symbol_starts). + chan_est = demod_symbol(iq[start:start + SYM_LEN]) + recovered = [] + for s in range(args.n_sym): + seg = iq[start + SYM_LEN * (1 + s):start + SYM_LEN * (2 + s)] + if len(seg) < SYM_LEN: + break + freq = pilot_phase_correct(demod_symbol(seg, chan_est)) + recovered.append(slice_data(freq, phy)) + recovered = np.array(recovered, dtype=np.int8) + print(f"[fft] demodulated {len(recovered)} symbol(s) from {args.iq}") + + if args.pattern: + target = enc.parse_pattern_file(args.pattern, phy.n_sd) + rep = compare(recovered, target) + print(f"[fft] vs target: {rep['match_pct']:.1f}% subcarriers match " + f"({rep['mismatches']}/{rep['subcarriers']} mismatched over " + f"{rep['symbols']} symbol(s))") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/precoder/pyproject.toml b/tools/precoder/pyproject.toml new file mode 100644 index 0000000..7f6bb9b --- /dev/null +++ b/tools/precoder/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "devourer-precoder" +version = "0.1.0" +description = "Pre-modulator subcarrier encoder for devourer — inverts the 802.11 BPSK/BCC TX pipeline so chosen OFDM data subcarriers carry chosen bits." +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "numpy>=1.23", +] + +# Phase-B RF verification (tools/precoder/fft_capture.py) needs an SDR stack. +# Kept optional so the encoder + smoke tests install with numpy alone: +# uv sync --extra sdr +[project.optional-dependencies] +sdr = [ + "pyrtlsdr>=0.2.93", +] + +[dependency-groups] +dev = [ + "pytest>=7", +] + +[tool.pytest.ini_options] +# test_pipeline.py lives alongside the module; default (prepend) import mode +# puts this dir on sys.path so `import encode_subcarriers` resolves. +testpaths = ["test_pipeline.py"] diff --git a/tools/precoder/seed_probe.py b/tools/precoder/seed_probe.py new file mode 100644 index 0000000..f7f6061 --- /dev/null +++ b/tools/precoder/seed_probe.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Discover / characterise the chip scrambler seed for the precoder. + +The 802.11 scrambler is initialised to a (pseudo-random) non-zero 7-bit seed +*per PPDU*. To pre-descramble a PSDU so the chip emits a chosen subcarrier +pattern (encode_subcarriers.py), we must know the seed the chip will use. Two +strategies, mirroring the plan: + + --mode rx (primary intent) + Read the descrambler seed the chip recovers from frames it *receives*. + Run `WiFiDriverDemo` with DEVOURER_DUMP_SCRAMBLER=1 on a second adapter + pointed at the precoder TX; it prints `seed=0xNN` + lines. This script parses them and reports whether the seed is CONSTANT + (one shaped PSDU works) or VARYING per frame (brute-force needed). + + CAVEAT: the seed field is only trustworthy when the RX adapter is an + RTL8814AU — the 8812/8821 RX descriptor doesn't carry it there (see + FrameParser.cpp). On 8812/8821 use --mode bruteforce. + + --mode bruteforce (robust fallback, single adapter) + Expand one target pattern into 128 candidate PSDUs (one per possible + seed) via encode_subcarriers. Transmit them all; whichever frames the + chip scrambles with seed k will show the target only when the matching + variant-k PSDU was sent. Costs ~128x airtime but needs no seed knowledge + and no second adapter. + +Examples: + # Spawn the RX demo on adapter 0x8813 and characterise for 20 s: + uv run python seed_probe.py --mode rx --rx-pid 0x8813 --channel 6 --duration 20 + + # Or parse a previously captured log / piped stdout: + uv run python seed_probe.py --mode rx --from-log demo.log + + # Generate the 128-variant brute-force set from a target pattern: + uv run python seed_probe.py --mode bruteforce --pattern target.txt --out bf +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from collections import Counter + +import encode_subcarriers as enc + +_SEED_RE = re.compile(rb"seed=0x([0-9a-fA-F]{2})") + + +def _iter_seed_lines(stream) -> "list[int]": + seeds = [] + for raw in stream: + if isinstance(raw, str): + raw = raw.encode("utf-8", "replace") + m = _SEED_RE.search(raw) + if m: + seeds.append(int(m.group(1), 16)) + return seeds + + +def _report(seeds: "list[int]") -> int: + if not seeds: + print("seed_probe: no lines seen. Is the RX demo " + "running with DEVOURER_DUMP_SCRAMBLER=1, pointed at the precoder " + "TX on the same channel?", file=sys.stderr) + return 1 + hist = Counter(seeds) + uniq = sorted(hist) + print(f"seed_probe: {len(seeds)} frame(s), {len(uniq)} distinct seed(s)") + for s, n in hist.most_common(8): + print(f" seed=0x{s:02x} {n:5d} frame(s) ({100*n/len(seeds):.1f}%)") + if len(uniq) > 8: + print(f" ... and {len(uniq) - 8} more") + if len(uniq) == 1: + print(f"\nVERDICT: CONSTANT seed 0x{uniq[0]:02x} — a single shaped PSDU " + f"works. Encode with --scrambler-seed 0x{uniq[0]:02x}.") + else: + print("\nVERDICT: seed VARIES across frames — the chip re-seeds per " + "PPDU. Use --mode bruteforce (you cannot pin a single PSDU).") + return 0 + + +def mode_rx(args) -> int: + if args.from_log: + if args.from_log == "-": + return _report(_iter_seed_lines(sys.stdin.buffer)) + with open(args.from_log, "rb") as fh: + return _report(_iter_seed_lines(fh)) + + # Spawn the RX demo and tee its output while collecting seeds. + env = dict(os.environ, DEVOURER_DUMP_SCRAMBLER="1") + if args.rx_pid: + env["DEVOURER_PID"] = args.rx_pid + if args.channel: + env["DEVOURER_CHANNEL"] = str(args.channel) + cmd = [args.demo_bin] + print(f"seed_probe: launching {' '.join(cmd)} (DEVOURER_DUMP_SCRAMBLER=1, " + f"pid={args.rx_pid or 'any'}, channel={args.channel or 'default'}) " + f"for {args.duration}s", file=sys.stderr) + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, env=env) + except FileNotFoundError: + print(f"seed_probe: demo binary not found: {args.demo_bin} " + f"(build it, or pass --demo-bin / --from-log)", file=sys.stderr) + return 2 + seeds: "list[int]" = [] + try: + import time + end = time.monotonic() + args.duration + assert proc.stdout is not None + while time.monotonic() < end: + line = proc.stdout.readline() + if not line: + break + m = _SEED_RE.search(line) + if m: + seeds.append(int(m.group(1), 16)) + finally: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + return _report(seeds) + + +def mode_bruteforce(args) -> int: + if not args.pattern: + print("--mode bruteforce requires --pattern", file=sys.stderr) + return 2 + phy = enc.phy_params(args.phy) + targets = enc.parse_pattern_file(args.pattern, phy.n_sd) + prefix = args.out or "bf" + for seed in range(128): + res = enc.encode_pattern(targets, seed=seed, phy=phy, offset=args.offset) + with open(f"{prefix}.seed_{seed:02x}.bin", "wb") as fh: + fh.write(res.psdu_bytes) + print(f"seed_probe: wrote 128 candidate PSDUs to {prefix}.seed_*.bin " + f"({phy.name}, {targets.shape[0]} symbol(s)).") + print("Transmit them in a loop (e.g. cycle PrecoderDemo --psdu over each " + "file); frames whose variant index matches the chip's per-frame seed " + "will carry the target pattern. Capture with fft_capture.py.") + return 0 + + +def main(argv: "list[str] | None" = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--mode", choices=("rx", "bruteforce"), required=True) + # rx + ap.add_argument("--demo-bin", default="../../build/WiFiDriverDemo", + help="path to WiFiDriverDemo (rx mode)") + ap.add_argument("--rx-pid", help="DEVOURER_PID for the RX adapter (rx mode)") + ap.add_argument("--channel", type=int, help="capture channel (rx mode)") + ap.add_argument("--duration", type=float, default=20.0, + help="seconds to collect (rx mode, default 20)") + ap.add_argument("--from-log", help="parse seeds from a file or '-' (stdin) " + "instead of spawning the demo (rx mode)") + # bruteforce + ap.add_argument("--pattern", help="+-1 target pattern file (bruteforce mode)") + ap.add_argument("--out", help="output filename prefix (bruteforce mode)") + ap.add_argument("--phy", choices=("ht", "legacy"), default="legacy") + ap.add_argument("--offset", type=int, default=0) + args = ap.parse_args(argv) + return mode_rx(args) if args.mode == "rx" else mode_bruteforce(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/precoder/test_pipeline.py b/tools/precoder/test_pipeline.py new file mode 100644 index 0000000..a920d64 --- /dev/null +++ b/tools/precoder/test_pipeline.py @@ -0,0 +1,217 @@ +"""Unit tests for the pre-modulator encoder pipeline. + +Pure software — no hardware, no libusb. Runs under the uv project env: + + cd tools/precoder && uv run pytest + +The known-answer tests pin the actual standard: + * scrambler -> recurrence o[m]=o[m-4]^o[m-7] (polynomial x^7+x^4+1) + maximal + length (period 127, 64 ones per period); + * BCC -> impulse response equals the (133,171) generators; + * interleaver -> bijection + self-inverse; + * full pipeline -> bit-exact round-trip for representable targets, and the + nearest-pattern Hamming-distance contract for arbitrary ones. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import encode_subcarriers as enc + +PHYS = [enc.phy_params("ht"), enc.phy_params("legacy")] +PHY_IDS = ["ht", "legacy"] + + +# --------------------------------------------------------------------------- # +# Scrambler +# --------------------------------------------------------------------------- # +def test_scrambler_recurrence_pins_polynomial(): + # o[m] = o[m-4] ^ o[m-7] is exactly the polynomial 1 + x^4 + x^7. + seq = enc.scrambler_sequence(0x5D, 300) + m = np.arange(7, len(seq)) + assert np.array_equal(seq[m], seq[m - 4] ^ seq[m - 7]) + + +@pytest.mark.parametrize("seed", [1, 0x5D, 0x7F, 42]) +def test_scrambler_maximal_length(seed): + seq = enc.scrambler_sequence(seed, 254) + # Period exactly 127 (prime) and 64 ones per period => maximal m-sequence. + assert np.array_equal(seq[:127], seq[127:254]) + assert int(seq[:127].sum()) == 64 + + +def test_scrambler_is_self_inverse(): + rng = np.random.default_rng(0) + bits = rng.integers(0, 2, size=200, dtype=np.uint8) + once = enc.apply_scrambler(bits, 0x5D) + twice = enc.apply_scrambler(once, 0x5D) + assert np.array_equal(bits, twice) + assert not np.array_equal(bits, once) # it actually did something + + +def test_scrambler_offset_matches_slice(): + full = enc.scrambler_sequence(0x5D, 100) + bits = np.zeros(20, dtype=np.uint8) + # scrambling zeros yields the sequence itself; offset must select a window. + assert np.array_equal(enc.apply_scrambler(bits, 0x5D, offset=33), full[33:53]) + + +# --------------------------------------------------------------------------- # +# BCC +# --------------------------------------------------------------------------- # +def test_bcc_impulse_response_equals_generators(): + info = np.zeros(7, dtype=np.uint8) + info[0] = 1 + coded = enc.bcc_encode(info) + a_stream = coded[0::2] + b_stream = coded[1::2] + # 0o133 = 1011011, 0o171 = 1111001 (MSB first). + assert np.array_equal(a_stream, [1, 0, 1, 1, 0, 1, 1]) + assert np.array_equal(b_stream, [1, 1, 1, 1, 0, 0, 1]) + + +def test_bcc_viterbi_recovers_clean_codeword(): + rng = np.random.default_rng(1) + info = rng.integers(0, 2, size=60, dtype=np.uint8) + coded = enc.bcc_encode(info) + recovered, dist = enc.bcc_viterbi_preimage(coded) + assert dist == 0 + assert np.array_equal(recovered, info) + + +def test_bcc_viterbi_corrects_single_error(): + rng = np.random.default_rng(2) + info = rng.integers(0, 2, size=80, dtype=np.uint8) + coded = enc.bcc_encode(info).copy() + coded[10] ^= 1 # inject one bit error mid-stream + recovered, dist = enc.bcc_viterbi_preimage(coded) + # K=7 free distance is 10, so a single error is well within correction. + assert np.array_equal(recovered, info) + assert dist == 1 + + +# --------------------------------------------------------------------------- # +# Interleaver +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize("p", PHYS, ids=PHY_IDS) +def test_interleaver_is_a_bijection(p): + perm = enc.interleaver_perm(p) + assert np.array_equal(np.sort(perm), np.arange(p.n_cbps)) + + +@pytest.mark.parametrize("p", PHYS, ids=PHY_IDS) +def test_interleave_deinterleave_inverse(p): + rng = np.random.default_rng(3) + coded = rng.integers(0, 2, size=p.n_cbps, dtype=np.uint8) + assert np.array_equal(enc.deinterleave(enc.interleave(coded, p), p), coded) + + +# --------------------------------------------------------------------------- # +# BPSK + byte packing +# --------------------------------------------------------------------------- # +def test_bpsk_map_demap_inverse(): + bits = np.array([0, 1, 0, 1, 1, 0], dtype=np.uint8) + assert np.array_equal(enc.bpsk_map(bits), [1, -1, 1, -1, -1, 1]) + assert np.array_equal(enc.bpsk_demap(enc.bpsk_map(bits)), bits) + + +def test_bits_bytes_roundtrip(): + rng = np.random.default_rng(4) + bits = rng.integers(0, 2, size=26, dtype=np.uint8) # not a byte multiple + data = enc.bits_to_bytes(bits) + assert np.array_equal(enc.bytes_to_bits(data, n=len(bits)), bits) + + +# --------------------------------------------------------------------------- # +# Full pipeline round-trip +# --------------------------------------------------------------------------- # +def _representable_target(p, seed, n_sym, offset, rng): + """Build a target that IS reachable by the code: pick random info, run the + forward model, use its constellation as the target.""" + info_bits = rng.integers(0, 2, size=n_sym * p.n_dbps, dtype=np.uint8) + psdu_bits = enc.apply_scrambler(info_bits, seed, offset) # info domain -> PSDU + target = enc.emulate_chip(psdu_bits, seed, p, n_sym, offset) + return target, psdu_bits + + +@pytest.mark.parametrize("p", PHYS, ids=PHY_IDS) +@pytest.mark.parametrize("n_sym", [1, 3]) +@pytest.mark.parametrize("offset", [0, 16]) +def test_representable_roundtrip_is_exact(p, n_sym, offset): + rng = np.random.default_rng(5) + seed = 0x5D + target, psdu_bits = _representable_target(p, seed, n_sym, offset, rng) + + res = enc.encode_pattern(target, seed=seed, phy=p, offset=offset) + assert res.representable + assert res.hamming_distance == 0 + assert np.array_equal(res.psdu_bits, psdu_bits) + + # The chip emulation of our bytes reproduces the requested constellation. + back = enc.emulate_chip(res.psdu_bits, seed, p, n_sym, offset) + assert np.array_equal(back, target) + + +@pytest.mark.parametrize("p", PHYS, ids=PHY_IDS) +def test_arbitrary_target_nearest_distance_contract(p): + rng = np.random.default_rng(6) + seed = 0x11 + target = rng.choice([1, -1], size=(2, p.n_sd)).astype(np.int8) + + res = enc.encode_pattern(target, seed=seed, phy=p, offset=0) + # A random target is essentially never a codeword (2^-N_DBPS per symbol). + assert res.hamming_distance > 0 + back = enc.emulate_chip(res.psdu_bits, seed, p, n_sym=2, offset=0) + # The emulated pattern differs from the target in exactly H subcarriers. + assert int(np.count_nonzero(back != target)) == res.hamming_distance + + +def test_bcc_final_state_matches_continuation(): + rng = np.random.default_rng(8) + prefix = rng.integers(0, 2, size=200, dtype=np.uint8) + tail = rng.integers(0, 2, size=60, dtype=np.uint8) + # Encoding prefix+tail in one shot == encoding tail from prefix's final state. + joined = enc.bcc_encode(np.concatenate([prefix, tail])) + state = enc.bcc_final_state(prefix) + continued = enc.bcc_encode(tail, init_state=state) + assert np.array_equal(joined[2 * len(prefix):], continued) + + +@pytest.mark.parametrize("p", PHYS, ids=PHY_IDS) +def test_mid_frame_entry_state_roundtrip(p): + # Exact control of a symbol that follows a SERVICE field + MAC header: the + # BCC state entering it is non-zero, derived from the preceding bits. + rng = np.random.default_rng(9) + seed = 0x5D + prefix_scrambled = rng.integers(0, 2, size=16 + 24 * 8, dtype=np.uint8) + entry = enc.bcc_final_state(prefix_scrambled) + assert entry != 0 # the whole point: header leaves a non-zero state + + info = rng.integers(0, 2, size=2 * p.n_dbps, dtype=np.uint8) + psdu = enc.apply_scrambler(info, seed, offset=len(prefix_scrambled)) + target = enc.emulate_chip(psdu, seed, p, 2, + offset=len(prefix_scrambled), entry_state=entry) + + res = enc.encode_pattern(target, seed=seed, phy=p, + offset=len(prefix_scrambled), entry_state=entry) + assert res.representable + assert np.array_equal(res.psdu_bits, psdu) + + +def test_strict_refuses_non_representable(): + p = enc.phy_params("ht") + rng = np.random.default_rng(7) + target = rng.choice([1, -1], size=p.n_sd).astype(np.int8) + with pytest.raises(ValueError): + enc.encode_pattern(target, phy=p, strict=True) + + +def test_alternating_example_runs_for_both_phys(): + for p in PHYS: + target = enc.alternating_pattern(p.n_sd) + res = enc.encode_pattern(target, phy=p) # non-strict: nearest is fine + assert len(res.psdu_bits) == p.n_dbps + back = enc.emulate_chip(res.psdu_bits, res.seed, p, n_sym=1) + assert int(np.count_nonzero(back != target)) == res.hamming_distance diff --git a/tools/precoder/uv.lock b/tools/precoder/uv.lock new file mode 100644 index 0000000..03e015a --- /dev/null +++ b/tools/precoder/uv.lock @@ -0,0 +1,429 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "devourer-precoder" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.optional-dependencies] +sdr = [ + { name = "pyrtlsdr" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=1.23" }, + { name = "pyrtlsdr", marker = "extra == 'sdr'", specifier = ">=0.2.93" }, +] +provides-extras = ["sdr"] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=7" }] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrtlsdr" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/e0/4011cab0c7d1f886c2851e1d683fc807130eb7a90d48a4e2dabfde50b0e0/pyrtlsdr-0.4.0.tar.gz", hash = "sha256:67f10775b8b56be9d64fb932881795c2a896425f1efbc78cca6ea8b734c46cde", size = 37623, upload-time = "2026-03-01T21:47:43.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/e2/ee05a0e985e1175be2ed451dcdd525fc4901bbe891399056d56fd9ab47c4/pyrtlsdr-0.4.0-py3-none-any.whl", hash = "sha256:964dc69e261f608f617db0e48c5d92e8d8c975f1c1c3c1d8add8d8012b72bd14", size = 31484, upload-time = "2026-03-01T21:47:41.541Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/txdemo/precoder_demo/main.cpp b/txdemo/precoder_demo/main.cpp new file mode 100644 index 0000000..d633ee9 --- /dev/null +++ b/txdemo/precoder_demo/main.cpp @@ -0,0 +1,252 @@ +// PrecoderDemo — transmit a pre-shaped PSDU produced by +// tools/precoder/encode_subcarriers.py. +// +// This is the on-air vehicle for the pre-modulator subcarrier PoC: the Python +// encoder inverts the chip's BPSK/BCC/interleaver/scrambler pipeline and emits +// the PSDU bytes that make chosen OFDM data subcarriers carry chosen bits. +// This demo wraps those bytes in the fixed radiotap + 802.11 header the chip +// and the existing TX-validation tooling expect, then streams them out. +// +// Scope (matches let-s-plan-this PoC): RTL8812AU / RTL8821AU / RTL8811AU, +// single-stream BPSK, BCC, 20 MHz. RTL8814AU is out of scope (issue #36 TX +// flakiness would mask the experiment). +// +// RATE CHOICE — legacy 6 Mbps OFDM, not HT MCS 0. The plan said HT MCS 0, but +// RtlJaguarDevice::send_packet only wires `fixed_rate` from the radiotap RATE +// field (legacy) or the VHT field — the HT MCS *index* is never read, so an +// HT-MCS radiotap with no RATE field would transmit at the MGN_1M default = +// 1 Mbps CCK (DSSS, NO OFDM subcarriers at all), defeating the whole PoC. +// Legacy 6 Mbps is BPSK rate-1/2 OFDM (48 data subcarriers, 16x3 interleaver) +// = exactly the encoder's `--phy legacy` and the plan's "48 subcarrier" prose. +// Encode shaped PSDUs with `--phy legacy` (the encoder default) to match. +// +// Usage: +// DEVOURER_PID=0x8812 DEVOURER_CHANNEL=6 ./build/PrecoderDemo --psdu shaped.bin +// [--count N] [--interval-ms MS] (Termux: pass the numeric USB fd as argv[1]) +// +// Env: DEVOURER_VID / DEVOURER_PID / DEVOURER_CHANNEL / DEVOURER_SKIP_RESET — +// same conventions as the other demos. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MSC_VER) + #include + #include + typedef int pid_t; + #define sleep(seconds) Sleep((seconds)*1000) +#elif defined(__ANDROID__) + #include +#elif defined(__APPLE__) + #include + #include +#else + #include + #include +#endif + +#include "FrameParser.h" +#include "RtlUsbAdapter.h" +#include "WiFiDriver.h" +#include "logger.h" + +#define USB_VENDOR_ID 0x0bda + +// Same PID set as the RX/TX demos. The precoder PoC targets the single-stream +// Jaguar parts; pin one with DEVOURER_PID (e.g. 0x8812). +static constexpr uint16_t kRealtekProductIds[] = { + 0x8812, 0x0811, 0xa811, 0xb811, 0x8813, +}; + +// Legacy 6 Mbps OFDM radiotap header (13 bytes). Presence = RATE(bit2) | +// TX_FLAGS(bit15); it_present = 0x00008004. Field layout after the 8-byte +// header: RATE @ offset 8 = 0x0c (12 * 500 kbps = 6 Mbps; numerically == the +// MGN_6M enum send_packet feeds to MRateToHwRate -> DESC_RATE6M), one pad byte +// for TX_FLAGS' 2-byte alignment, TX_FLAGS @ 10-11 = 0x0008, one trailing pad. +// Kept at exactly 13 bytes (0x0d) so send_packet's `len != 0x0d -> vht=true` +// heuristic leaves us on the non-VHT path (rate_id 8). +static const uint8_t kRadiotapLegacy6M[13] = { + 0x00, 0x00, 0x0d, 0x00, 0x04, 0x80, 0x00, + 0x00, 0x0c, 0x00, 0x08, 0x00, 0x00}; + +// Canonical TX-validation source MAC — shared with txdemo/main.cpp, +// demo/main.cpp's `` matcher, tests/regress.py (CANONICAL_SA) +// and tests/inject_beacon.py. Change all of them together if it ever moves. +static const uint8_t kCanonicalSa[6] = {0x57, 0x42, 0x75, 0x05, 0xd6, 0x00}; + +// 802.11 probe-request mgmt header (24 bytes), mirroring txdemo/main.cpp's +// frame. DATA frames (ToDS) get silently NAKed by the chip in monitor mode — +// the plan's "Data frame" wording is superseded by txdemo's hard-won +// probe-request, which the SA matcher recognises identically (addr2 at +10). +static std::vector build_dot11_probe_req() { + std::vector h = { + 0x40, 0x00, // frame control: mgmt / probe request + 0x00, 0x00, // duration + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, // addr1 / DA (broadcast) + }; + h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); // addr2 / SA + h.insert(h.end(), kCanonicalSa, kCanonicalSa + 6); // addr3 / BSSID + h.push_back(0x80); // seq_ctl + h.push_back(0x00); + return h; // 24 bytes +} + +static bool read_file(const std::string &path, std::vector &out) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + out.assign(std::istreambuf_iterator(f), + std::istreambuf_iterator()); + return true; +} + +int main(int argc, char **argv) { + auto logger = std::make_shared(); + + std::string psdu_path; + long count = -1; // -1 == loop forever (like txdemo) + int interval_ms = 2; // ~500 fps, gentle on the bulk EP + long termux_fd = 0; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--psdu" && i + 1 < argc) { + psdu_path = argv[++i]; + } else if (a == "--count" && i + 1 < argc) { + count = std::strtol(argv[++i], nullptr, 0); + } else if (a == "--interval-ms" && i + 1 < argc) { + interval_ms = std::atoi(argv[++i]); + } else { + // A bare numeric arg is the Termux USB fd (libusb_wrap_sys_device). + char *end = nullptr; + long v = std::strtol(a.c_str(), &end, 0); + if (end && *end == '\0' && v > 0) termux_fd = v; + } + } + + if (psdu_path.empty()) { + logger->error("usage: PrecoderDemo --psdu [--count N] " + "[--interval-ms MS]"); + return 2; + } + + std::vector psdu; + if (!read_file(psdu_path, psdu) || psdu.empty()) { + logger->error("cannot read PSDU file (or it is empty): {}", psdu_path); + return 2; + } + logger->info("loaded {} PSDU bytes from {}", psdu.size(), psdu_path); + + libusb_context *context = nullptr; + libusb_device_handle *handle = nullptr; + int rc; + + if (termux_fd > 0) { + logger->info("Termux mode: wrapping fd {}", termux_fd); + libusb_set_option(NULL, LIBUSB_OPTION_NO_DEVICE_DISCOVERY); + libusb_set_option(NULL, LIBUSB_OPTION_WEAK_AUTHORITY); + libusb_init(&context); + rc = libusb_wrap_sys_device(context, (intptr_t)termux_fd, &handle); + if (rc < 0) { + logger->error("libusb_wrap_sys_device: {}", rc); + return 1; + } + } else { + rc = libusb_init(&context); + if (rc < 0) return rc; + + uint16_t target_pid = 0; + if (const char *pid_env = std::getenv("DEVOURER_PID")) { + target_pid = static_cast(std::strtoul(pid_env, nullptr, 0)); + logger->info("DEVOURER_PID={:04x} (limiting to this PID)", target_pid); + } + uint16_t target_vid = USB_VENDOR_ID; + if (const char *vid_env = std::getenv("DEVOURER_VID")) { + target_vid = static_cast(std::strtoul(vid_env, nullptr, 0)); + logger->info("DEVOURER_VID={:04x} (overriding default VID)", target_vid); + } + for (uint16_t pid : kRealtekProductIds) { + if (target_pid != 0 && pid != target_pid) continue; + handle = libusb_open_device_with_vid_pid(context, target_vid, pid); + if (handle != NULL) { + logger->info("Opened device {:04x}:{:04x}", target_vid, pid); + break; + } + } + if (handle == NULL && target_pid != 0) { + handle = libusb_open_device_with_vid_pid(context, target_vid, target_pid); + } + if (handle == NULL) { + logger->error("No supported device found under VID {:04x}", target_vid); + libusb_exit(context); + return 1; + } + } + + if (libusb_kernel_driver_active(handle, 0)) { + rc = libusb_detach_kernel_driver(handle, 0); + if (rc != 0) logger->error("libusb_detach_kernel_driver: {}", rc); + } + if (termux_fd == 0 && !std::getenv("DEVOURER_SKIP_RESET")) { + libusb_reset_device(handle); + } + rc = libusb_claim_interface(handle, 0); + assert(rc == 0); + + WiFiDriver wifi_driver{logger}; + auto rtlDevice = wifi_driver.CreateRtlDevice(handle); + + // 2.4 GHz channel 6 is the plan's matrix-validated cell for these chips. + int channel = 6; + if (const char *ch_env = std::getenv("DEVOURER_CHANNEL")) { + channel = std::atoi(ch_env); + logger->info("DEVOURER_CHANNEL set — tuning TX to channel {}", channel); + } + + rtlDevice->SetTxPower(40); + rtlDevice->InitWrite(SelectedChannel{.Channel = static_cast(channel), + .ChannelOffset = 0, + .ChannelWidth = CHANNEL_WIDTH_20}); + sleep(2); + + // [ radiotap (13) | 802.11 probe-req hdr (24) | shaped PSDU ]. The MAC header + // is 24 bytes; with the PHY's 16-bit SERVICE prefix that is 16 + 24*8 = 208 + // scrambled-stream bits before the body. For *exact* per-subcarrier control + // the encoder must be run with --offset 208 and the matching entry_state + // (legacy N_DBPS=24 -> the body starts mid-symbol, so the first fully + // controllable symbol is the next 24-bit boundary; see + // tools/precoder/README.md). For a byte-level round-trip (Phase A) the offset + // is irrelevant — the shaped bytes come back verbatim. + auto dot11 = build_dot11_probe_req(); + std::vector tx_buf; + tx_buf.reserve(sizeof(kRadiotapLegacy6M) + dot11.size() + psdu.size()); + tx_buf.insert(tx_buf.end(), kRadiotapLegacy6M, + kRadiotapLegacy6M + sizeof(kRadiotapLegacy6M)); + tx_buf.insert(tx_buf.end(), dot11.begin(), dot11.end()); + tx_buf.insert(tx_buf.end(), psdu.begin(), psdu.end()); + logger->info("TX frame (legacy 6M OFDM): {} radiotap + {} hdr + {} PSDU = {} " + "bytes total", sizeof(kRadiotapLegacy6M), dot11.size(), + psdu.size(), tx_buf.size()); + + long tx_count = 0; + while (count < 0 || tx_count < count) { + bool ok = rtlDevice->send_packet(tx_buf.data(), tx_buf.size()); + ++tx_count; + if (tx_count <= 10 || tx_count % 500 == 0) { + printf("TX #%ld ok=%d\n", tx_count, ok ? 1 : 0); + fflush(stdout); + } + std::this_thread::sleep_for(std::chrono::milliseconds(interval_ms)); + } + + libusb_release_interface(handle, 0); + libusb_close(handle); + libusb_exit(context); + return 0; +}