From c392c5fd42a3d5c80582124f2bf39dcff8ba9423 Mon Sep 17 00:00:00 2001 From: howhangliu Date: Sat, 4 Jul 2026 22:36:12 +0200 Subject: [PATCH 1/4] pof: fix get_next_deadline --- pof.c | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pof.c b/pof.c index 7449d14..e66b7f8 100644 --- a/pof.c +++ b/pof.c @@ -238,10 +238,13 @@ static struct timespec *get_next_deadline(struct Pof *pof) pof->next_to_forward = NULL; if (pof->queue_len == 0) return NULL; + // earliest deadline in the buffer (queue is ordered by seq, not arrival, + // so we must scan; the elem holding it is the one a timeout releases) struct timespec *ret = &pof->q_head->forward_time; - struct PofElem *iter = pof->q_head; + pof->next_to_forward = pof->q_head; + struct PofElem *iter = pof->q_head->next; while (iter) { - if (timespeccmp(&iter->forward_time, ret, !=)) { + if (timespeccmp(&iter->forward_time, ret, <)) { ret = &iter->forward_time; pof->next_to_forward = iter; } @@ -266,9 +269,10 @@ static void pof_try_forward(struct Pof *pof, int event) //TODO on timeout the packet with the lowest seq should be sent not the oldest one in the queue // (this follows the RFC, but it's wrong) if ((event & POF_TIMEOUT) && pof->take_any == false) { - log_packet("timeout, next to forward %u", pof->next_to_forward->seq); - if (pof->next_to_forward) + if (pof->next_to_forward) { + log_packet("timeout, next to forward %u", pof->next_to_forward->seq); pkt_to_send = pof->next_to_forward; + } } log_packet("try forward, to send %u last sent %u", pkt_to_send->seq, pof->pof_last_sent); while (pof->queue_len > 0) { From 08efbb6777964da5ec60b6a8cc769172d518f199 Mon Sep 17 00:00:00 2001 From: howhangliu Date: Sat, 4 Jul 2026 23:38:38 +0200 Subject: [PATCH 2/4] pof: fire expired deadlines immediately, harden event handling Three further fixes to the POF worker found under sustained traffic (100 pkt/s FRER stream with reordering between member paths): - When the earliest forward deadline had already passed at re-arm time, the poll timeout was set to take_any_time (seconds) instead of firing immediately. Under continuous traffic every new arrival re-armed it again, so held packets were never released: the conditional delay buffer filled up and all subsequent packets were dropped ('buffer is full, dropping new packet'). Arm a zero timeout when the deadline is in the past. - The eventfd is a counter, so concurrent notifications sum up (e.g. two IN_ORDER writes read back as 2 == OUT_OF_ORDER) and bit-testing the sum misclassifies events. Attempt a forward on any positive event; pof_try_forward() already only forwards when the head is next in order. - Guard pof_try_forward() against an empty queue: it dereferenced q_head unconditionally, which can be NULL when an event is read after a timeout already drained the queue. Co-Authored-By: Claude Fable 5 --- pof.c | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/pof.c b/pof.c index e66b7f8..577bda5 100644 --- a/pof.c +++ b/pof.c @@ -265,6 +265,8 @@ static void pof_forward(struct PofElem *pe) static void pof_try_forward(struct Pof *pof, int event) { + if (pof->queue_len == 0 || pof->q_head == NULL) + return; struct PofElem *pkt_to_send = pof->q_head; //TODO on timeout the packet with the lowest seq should be sent not the oldest one in the queue // (this follows the RFC, but it's wrong) @@ -304,10 +306,12 @@ static void *pof_thread(void *arg) struct timespec now, timeout; struct timespec *next_deadline = get_next_deadline(pof); clock_gettime(CLOCK_REALTIME, &now); - if (next_deadline && timespeccmp(next_deadline, &now, >)) - timespecsub(next_deadline, &now, &timeout); - else + if (next_deadline == NULL) timeout = pof->pof_take_any_time; + else if (timespeccmp(next_deadline, &now, >)) + timespecsub(next_deadline, &now, &timeout); + else // deadline already passed: fire immediately, don't wait take_any_time + timeout = (struct timespec){ 0, 0 }; while (true) { int ret = ppoll(&fd, 1, &timeout, NULL); if (ret < 0) { @@ -329,8 +333,11 @@ static void *pof_thread(void *arg) } //TODO instead of this: if first item's seq == pof->pof_last_sent + 1 // even better: no if here, decide it in pof_try_forward() - if (event & POF_IN_ORDER_PKT) { - pof_try_forward(pof, event); + // eventfd is a counter: concurrent writes sum up (e.g. 1+1=2), so + // testing bits of the sum misclassifies events. Attempting a + // forward is harmless when the head is not next-in-order. + if (event > 0) { + pof_try_forward(pof, POF_IN_ORDER_PKT); } } else if (ret == 0) { // POF timeout, packet deadline or take_any if (pof->queue_len != 0) { @@ -343,10 +350,12 @@ static void *pof_thread(void *arg) out: next_deadline = get_next_deadline(pof); clock_gettime(CLOCK_REALTIME, &now); - if (next_deadline && timespeccmp(next_deadline, &now, >)) - timespecsub(next_deadline, &now, &timeout); - else + if (next_deadline == NULL) timeout = pof->pof_take_any_time; + else if (timespeccmp(next_deadline, &now, >)) + timespecsub(next_deadline, &now, &timeout); + else // deadline already passed: fire immediately, don't wait take_any_time + timeout = (struct timespec){ 0, 0 }; pthread_mutex_unlock(&pof->lock); } From b314ebd23adc2f3944f8f8a3aba55d38c5e81b59 Mon Sep 17 00:00:00 2001 From: howhangliu Date: Wed, 2 Sep 2026 09:56:33 +0200 Subject: [PATCH 3/4] dnt_testbed: add FRER-AoI reproduction scenario Adds the network-namespace testbed used to find and verify the two POF fixes in this branch. It is self-contained (netns + veth + tc netem, no external hardware) so the failure can be reproduced from a clean checkout. Topology: a periodic talker feeds a FRER stream that is replicated over two member paths with independent exponential delay, then recovered and reordered by a second DNT instance (nxp1.ini -> nxp2.ini). The reordering between member paths under sustained 100 pkt/s traffic is what drove the POF conditional delay buffer into the 'buffer is full, dropping new packet' state before the fix. Contents: env_frer_aoi.sh namespace/veth/netem builder (source as root) verify_setup.sh checks the environment before a run talker.py periodic source listener.py receiver, records per-packet peak age gen_expo_dist.py netem exponential delay table run_point.sh one (D,H) measurement point sweep.sh sweeps D, R repetitions per point aggregate_results.py sweep output -> per-(D,H) means with 95% CIs README.md full walkthrough, incl. the repro procedure Generated result files are gitignored; finished campaigns can be archived under dnt_testbed/results_archive/ if wanted. Co-Authored-By: Claude Opus 5 --- .gitignore | 7 + dnt_testbed/README.md | 308 +++++++++++++++++++++++++++++++ dnt_testbed/aggregate_results.py | 62 +++++++ dnt_testbed/env_frer_aoi.sh | 60 ++++++ dnt_testbed/gen_expo_dist.py | 29 +++ dnt_testbed/listener.py | 72 ++++++++ dnt_testbed/nxp1.ini | 18 ++ dnt_testbed/nxp2.ini | 24 +++ dnt_testbed/run_point.sh | 26 +++ dnt_testbed/sweep.sh | 29 +++ dnt_testbed/talker.py | 26 +++ dnt_testbed/verify_setup.sh | 32 ++++ 12 files changed, 693 insertions(+) create mode 100644 dnt_testbed/README.md create mode 100755 dnt_testbed/aggregate_results.py create mode 100644 dnt_testbed/env_frer_aoi.sh create mode 100644 dnt_testbed/gen_expo_dist.py create mode 100644 dnt_testbed/listener.py create mode 100644 dnt_testbed/nxp1.ini create mode 100644 dnt_testbed/nxp2.ini create mode 100755 dnt_testbed/run_point.sh create mode 100755 dnt_testbed/sweep.sh create mode 100644 dnt_testbed/talker.py create mode 100755 dnt_testbed/verify_setup.sh diff --git a/.gitignore b/.gitignore index 1d13fe2..344526e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ compile_commands.json #auto generated doc/protocols.md +dnt_testbed/result_*.txt +dnt_testbed/sweep_results.txt +dnt_testbed/emulation_points.csv +dnt_testbed/dnt_nxp*.log +dnt_testbed/raw/ +# results_archive/ is intentionally NOT ignored: it preserves finished +# measurement campaigns (commit it to keep them) diff --git a/dnt_testbed/README.md b/dnt_testbed/README.md new file mode 100644 index 0000000..367b34b --- /dev/null +++ b/dnt_testbed/README.md @@ -0,0 +1,308 @@ +# FRER-AoI Testbed on DNT — Documentation + +Emulation testbed for the Letter's Fig. 1 scenario: a periodic source whose +stream is protected by IEEE 802.1CB FRER (replication over two lossy, +delay-disparate paths, then duplicate elimination) followed by a Packet +Ordering Function (POF) with hold time *D*, measuring **in-order +completeness** and **Age of Information (AoI)** at the listener. + +Built on [DNT — Dependable Networking Toolkit](https://github.com/EricssonResearch/dnt) +(Ericsson Research), running four instances of Linux network namespaces on a +single host, with `tc netem` providing per-path delay distributions and loss. + +**Unit mapping:** 1 model time unit = 1 ms, source period T = 1 unit +(λ = 1 kHz). All D/H below follow the Letter's Setup: D_h = D_t = 1 ms, +branch A (Dprop = 1 ms, β_A = 2 ms⁻¹, p_A = 0.97), branch B (Dprop = 3 ms, +β_B = 0.4 ms⁻¹, p_B = 0.99), mean skew E[S] = 4 ms, H_max = 16. + +--- + +## 1. File inventory + +| File | Purpose | +|---|---| +| `env_frer_aoi.sh` | One-time (per boot) environment builder: creates the 4 network namespaces, the veth links between them, and installs the `tc netem` impairments. Also generates the exponential delay distribution table for netem. Provides `setup` / `teardown` shell functions. **Must be sourced in bash, as root.** | +| `gen_expo_dist.py` | Generates `expo.dist`, the netem distribution table realizing an exponential residual delay W ~ Exp(1/mean). Called by `setup`. | +| `nxp1.ini` | DNT config for the **SGF node** (sequence generation + replication). | +| `nxp2.ini` | DNT config for the **recovery node**: SRF (duplicate elimination, history H) followed by POF (ordering, hold time D). `run_point.sh` rewrites `MaxDelay` and `frerSeqRcvyHistoryLength` in this file for each operating point. | +| `talker.py` | Periodic source: N frames at T = 1 ms (SCHED_FIFO via `chrt` when available, to keep source jitter ≪ T), 802.1Q VLAN 10, experimental ethertype 0x88B5, payload = 64-bit sequence number + 64-bit float generation timestamp (`CLOCK_REALTIME`). | +| `listener.py` | Sink + measurement: raw-socket capture of delivered frames, extracts (seq, gen_ts, rx_ts), enforces monotone sequence (out-of-order arrivals counted in `outOfOrder` and discarded, consistent with the model's "late discard"), computes completeness, time-average AoI, and mean peak AoI. Optional 2nd argument: dump the raw per-frame `(seq, gen, rx)` arrays to a compressed `.npz` for offline distributional metrics (PAoI CCDF). | +| `run_point.sh` | Runs **one operating point** `(D, H, N)`: rewrites `nxp2.ini`, (re)starts both DNT instances, launches listener then talker, prints the result line and stores it in `result_D.txt`. Also saves the raw capture to `raw/D_H_.npz` (timestamped ⇒ repetitions never overwrite each other; override dir with `RAWDIR=`). | +| `verify_setup.sh` | Post-run sanity checker: dumps qdisc configs + drop counters and per-interface packet counters so replication / elimination / loss rates can be checked against theory (see §5). | +| `sweep.sh` | Runs the full D-sweep: for each D in the grid, H* = min(D, 16), R repetitions of `run_point.sh`, appending tagged result lines to `sweep_results.txt`. | +| `aggregate_results.py` | Parses `sweep_results.txt` → `emulation_points.csv` with per-(D,H) means and 95% Student-t confidence intervals, ready to overlay on the figure (see §7). | + +### Who calls what + +``` +env_frer_aoi.sh::setup (run once, as root, in bash) + ├── gen_expo_dist.py → writes /expo.dist + ├── ip netns add / ip link add / ethtool (topology) + └── tc qdisc replace ... netem ... (impairments) + +run_point.sh (run per operating point, as root) + ├── sed -i nxp2.ini (MaxDelay=D, frerSeqRcvyHistoryLength=H) + ├── ip netns exec nxp1 ../dnt nxp1.ini & (SGF) + ├── ip netns exec nxp2 ../dnt nxp2.ini & (SRF + POF) + ├── ip netns exec listener python3 listener.py N > result_D.txt & + └── ip netns exec talker python3 talker.py N +``` + +The `dnt` binary is resolved as: `$DNT` env var → `dnt` on PATH → `../dnt` +(the build in the repo root). `sudo` strips the user PATH, hence the explicit +fallback. + +--- + +## 2. Topology and network configuration + +``` + ┌────────────┐ branch A ┌───────────────┐ + ┌────────┐ head │ nxp1 ├─────────────┤ nxp2 │ tail ┌──────────┐ + │ talker ├──────┤ (SGF) │ │ (SRF + POF) ├──────┤ listener │ + └────────┘ │ Gen + Repl ├─────────────┤ Elim -> Ord │ └──────────┘ + eth0 uni└────────────┘ branch B └───────────────┘uni eth0 + brA/brB brA/brB +``` + +Four namespaces, four veth pairs: + +| Link | veth endpoints | Impairment (egress qdisc) | Model | +|---|---|---|---| +| head | talker:eth0 ↔ nxp1:uni | netem `delay 1ms` on talker:eth0 | D_h = 1 ms, lossless | +| branch A | nxp1:brA ↔ nxp2:brA | netem `delay 1.5ms 1ms distribution expo loss random 3%` on nxp1:brA | L_A = 1 ms + Exp(2 ms⁻¹), p_A = 0.97 | +| branch B | nxp1:brB ↔ nxp2:brB | netem `delay 5.5ms 5ms distribution expo loss random 1%` on nxp1:brB | L_B = 3 ms + Exp(0.4 ms⁻¹), p_B = 0.99 | +| tail | nxp2:uni ↔ listener:eth0 | netem `delay 1ms` on nxp2:uni | D_t = 1 ms, lossless | + +netem's `delay MU SIGMA distribution expo` draws `MU + SIGMA·t/8192` with `t` +an int16 from `expo.dist`. The table stores `t = 8192·(Exp(1) − 1)/2`, so with +`SIGMA = 2·mean` and `MU = Dprop + mean` this realizes exactly +`L = Dprop + W, W ~ Exp(1/mean)` (branch A: Dprop = 1 ms, mean = 1/β_A = +0.5 ms; branch B: Dprop = 3 ms, mean = 1/β_B = 2.5 ms). The int16 clip +truncates the Exp tail at ≈ 9·mean (P ≈ 1.2e-4). + +VLAN plumbing (how DNT classifies the streams): + +- talker sends VID **10** → nxp1 matches VID 10, adds R-TAG (seq), replicates: + copy A re-tagged VID **100** → brA, copy B re-tagged VID **200** → brB. +- nxp2 matches VID 100/200, reads the R-TAG seq, eliminates duplicates + (SeqRcvy, Vector algorithm, history **H**), orders (Pof, `MaxDelay` **D** ms, + `BufferSize` 64, `TakeAnyTime` 2000 ms), strips the R-TAG, re-tags VID 10, + sends to the listener. +- `ethtool -K ... rxvlan off txvlan off` disables VLAN offload so tags stay + in-packet; the listener still handles both tagged and kernel-stripped frames. + +--- + +## 3. Bugs found and fixed along the way + +### 3.1 Testbed-side (scripts) + +| Symptom | Root cause | Fix | +|---|---|---| +| `exec of "dnt" failed: No such file or directory` | `sudo` resets PATH to `secure_path`, which does not contain the project dir | `run_point.sh` resolves the binary explicitly (`$DNT` → PATH → `../dnt`) | +| `dnt: invalid option -- 'c'` | This DNT build takes the config file as a **positional** argument, not `-c` | `dnt nxp1.ini` instead of `dnt -c nxp1.ini` | +| `IndexError` in listener when nothing is received | Empty capture array | Listener exits gracefully with `delivered=0 ... (too few frames received ...)` | +| Branch qdiscs silently absent (completeness ≈ 1.0, avgAoI ≈ 2.5u — below the physical floor of ~4u) | Two independent problems: (a) `expo.dist` was written to `/usr/lib/tc/` but Ubuntu's `tc` searches `/usr/lib/x86_64-linux-gnu/tc/`; (b) the table had 65536 entries while the kernel caps netem tables at 16384 (`MAX_DIST` in `sch_netem.c`) | `setup` auto-detects the tc lib dir (via the stock `normal.dist`); `gen_expo_dist.py` now emits **4096** exact Exp(1) quantiles (inverse CDF at midpoints), same size as stock tables | + +**Diagnostic that exposed it:** with only head+tail netem active, avgAoI +measured exactly D_h + D_t + T/2 and completeness ≈ 1 — both inconsistent +with any run where the branch impairments are on. (The debugging above was +done under the original 10 ms bring-up configuration; the reasoning carries +over to the current 1 ms parameters unchanged.) + +### 3.2 DNT source (`pof.c`) — 4 bugs, branch `fix-pof-deadline` + +**Symptom:** continuous bursts of `[POF] [WARNING] buffer is full, dropping +new packet.` under sustained traffic; POF effectively dead. + +**Mechanism:** when a sequence gap blocks in-order release, packets are held +in the conditional delay buffer until their `MaxDelay` deadline. Two bugs +prevented that deadline from ever firing while traffic kept flowing, so the +64-slot buffer filled and everything after that was dropped. + +Commit 1 — `pof: fix get_next_deadline`: + +1. **`get_next_deadline()` compared deadlines with `!=` instead of `<`**, so + it returned the deadline of the *newest* packet instead of the earliest. + Every new arrival pushed the timer further out → with inter-arrivals + (≈10 ms) shorter than `MaxDelay` (43 ms) the timeout never fired. +2. `pof_try_forward()` dereferenced `next_to_forward` in a log statement + *before* its NULL check. + +Commit 2 — `pof: fire expired deadlines immediately, harden event handling`: + +3. **Expired deadlines armed a 2 s timer.** The re-arm logic treated + "deadline in the past" the same as "no deadline" and armed + `take_any_time` (2000 ms) instead of firing immediately. Once any held + packet's deadline passed while the queue was blocked, every new arrival + re-armed 2 s again — deadlock persists under load. Fix: past deadline ⇒ + zero timeout (fire now). +4. **eventfd sums, code assumed OR-semantics.** Concurrent event + notifications accumulate in the eventfd counter (1+1 = 2), so bit-testing + the read value misclassifies two IN_ORDER events as OUT_OF_ORDER and skips + the forward attempt. Fix: attempt a forward on any positive event + (harmless when the head is not next-in-order). Plus an empty-queue guard + in `pof_try_forward()`. + +**Known remaining deviations (documented, not fixed):** + +- On timeout DNT forwards the *oldest-arrival* packet, not the *lowest-seq* + one (the code's own comment flags this as RFC-9550-conformant but wrong). + Slightly inflates out-of-order deliveries; the listener's monotone-seq + guard discards those, consistent with the model. +- POF tracks sequence numbers as 16-bit with no wraparound handling + (`ntohl(seq) & 0xffff`) ⇒ **keep N < 65536 per run** (each run restarts + DNT, so the counter starts fresh every time). + +Git layout: `fix-pof-deadline` (the two fix commits only → PR to +EricssonResearch/dnt) and `frer-aoi-testbed` (= fixes + this testbed), +both on the fork `howhangliu/dnt`. + +--- + +## 4. Running a measurement + +```bash +# once per boot (bash, root): +sudo bash -c 'cd && source env_frer_aoi.sh && setup' +# (rerun impairments after any teardown; watch for the netem WARNING — it must NOT appear) + +# one operating point (D in ms = model units, H in packets, N frames): +sudo ./run_point.sh 4 4 60000 # ~60 s at 1000 pkt/s +``` + +Output (also saved to `result_D.txt`): + +``` +delivered=... completeness=... avgAoI=...u peakAoI=...u outOfOrder=... +``` + +- **completeness** = delivered-in-order / N +- **avgAoI** = time-average age (trapezoid integration of the sawtooth), in + model units (1u = 1 ms) +- **peakAoI** = mean of the sawtooth peaks `u_{k+1} − g_k` +- **outOfOrder** = frames that arrived non-monotonically and were discarded + +## 5. Verification checklist + +Run `sudo ./verify_setup.sh` after a run (counters accumulate across runs — +for exact bookkeeping do `teardown` + `setup` + one run first): + +1. **Impairments live:** brA/brB show the netem configs; `dropped ≈ 3% / 1%` + of the packets offered to each branch. +2. **Replication:** nxp1 brA TX+drops ≈ N *and* brB TX+drops ≈ N (two full + copies of the stream). +3. **Elimination:** nxp2 uni TX ≈ delivered ≪ 2N (duplicates collapsed; only + both-copies-lost and out-of-history/late packets missing). +4. **Ordering:** listener prints `outOfOrder≈0`. Falsification test: run + `sudo ./run_point.sh 0 1 5000` (D=0 disables holding) → outOfOrder ≫ 0. +5. **Plausibility bounds:** completeness ≤ 1 − (1−p_A)(1−p_B) = 0.9997 (the + gap below it is D/H-window loss — the phenomenon under study; the + branch-recovery event "A lost, B delivered" has probability + (1−p_A)·p_B ≈ 3×10⁻², i.e. ~1800 events per 60000-frame run); + avgAoI ≥ D_h + Dprop_A + D_t + T/2 = 3.5u. + +Benign log noise: `SYSMON ... pmc exited, status 253` (no PTP daemon — all +namespaces share the host clock, sync is irrelevant here); listener-side +`RX dropped` in `ip -s link` (ethertype 0x88B5 has no kernel handler; the +AF_PACKET tap still receives every frame). + +## 6. Producing the full plot + +**One `run_point.sh` invocation = one point** on the plot: it measures +(completeness, avgAoI, peakAoI) for a single (D, H) pair. With T = 1 ms the +conversion is direct: + +- `MaxDelay = D` in ms = D in model units (integer ms only — DNT parses the + value as an integer, so the emulation grid has 1 ms resolution even where + the analytical D_ρ is fractional; place emulation markers at the integer + D actually run) +- elimination history is **fixed at H_max = 16 across the sweep**: the + model's Pareto front assumes ideal dedup, and the POF hold D does all the + dropping (late frames are forwarded stale and discarded at the listener — + the model's late-discard). Matching H to D adds non-model losses: DNT's + Vector recovery *rogue-drops* out-of-window packets, and H ≤ 2 deadlocks + into 2 s resets after a single branch-A loss (measured: D=1/H=1 gives + completeness 0.0035). The coupling H\* = ceil(D_ρ/T) is validated + separately at the star point: `run_point.sh 6 7` (expect comp ≥ ρ) vs the + H_max=4 infeasibility demo `run_point.sh 6 4` (expect comp < ρ). + +Run the whole sweep with: + +```bash +sudo ./sweep.sh # defaults: D in {0..6,8,10,12,16}, H=16, R=5, N=60000 +D_LIST="2 4 6 8" R=10 N=60000 sudo -E ./sweep.sh # custom grid / repetitions +``` + +**Budget:** at 1 kHz a 60000-frame run takes ~60 s (+ ~2 s restart overhead) +⇒ default grid 10 points × 5 reps ≈ **55 min** total. Runs are strictly +sequential — the namespaces are shared state; never parallelize on one host. + +**Statistics:** per rep, the completeness standard error is +√(p(1−p)/N) ≈ 4×10⁻⁴ at p ≈ 0.99; R = 5 reps brings the point estimate to +≈ 2×10⁻⁴ — sufficient for the 10⁻³-scale features of ρ. Each rep contains +~1800 branch-recovery events ((1−p_A)p_B ≈ 3×10⁻²) and ~18 both-copies-lost +events. The simulator's 4×10⁶-update resolution is not reachable in a single +run (N < 65536, §3.2); if a point needs it, raise R (R = 67 ≈ 4×10⁶ samples, +~70 min for that one point). + +## 7. Combining emulation results into the figure + +1. Aggregate the sweep into per-point means ± 95% CI: + + ```bash + python3 aggregate_results.py sweep_results.txt -o emulation_points.csv + ``` + +2. Overlay on the existing analytical/simulation figure as discrete markers + with error bars (do not draw lines through emulation points — they are + measurements, not a model): + + ```python + import pandas as pd + em = pd.read_csv("emulation_points.csv") + ax.errorbar(em.D, em.comp_mean, yerr=em.comp_ci, fmt="o", mfc="none", + ms=5, capsize=2, color="k", label="DNT emulation", zorder=5) + ax2.errorbar(em.D, em.avgAoI_mean, yerr=em.avgAoI_ci, fmt="o", mfc="none", + ms=5, capsize=2, color="k", zorder=5) + ``` + + AoI columns are already in model units (1u = 1 ms), so no rescaling is + needed against the analytical curves. + +3. If the figure's x-axis is ρ rather than D, map each emulated integer D + through the Letter's D_ρ relation (eq. Drho) to place the marker at its + effective ρ; alternatively add a top axis in D. + +3b. **PAoI CCDF (fig_paoi):** the Letter's CCDF figure compares the + simulator's peak-AoI distribution (curves from `paoi_sweep.py` in the + repo root → `paoi_ccdf.npz`) against pooled empirical CCDFs from the raw + captures. Required emulation runs: repetitions at **D ∈ {0, 6, 16}, H = 16** + (the three curve operating points — DROP, ≈D_ρ design point, HOLD): + + ```bash + D_LIST="0 6 16" R=5 N=60000 sudo -E ./sweep.sh # ~16 min, 5×60000 peaks per D + ``` + + The companion analysis script (`figs.py`, kept outside this repo) + pools every `raw/D_H16_*.npz` per D into the overlay markers; + more reps ⇒ deeper reachable tail + (pooled n samples resolve the CCDF down to ~1/n). + +**Result-file lifecycle:** `sweep_results.txt` is append-only (dated header +per sweep); `result_D.txt` holds only the *last* run at that D and is +overwritten; `raw/*.npz` files are timestamped and never overwritten. +Finished campaigns are preserved by copying `sweep_results.txt`, +`result_D*.txt`, `emulation_points.csv` (and optionally `raw/`) into +`results_archive/_