Add cascaded-noise-figure post, animate TCP scenes, expand ideas

New post cascaded-noise-figure (scheduled 2026-04-29) with four
interactive scenes on noise floor, single-stage NF, the Friis
formula as a block builder, and LNA placement.

Upgrade the three static TCP scenes (data, sliding window, UDP)
to animated packet flow using a shared drawPartialPacket primitive.

Expand IDEAS.md with roughly thirty more post concepts across
electronics, networking, ham radio, microwave, and longer arcs.

Add outbound links to relevant first-mentions across posts:
Fresnel zone, effective Earth radius, atmospheric duct, HRRR,
BJT, common-emitter, TCP/UDP RFCs, slow start, QUIC, Friis,
noise figure, Boltzmann constant. Scrub Unicode arrows and
multiplication symbols out of prose and scene labels.
This commit is contained in:
Graham McIntire 2026-04-22 13:28:28 -05:00
parent 031d04ed3f
commit 4e42bcf97f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
9 changed files with 998 additions and 117 deletions

View file

@ -10,7 +10,15 @@ Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scene
- **Feedback loop stability.** Drag the poles and zeros around on the s-plane, watch the step response and the bode plot change. The moment a pole crosses into the right half plane and everything goes sideways.
- **A superhet receiver, piece by piece.** Antenna, RF amp, mixer, LO, IF filter, detector. Tunable signals scrolling through the spectrum, user drags the LO, watches the desired signal slide into the IF passband. Image rejection as a visible thing.
- **A switching power supply.** Buck converter with a slider for duty cycle. Inductor current ramping and freewheeling, output ripple. Makes the "magic efficiency" of SMPS feel earned.
- **Noise figure, cascaded.** Build a receiver chain by dragging in blocks (LNA, mixer, IF amp), each with a noise figure. Live link budget at the bottom. The Friis formula stops feeling abstract.
- ~~**Noise figure, cascaded.** Build a receiver chain by dragging in blocks (LNA, mixer, IF amp), each with a noise figure. Live link budget at the bottom. The Friis formula stops feeling abstract.~~ *Written 2026-04-29 (scheduled): [cascaded-noise-figure](/blog/cascaded-noise-figure/).*
- **Op-amp, opened up.** Differential pair on the left, gain stage in the middle, push-pull output on the right. Slider for input differential voltage, watch the tail current redistribute and the output swing. Then close the loop with a feedback resistor and watch virtual ground appear out of nothing.
- **Schmitt trigger hysteresis.** Noisy input riding on a slow ramp. Slider for the hysteresis band. Without it the output chatters wildly at the threshold; widen the band and the chatter stops. Show the two trip points moving on the transfer curve.
- **A SAR ADC, bit by bit.** Sample-and-hold capacitor charges, then the comparator and DAC binary-search the input. Step through each bit, watch the trial voltage close in on the sample. User can change resolution and see conversion time scale.
- **PLL locking.** Reference oscillator on one side, VCO on the other, phase detector and loop filter in between. Slider for loop bandwidth. Watch the VCO drag itself into lock, then perturb the reference and see the loop track. Crank the bandwidth too high and watch it ring.
- **Linear regulator vs LDO.** Pass transistor, error amp, reference. Slider for input voltage and load current. Drop the input toward the output and watch a standard regulator fall out of regulation while the LDO holds on. Power dissipation rendered as a thermometer on the pass element.
- **Crystal oscillator startup.** Pierce topology with the crystal modeled as its motional RLC plus shunt capacitance. Start from thermal noise. Slider for negative resistance from the active stage. Too little and it never starts; just enough and you watch a clean sine build over thousands of cycles.
- **Class-D from triangle and comparator.** Audio in, triangle carrier, comparator output as a PWM stream, LC filter recovering audio. Slider for switching frequency and modulation depth. The moment the recovered waveform looks identical to the input despite the output stage being only ever fully on or fully off is the payoff.
- **Charge pump, bucket by bucket.** Two capacitors and four switches. Step through the phases by hand at first, then let it run. Output voltage climbs to roughly twice the input. Add load current and watch the output sag, then crank the switching frequency to recover it.
## Computers / networking
@ -20,6 +28,15 @@ Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scene
- **Cache coherence (MESI).** Two cores, a shared line of memory, user issues reads and writes, arrows show the invalidations flying around. The reason your "obviously correct" lock-free code isn't is usually on this graph somewhere.
- **BGP finds a route.** A handful of ASes with peering relationships, user picks source and destination, watches announcements propagate and the path emerge. Bonus scene: a single misconfigured announcement leaking and causing a global outage.
- **Virtual memory and page tables.** Process issues a virtual address, the MMU walks the levels, TLB hits and misses, page fault handling. Good payoff if tied to a real program's memory map.
- **Diffie-Hellman in colored paint.** Two parties, a public modulus, private exponents kept hidden. Watch the shared secret emerge while an eavesdropper sees only the public exchange. Slider for prime size to show why small primes are trivially broken and large ones are not.
- **Huffman coding live.** Type a string, watch the frequency table build, then the tree assemble bottom-up, then the bit codes fall out. A second pane shows the encoded length shrinking as the message gets more redundant.
- **NAT and a stuck connection.** Two clients behind NATs trying to reach each other. Show the translation tables, the dropped SYNs, then a STUN server learning the mapped ports, then a hole punch succeeding. The reason video calls feel like dark magic becomes a diagram.
- **ARP, the unsung protocol.** A host wants to send a packet, has an IP, needs a MAC. Broadcast goes out, reply comes back, cache populates with a TTL. Second scene: a gratuitous ARP from a duplicate IP and the resulting chaos.
- **Raft electing a leader.** Five nodes on a ring, term counters, randomized election timeouts. User can pause a node, partition the network, watch a new leader emerge and then watch the old one rejoin and step down. The split-brain scenario as a movable thing.
- **Token bucket rate limiting.** Bucket fills at a steady rate, requests drain it. Slider for fill rate and burst size. Send a burst of requests and watch which get through and which get 429'd. Compare side-by-side with a leaky bucket.
- **Garbage collection, three flavors.** Same heap, same allocation pattern, three collectors running in parallel panes: mark-sweep, copying, generational. Watch pause times and fragmentation diverge. Slider for allocation rate to find where each collector falls over.
- **Branch prediction history.** A loop with a data-dependent branch. Toggle between always-taken, two-bit saturating counter, and a small perceptron predictor. Mispredict rate and pipeline bubbles rendered as wasted clock ticks.
- **A load balancer at work.** Incoming requests, a pool of backends with varying response times. Switch between round robin, least connections, and consistent hashing. Kill a backend and watch the failure modes differ.
## Ham radio
@ -29,6 +46,14 @@ Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scene
- **Great circle and grid square math.** Enter two grid squares, get the path, bearing, distance. Drag either endpoint on a globe. Small but useful.
- **HF skip vs MUF.** Ionospheric layers with time-of-day and solar-cycle sliders. Rays launching at various angles, reflecting (or punching through) D/E/F layers, landing somewhere else on the globe. Great contrast piece to the NLOS post.
- **Feedline loss as a function of length, frequency, and type.** Slider for length, buttons for RG-58/RG-8/LMR-400/heliax. A visible chunk of transmit power disappearing into the coax as frequency climbs.
- **CW keying waveforms and key clicks.** A keyed carrier with adjustable rise and fall times. Time domain on the left, spectrum on the right. Hard keying produces audible-looking sidebands sprawling across the band; smooth shaping pulls them in. Slider for WPM to show the trade against keying speed.
- **QSK, in slow motion.** Transmit relay, PA, T/R switching, receiver muting, all on a millisecond timeline. User adjusts the keying speed and watches the sequencer's headroom shrink. The point at which the receiver hears its own backwave becomes obvious.
- **APRS packet over the air.** A position report becoming an AX.25 frame, then AFSK tones, then audio out. Inject noise on the channel and watch a digipeater either decode and forward or drop the frame. Second scene shows the path field accumulating hops.
- **Yagi pattern, element by element.** Start with a driven dipole. Add a reflector behind it, watch the back lobe collapse. Add directors out front one at a time, watch the main lobe sharpen and the side lobes appear. Slider for boom length sweeps gain against beamwidth.
- **Antenna tuner matching anything.** L-network with two variable components. User picks an arbitrary load on a Smith chart, watches the tuner walk the impedance toward 50 ohms. Show the loss in the tuner climbing as the mismatch gets ugly.
- **Baluns, currents, and common mode.** A coax-fed dipole with no balun, current on the shield rendered as a moving arrow. Add a choke balun, watch the common-mode current die. Receiver noise floor in a side panel drops as the feedline stops radiating.
- **Lightning and station grounding.** A strike hits the tower. Trace the current down through the ground rods, across the bond to the shack ground, and (without a single-point ground) up through the radio and out the AC mains. Toggle a proper ground window and watch the current bypass the gear.
- **QRP propagation reality check.** Slider for power from 100 W down to 100 mW. Path loss, noise floor, and required SNR for SSB, CW, and FT8 plotted as bars. The point where 5 W on FT8 still closes the link but 100 W on SSB does not is the punchline.
## Microwave / advanced RF
@ -39,6 +64,14 @@ Working scratch list. Same format as the NLOS post: vanilla JS, 2D canvas, scene
- **Smith chart, but friendly.** Drag a load impedance, watch reflection coefficient, SWR, and return loss update. Add a matching network with an L-network or stub and watch it slide to the center.
- **Parabolic dish aperture.** Slider for diameter and frequency, see gain vs beamwidth vs sidelobes. Illumination taper as a second control, showing the efficiency / sidelobe trade.
- **ITU-R P.838 rain attenuation.** Pick a frequency, a rain rate, and a path length. Curve of attenuation over the full microwave band. Useful companion to anything on 10 GHz and up.
- **S-parameters, demystified.** A two-port network with adjustable internal components. Drag a frequency cursor across the band, watch S11, S21, S12, S22 trace out on a Smith chart and a magnitude plot in lockstep. Toggle between an amplifier, a filter, and an attenuator and see what each one's signature looks like.
- **Stub matching on a transmission line.** A mismatched load at the end of a line. User slides a shorted shunt stub along the line and adjusts its length. SWR upstream of the stub plotted live. Watch the match snap into place at the right position.
- **Prime focus vs Cassegrain.** Same dish diameter, two feed geometries side by side. Ray trace the illumination, show the spillover and the blockage from each. Slider for f/D and subreflector size to see how the trades shift.
- **Polarization tracking a LEO bird.** A satellite with linear polarization passing overhead. Show the apparent polarization angle rotating from horizon to horizon as geometry changes. User dials a manual rotator to keep up, or enables auto-tracking. Signal strength dips on every degree of mismatch.
- **LNA bias network.** A GaAs FET with a gate bias tee, drain choke, and decoupling. Slider for drain voltage and gate voltage. Live IV curve, noise figure circles, and gain compression all updating. A second scene adds a missing bypass cap and shows the LNA breaking into oscillation.
- **Waveguide modes.** A rectangular guide cross-section. User picks a frequency. Below cutoff, nothing propagates; cross the TE10 cutoff and the field pattern lights up. Push higher and TE20, TE01, and TM11 appear with their distinct patterns. Group velocity slowing near cutoff is visible.
- **Sun noise as an antenna check.** Point a dish at cold sky, then drag it onto the sun. Y-factor in dB updates live. Slider for dish size and frequency derives system noise temperature from the result. Calibration without a noise source.
- **Noise temperature and noise figure, two views of one thing.** A receiver chain with a slider that toggles the unit between K and dB. Friis recomputes in both. Toss in a lossy feedline ahead of the LNA and watch the system noise temperature jump in a way the noise figure number understates.
## Longer arcs
@ -47,6 +80,10 @@ A couple of ideas that would be a whole series, not one post.
- **"Build a receiver from first principles."** Start at an antenna picking up an EM wave, end at a speaker playing audio. Every stage a scene. Takes the shape of a short book.
- **"What the atmosphere is doing right now."** Live data from prop.w5isp.com, rendered as an interactive cross-section of North Texas atmosphere at the current hour. Refractivity aloft, ducts forming and collapsing, paths scored against real conditions. Operational rather than explanatory.
- **"Every component in my station, demystified."** One post per piece of gear. Transverter, PA, sequencer, rotator, preamp. Each one a scene that makes the internal thing you pay for make sense.
- **"How GPS knows where you are."** A series. One post on pseudoranges and trilateration with four satellites, one on the navigation message and clock corrections, one on ionospheric and tropospheric delay, one on RTK and carrier phase. Each scene builds the previous post's output into its inputs.
- **"A radar, from pulse to picture."** Start with a single pulse and a target return. Add pulse compression with a chirp and matched filter. Add Doppler processing across a pulse train. Add MTI to suppress clutter. End at a synthetic aperture image of a patch of ground. Each step a scene, each scene reusing the prior one's signal.
- **"Light down a fiber."** A series on optical comms. Modes in a step-index fiber, then graded index, then single mode. Chromatic and polarization-mode dispersion as time-domain pulse smearing. EDFA gain. Coherent detection and DSP equalization. The series ends with a 400G link budget that actually closes.
- **"Control loops that don't ring."** PID tuning as a sandbox. A plant (motor, thermal mass, antenna rotator) with adjustable P, I, D. Step responses, disturbance rejection, integrator windup. Second post on cascaded loops. Third on feedforward. By the end the reader has tuned a real-feeling system without wanting to throw the rotator off the tower.
## House style

View file

@ -0,0 +1,91 @@
+++
title = "Noise figure, cascaded: why your LNA goes where it goes"
date = 2026-04-29
draft = false
+++
<link rel="stylesheet" href="/css/scene.css">
<div class="scene-host" data-scene-host>
Every radio receiver has the same job. A weak signal arrives at the antenna, some amount of noise arrives with it, and the receiver tries to hand the demodulator something with a decent signal-to-noise ratio. The receiver itself is an obstacle to this project because every component in it adds noise of its own. How much depends on the components and on the order they're in.
If you've ever heard someone say "the [noise figure](https://en.wikipedia.org/wiki/Noise_figure) of the first stage is everything" or "the LNA has to go at the antenna," they were talking about the [Friis formula](https://en.wikipedia.org/wiki/Friis_formulas_for_noise). It's a small piece of engineering math that, once it clicks, makes most of the weird choices in receiver design look obvious.
Below, the same territory in four scenes. Start with where noise comes from, then where noise figure as a number comes from, then what happens when you chain stages together, and finally why the LNA ends up at the antenna and not near the radio.
## Where the noise floor comes from
Resistors hiss. Electrons in anything above absolute zero rattle around, and some of that rattling shows up as voltage across any resistor in the circuit. In a receiver, the resistor is effectively the antenna (or the load it connects to), and the available noise power works out to a specific, unavoidable number:
> P_noise = k * T * B
where *k* is [Boltzmann's constant](https://en.wikipedia.org/wiki/Boltzmann_constant), *T* is the temperature in kelvin, and *B* is the bandwidth your receiver looks at. At 290 K (roughly room temperature), that works out to approximately 174 dBm per hertz of bandwidth. Wider filter, wider noise. Hotter resistor, hotter noise.
<div id="scene-noise-floor"></div>
Move the sliders. Widen the bandwidth from 1 kHz to 1 MHz and the noise floor rises by 30 dB. Heat the front end from 290 K to a hypothetical 2900 K and the floor rises by another 10. A signal at, say, 110 dBm sits comfortably above a narrow-bandwidth floor and disappears into the noise at wider ones, without the signal itself changing at all.
This is the part of the receiver you can never quite beat. You can shape the filter, pick a lower temperature, or move the measurement further down the chain, but whatever noise the antenna delivers is the floor you have to work with.
## A single stage: what noise figure actually measures
Put a signal and some noise into an amplifier. The amplifier has some gain, which multiplies both by the same factor, leaving the signal-to-noise ratio unchanged. So if gain were the only thing an amplifier did, noise figures wouldn't need to exist.
Real amplifiers, however, add their own noise at the output. You can model this as an extra noise source sitting at the input of an ideal amplifier, and the noise figure is just a compact way to say how much extra. Formally:
> NF (dB) = 10 * log₁₀ ( SNR_in / SNR_out )
which in practice means "this stage made my SNR worse by this many decibels." A 0 dB noise figure is a perfect amplifier that adds no noise. A 3 dB NF is an amplifier that doubles the noise power at the output relative to what was at the input. Real low-noise amplifiers at HF and low VHF get down to a few tenths of a dB. At microwave they might be 0.5 to 1.5 dB if you paid well.
<div id="scene-single-stage"></div>
Cranking the gain slider up doesn't change the SNR degradation, which is the point: gain is free, noise figure isn't. Cranking the NF slider is the one that actually matters for the output SNR, and the damage is identical regardless of what the input signal level was.
## Friis: why chain order matters
Now chain two amplifiers together. The first one degrades the SNR by its NF. The second one degrees the SNR by its own NF, but applied to the signal and noise that the first stage has already amplified. If the first stage has high gain, the noise it produced is much larger than the second stage's own noise, so the second stage's NF barely matters.
That's the whole idea behind the Friis formula:
> F_total = F₁ + (F₂ 1) / G₁ + (F₃ 1) / (G₁ * G₂) + ...
where F is the linear noise factor (F = 10^(NF/10)) and G is the linear gain. Everything is in linear units because you can't add decibels, only multiply them.
The crucial thing is the G₁ in the denominator of every term after the first. If G₁ is large, those later terms shrink to nothing. If G₁ is small, or worse, negative (an attenuator has G < 1), those terms blow up.
<div id="scene-cascade"></div>
Try a few chains. Start with "LNA -> mixer -> IF amp" and watch the contribution bar. Almost all the output noise is being contributed by the LNA, which is a little paradoxical until you remember that the LNA is the first thing touching the signal, and everything downstream is looking at a signal that's already 18 or 20 dB stronger than what the antenna is providing.
Now move the LNA to the second position and put the mixer first. The mixer is a terrible first stage because it loses 7 dB of gain (it's essentially an attenuator) and has a noise figure of 7 dB on its own. With the mixer first, the LNA's nice 1.5 dB noise figure is divided by a gain of 0.2 (a conversion loss of 7 dB in linear terms), which makes its contribution much larger than it should be. The total NF climbs dramatically.
Receivers almost always end up in the same left-to-right order because Friis leaves very little room to disagree.
## The LNA has to go at the antenna
One of the most common mistakes in a ham station is running the LNA at the radio instead of up at the antenna. Intuitively it seems fine. An amplifier amplifies regardless of where it is, and putting it near the radio means the DC power and the control are easier. Friis has opinions.
<div id="scene-lna-placement"></div>
Both chains use the same LNA and the same feedline loss. Putting the LNA *before* the feedline makes the feedline loss nearly invisible because it's followed by the LNA's large gain, and the noise it adds is small compared to the noise already present. Putting the LNA *after* the feedline means the feedline loss is in front of the amplifier, which is equivalent to having the first stage of your receiver be a 4 dB attenuator whose own noise figure is also 4 dB (loss and noise figure of a passive component are always equal).
Even a good LNA cannot recover the signal that the feedline loss has already corrupted. Friis is not a law you can argue with, and the difference in total system noise figure between "at the antenna" and "at the radio" can easily be 3 to 6 dB depending on how long the run is. That's the difference between hearing a weak DX station and never knowing it was there.
For microwave paths, the effect is worse because coaxial feedline loss goes up steeply with frequency. At 10 GHz, a few meters of LMR-400 eats a huge fraction of the signal before it reaches the radio. This is why every remote 10 GHz beacon setup you'll ever see has the LNA mounted in a weatherproof enclosure right at the dish, with a low-loss waveguide or short coax feed to the LNA input, and a standard coax run after it carrying the amplified signal back to the shack. The LNA's job is to get in front of the feedline loss before it happens.
## Practical rules
Once you've spent a while with the Friis formula, a few rules of thumb fall out naturally:
- The first active stage should have the lowest noise figure you can afford, and it should live as close to the antenna as possible.
- Everything before that first stage is loss, which adds directly to the system noise figure. Keep it short, keep it low-loss, and resist the temptation to add "just one more connector."
- Once the signal has been amplified by ~20 dB, later noise figures barely matter. You can use cheap, high-NF parts after the LNA and suffer essentially nothing.
- An attenuator of *L* dB has a noise figure of exactly *L* dB. A 6 dB pad in front of your LNA costs you 6 dB of system noise figure. Do not put one there.
- The total noise figure of the cascade is an upper bound on how good any link budget involving that receiver can ever be. No amount of antenna gain will help if the receiver itself is destroying the SNR.
The Friis formula is one of those pieces of math that looks abstract until you start moving blocks around in the scenes above, at which point it stops feeling abstract and starts feeling like an old acquaintance who shows up every time you design a receiver and reminds you, politely, that no, the LNA really does have to go at the antenna.
</div>
<script src="/js/noise.js" defer></script>

View file

@ -12,7 +12,7 @@ The phrase *small-signal gain* is one of those engineering expressions that mana
The answer, when you work through it, is not that a transistor makes electrons stronger. Nothing in the device multiplies anything in a physical sense. It controls how hard a separate, already-energized current is allowed to flow, and then a nearby resistor turns that variable current back into a variable voltage, and the variable voltage is bigger than the one you started with. That is it. Everything else is details, and the details are where it gets fun.
We'll build this up one scene at a time using an NPN bipolar junction transistor, the sort of thing that still comes in a TO-92 three-legged black lump if you buy a bag from Mouser.
We'll build this up one scene at a time using an NPN [bipolar junction transistor](https://en.wikipedia.org/wiki/Bipolar_junction_transistor), the sort of thing that still comes in a TO-92 three-legged black lump if you buy a bag from Mouser.
## What is a BJT?
@ -48,7 +48,7 @@ The base current is actually controlled by the voltage across the base-emitter j
Drag V_in (which is V_BE here) and watch V_out. The three regions of the earlier scene show up as three regions of the curve: a flat portion at the top where nothing happens, a steep diagonal in the middle where a few millivolts of input produce volts of output, and a flat portion at the bottom where you're welded to the rail. The slope of the steep part is the voltage gain. It is not an especially well-behaved function of V_in, but it's enormous, and that's enough.
Notice that the amplifier is upside down. V_in going up makes V_out go down. This is the standard "common-emitter" amplifier, and its inversion is a consequence of the fact that more base drive means more collector current means more voltage dropped across R_C means less voltage left on the collector. The inversion does not bother anyone except people reading their first schematic.
Notice that the amplifier is upside down. V_in going up makes V_out go down. This is the standard ["common-emitter" amplifier](https://en.wikipedia.org/wiki/Common_emitter), and its inversion is a consequence of the fact that more base drive means more collector current means more voltage dropped across R_C means less voltage left on the collector. The inversion does not bother anyone except people reading their first schematic.
## Amplification, live
@ -70,7 +70,7 @@ where V_T is the thermal voltage, about 26 mV at room temperature. That is a num
The voltage gain of this common-emitter stage is then:
> A_v = -g_m · R_C
> A_v = -g_m * R_C
<div id="scene-gain"></div>

View file

@ -8,7 +8,7 @@ draft = false
<div class="scene-host" data-scene-host>
TCP and UDP are the two transport protocols that carry essentially all internet traffic that isn't doing something exotic. They live at the same layer, speak to the same lower-level IP plumbing, and have almost completely opposite personalities.
[TCP](https://datatracker.ietf.org/doc/html/rfc9293) and [UDP](https://datatracker.ietf.org/doc/html/rfc768) are the two transport protocols that carry essentially all internet traffic that isn't doing something exotic. They live at the same layer, speak to the same lower-level IP plumbing, and have almost completely opposite personalities.
TCP is a protocol that wants very badly to behave. It sets up connections, numbers every byte, acknowledges what it receives, retransmits what gets lost, slows down when the network complains, and eventually closes the connection neatly. If TCP were a person, it would arrive at meetings early and send a follow-up email.
@ -50,7 +50,7 @@ TCP has no way of knowing, in advance, how much bandwidth is actually available
<div id="scene-congestion"></div>
The window grows exponentially during *slow start* (which despite the name is not slow, it's just starting small) until it reaches the *slow-start threshold*. After that it creeps up linearly, adding one segment per round trip. Eventually a packet gets dropped, which TCP interprets as the network saying "enough." The window is cut in half, the threshold is moved to the new value, and the cycle starts over. TCP is a protocol that lives its entire life anxious about whether it should be going faster.
The window grows exponentially during [*slow start*](https://en.wikipedia.org/wiki/TCP_congestion_control) (which despite the name is not slow, it's just starting small) until it reaches the *slow-start threshold*. After that it creeps up linearly, adding one segment per round trip. Eventually a packet gets dropped, which TCP interprets as the network saying "enough." The window is cut in half, the threshold is moved to the new value, and the cycle starts over. TCP is a protocol that lives its entire life anxious about whether it should be going faster.
This is the source of the famous sawtooth pattern in any graph of TCP throughput over time. It is also why networks with high but variable delay (cellular, satellite, transoceanic) can be fairly miserable for TCP: every burst of delay looks like congestion, the window collapses, and throughput never really gets to stretch out.
@ -68,7 +68,7 @@ This is wonderful for the applications that benefit from not paying for TCP's se
**Reach for TCP when** you want bytes to arrive intact and in order, you can tolerate whatever latency that costs, and the application doesn't want to think about packets at all. HTTP, SMTP, SSH, file transfer, databases, most RPC frameworks. The default.
**Reach for UDP when** late data is worse than lost data, when you care about the cost of a handshake, when you want to multicast to many recipients, or when you have a better reliability scheme than TCP's on top. Real-time media, DNS, NTP, SNMP, most online games, QUIC (which builds its own TCP-replacement on top of UDP specifically because UDP gets out of the way).
**Reach for UDP when** late data is worse than lost data, when you care about the cost of a handshake, when you want to multicast to many recipients, or when you have a better reliability scheme than TCP's on top. Real-time media, DNS, NTP, SNMP, most online games, [QUIC](https://datatracker.ietf.org/doc/html/rfc9000) (which builds its own TCP-replacement on top of UDP specifically because UDP gets out of the way).
Most of the modern protocols people hear about lately (HTTP/3, QUIC, WebRTC) are on UDP. That isn't because TCP has stopped being useful. It's because these protocols want control over exactly the parts of the stack that TCP does not let you touch: connection setup, head-of-line blocking, multiplexing. UDP is the way you get a blank sheet of paper where TCP would otherwise fill in the details for you.

View file

@ -24,7 +24,7 @@ That model is how nearly every commercial point-to-point microwave link is desig
## Earth gets in the way too
Zoom out far enough and the ground curves. From an antenna of height *h* the geometric horizon sits at about √(2·R·h). Two 30-meter towers can each see a horizon roughly 20 km away. Push them 80 km apart and the Earth bulges more than 120 meters up between them.
Zoom out far enough and the ground curves. From an antenna of height *h* the geometric horizon sits at about √(2*R*h). Two 30-meter towers can each see a horizon roughly 20 km away. Push them 80 km apart and the Earth bulges more than 120 meters up between them.
<div id="scene-curve"></div>
@ -32,9 +32,9 @@ No mast is tall enough to beat that on its own.
## The signal is fatter than a line
Here's the first thing in the geometry-only model that helps NLOS along a little. Radio energy doesn't ride a pencil-thin ray. It fills an ellipsoid between the antennas called the first Fresnel zone, and you want about 60% of that zone clear for the path to behave like free space.
Here's the first thing in the geometry-only model that helps NLOS along a little. Radio energy doesn't ride a pencil-thin ray. It fills an ellipsoid between the antennas called the first [Fresnel zone](https://en.wikipedia.org/wiki/Fresnel_zone), and you want about 60% of that zone clear for the path to behave like free space.
The ellipsoid is fattest at the middle of the path. Its radius there is √(λ·d/4), so it grows with wavelength. A 40 km path on 50 MHz has a midspan radius around 24 meters. Same path on 10 GHz is 1.7 meters.
The ellipsoid is fattest at the middle of the path. Its radius there is √(λ*d/4), so it grows with wavelength. A 40 km path on 50 MHz has a midspan radius around 24 meters. Same path on 10 GHz is 1.7 meters.
<div id="scene-fresnel"></div>
@ -60,7 +60,7 @@ This is where path profile software starts lying to you, politely and with convi
Air, it turns out, cares quite a bit about radio waves. Microwaves travel a hair slower through it than through vacuum, and the slowness changes with pressure, temperature, and humidity. Radio engineers express it as N-units:
> N = (n 1) · 10⁶
> N = (n 1) * 10⁶
where *n* is the refractive index. At sea level N is usually around 315. It falls off with altitude, and the slope of that fall is what bends rays.
@ -70,7 +70,7 @@ The dashed line is the international standard atmosphere. Drag the slope and the
## Rays follow the gradient
A horizontal ray in a medium whose refractive index falls with altitude bends downward. The tighter the gradient, the tighter the curve. Under the standard atmosphere the bend is gentle, about a quarter of Earth's own curvature, and engineers handle this by quietly pretending the Earth is 4/3 its real size and the rays are straight. Nobody points out that this is a slightly strange thing to do. That's the familiar k = 4/3 factor.
A horizontal ray in a medium whose refractive index falls with altitude bends downward. The tighter the gradient, the tighter the curve. Under the standard atmosphere the bend is gentle, about a quarter of Earth's own curvature, and engineers handle this by quietly pretending the Earth is 4/3 its real size and the rays are straight. Nobody points out that this is a slightly strange thing to do. That's the familiar [k = 4/3 effective-Earth factor](https://en.wikipedia.org/wiki/Effective_Earth_radius).
The slider below is labeled **dN/dh**, which reads as "the change in N per unit of altitude." It's just the slope of the refractivity curve from the previous scene, in N-units per kilometer. If N drops from 315 at the surface to 275 at 1 km up, dN/dh is 40 N/km. That's the international standard atmosphere, and it's also the zero point for everything that follows. More negative numbers mean refractivity is falling *faster* with altitude, which is what bends rays harder:
@ -86,7 +86,7 @@ Drag dN/dh through those thresholds and watch the traced ray. Standard air, ray
Here's where a ham-friendly explanation helps. Propagation models talk about "rays" because the math is a lot easier when you treat a radio wave as a bundle of straight (or curving) lines, each representing the direction the wavefront is moving at that point. In practice, those lines are the actual paths your RF follows out of the antenna. When the atmosphere bends them, your signal goes where the lines go.
A *duct* is a layer of atmosphere where the refractivity drops steeply enough that any signal launched into the layer at a shallow enough elevation angle can't escape upward. Picture a slice of your antenna's radiation pattern between about 0.5° and +0.5° elevation. Normally that energy just keeps rising into space and is lost. Under a duct, it climbs into the layer, curves back down, hits the ground (or a lower boundary of the duct) and reflects, climbs again, curves back down again, and keeps repeating. The signal skips along inside the duct for hundreds of kilometers, losing very little on each bounce because the reflections are shallow and the layer is keeping the wavefront collimated.
A [*duct*](https://en.wikipedia.org/wiki/Atmospheric_duct) is a layer of atmosphere where the refractivity drops steeply enough that any signal launched into the layer at a shallow enough elevation angle can't escape upward. Picture a slice of your antenna's radiation pattern between about 0.5° and +0.5° elevation. Normally that energy just keeps rising into space and is lost. Under a duct, it climbs into the layer, curves back down, hits the ground (or a lower boundary of the duct) and reflects, climbs again, curves back down again, and keeps repeating. The signal skips along inside the duct for hundreds of kilometers, losing very little on each bounce because the reflections are shallow and the layer is keeping the wavefront collimated.
<div id="scene-duct"></div>
@ -106,7 +106,7 @@ This is the 65 km path I've been staring at: my QTH to the W5HN/B beacon on top
<div id="scene-real"></div>
Figuring out whether that duct is there on any given day is the whole point of <https://prop.w5isp.com>. The app pulls HRRR at native hybrid-sigma levels, corrects against ASOS and radiosonde profiles, and computes the refractivity gradient along every path we track. When a duct is sitting on the path the score lights up. When it isn't, the path should behave the way the terrain profile says.
Figuring out whether that duct is there on any given day is the whole point of <https://prop.w5isp.com>. The app pulls [HRRR](https://rapidrefresh.noaa.gov/hrrr/) at native hybrid-sigma levels, corrects against ASOS and radiosonde profiles, and computes the refractivity gradient along every path we track. When a duct is sitting on the path the score lights up. When it isn't, the path should behave the way the terrain profile says.
The part that keeps me working on it is how often it *doesn't* behave that way. Ducting, super-refraction, and enhanced tropo scatter combine in ways that still surprise people who've been operating these bands for forty years, which is both a bit humbling and a strong argument against ever finishing this project. The more verified contacts we log with good atmospheric data behind them, the better the model gets.

View file

@ -297,7 +297,7 @@
{ key: 'bulge', label: 'Earth bulge' },
{ key: 'status', label: 'LOS' },
],
caption: 'Zoom out, and the ground stops being flat. The horizon distance for an antenna of height h is approximately √(2·R·h). If A and B are farther apart than both their horizon distances combined, the Earths bulge rises above the straight line between them.',
caption: 'Zoom out, and the ground stops being flat. The horizon distance for an antenna of height h is approximately √(2*R*h). If A and B are farther apart than both their horizon distances combined, the Earths bulge rises above the straight line between them.',
threeCol: true,
});
if (!s) return;
@ -730,7 +730,7 @@
ctx.strokeRect(pX, pY, pW, pH);
// Axis titles
labelText(ctx, 'ν (obstacle above LOS )', pX + pW / 2, pY + pH + 28, COLORS.dim, 'center');
labelText(ctx, 'ν (obstacle above LOS ->)', pX + pW / 2, pY + pH + 28, COLORS.dim, 'center');
ctx.save();
ctx.translate(pX - 30, pY + pH / 2);
ctx.rotate(-Math.PI / 2);
@ -1031,7 +1031,7 @@
const xLaunch = margin.l + 10;
const launchGroundY = margin.t + launchAltM * pxPerM_y;
// Ground surface: drops with d²/(2·Reff) going right. Clipped to panel.
// Ground surface: drops with d²/(2*Reff) going right. Clipped to panel.
const bottomY = margin.t + pH;
const groundYatDx = dx_m => Math.min(
bottomY,
@ -1400,8 +1400,8 @@
// Annotation arrow pointing at the steep-gradient zone
let annotAltM, annotText;
if (active === 'surface') { annotAltM = 50; annotText = 'steep drop traps signal below 100 m'; }
else if (active === 'elevated') { annotAltM = 275; annotText = 'steep drop in band traps signal inside'; }
if (active === 'surface') { annotAltM = 50; annotText = 'steep drop -> traps signal below 100 m'; }
else if (active === 'elevated') { annotAltM = 275; annotText = 'steep drop in band -> traps signal inside'; }
else { annotAltM = 15; annotText = 'steep drop in lowest ~30 m'; }
const annN = Nprofile(annotAltM);
const ax = xOfN(annN), ay = yOf(annotAltM);

616
static/js/noise.js Normal file
View file

@ -0,0 +1,616 @@
// Cascaded noise figure explainer. Vanilla JS + 2D canvas. Scoped to one post.
(() => {
'use strict';
const C = {
bg: '#1f232b',
panel: '#242932',
fg: '#d4d8df',
dim: '#5c6370',
grid: '#323844',
blue: '#61afef',
cyan: '#56b6c2',
green: '#98c379',
orange: '#d19a66',
red: '#e06c75',
yellow: '#e5c07b',
magenta: '#c678dd',
};
// ----- framework (same shape as other posts) -----
function setupCanvas(canvas) {
const ctx = canvas.getContext('2d');
const dpr = Math.max(1, window.devicePixelRatio || 1);
const fit = () => {
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
fit();
new ResizeObserver(fit).observe(canvas);
return { ctx, getSize: () => ({ w: canvas.clientWidth, h: canvas.clientHeight }) };
}
function scene(id, { height = 360, controls = [], readout = [], caption = '', threeCol = false } = {}) {
const root = document.getElementById(id);
if (!root) return null;
root.classList.add('scene-box');
const canvas = document.createElement('canvas');
canvas.style.width = '100%';
canvas.style.height = `${height}px`;
root.appendChild(canvas);
const readoutEl = document.createElement('div');
readoutEl.className = 'scene-readout';
const readoutSpans = {};
readout.forEach(r => {
const item = document.createElement('span');
item.className = 'scene-readout-item';
const label = document.createElement('span');
label.textContent = r.label + ':';
const val = document.createElement('span');
val.textContent = r.init ?? '';
item.appendChild(label); item.appendChild(val);
readoutEl.appendChild(item);
readoutSpans[r.key] = val;
});
if (readout.length) root.appendChild(readoutEl);
const controlsEl = document.createElement('div');
controlsEl.className = 'scene-controls' + (threeCol ? ' scene-controls--three' : '');
const values = {};
controls.forEach(c => {
const wrap = document.createElement('div');
wrap.className = 'scene-control';
const label = document.createElement('label');
const name = document.createElement('span'); name.textContent = c.label;
const valSpan = document.createElement('span'); valSpan.className = 'scene-value';
label.appendChild(name); label.appendChild(valSpan);
const input = document.createElement('input');
input.type = 'range';
input.min = c.min; input.max = c.max; input.step = c.step ?? 'any';
input.value = c.value;
values[c.key] = parseFloat(input.value);
const fmt = c.format || (v => v.toFixed(2));
valSpan.textContent = fmt(values[c.key]);
input.addEventListener('input', () => {
values[c.key] = parseFloat(input.value);
valSpan.textContent = fmt(values[c.key]);
});
wrap.appendChild(label); wrap.appendChild(input);
controlsEl.appendChild(wrap);
});
if (controls.length) root.appendChild(controlsEl);
if (caption) {
const cap = document.createElement('div');
cap.className = 'scene-caption';
cap.textContent = caption;
root.appendChild(cap);
}
const { ctx, getSize } = setupCanvas(canvas);
const setReadout = (key, text) => {
if (readoutSpans[key]) readoutSpans[key].textContent = text;
};
return { canvas, ctx, getSize, values, setReadout };
}
function clear(ctx, w, h) { ctx.clearRect(0, 0, w, h); }
function text(ctx, str, x, y, color = C.fg, align = 'left', baseline = 'alphabetic', size = 12) {
ctx.save();
ctx.fillStyle = color;
ctx.font = `${size}px system-ui, sans-serif`;
ctx.textAlign = align;
ctx.textBaseline = baseline;
ctx.fillText(str, x, y);
ctx.restore();
}
// ----- math helpers -----
const T0 = 290; // reference temperature in kelvin
const K = 1.380649e-23; // Boltzmann in J/K
function kTB_dBm(T, B_Hz) {
// Power in watts: k*T*B. Convert to dBm.
const pW = K * T * B_Hz;
return 10 * Math.log10(pW / 1e-3);
}
function dB2lin(dB) { return Math.pow(10, dB / 10); }
function lin2dB(x) { return 10 * Math.log10(x); }
// Friis: F_total = F1 + (F2-1)/G1 + (F3-1)/(G1*G2) + ...
function cascadeNF(stages) {
// stages: [{ gainDb, nfDb }]
let F = 1;
let Gcum = 1;
const contributions = [];
stages.forEach((s, i) => {
const Fi = dB2lin(s.nfDb);
const add = i === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
const before = F;
F += add;
contributions.push({ beforeF: before, afterF: F, add, gainLin: dB2lin(s.gainDb) });
Gcum *= dB2lin(s.gainDb);
});
return { F, nfDb: lin2dB(F), Gcum, contributions };
}
// ----- Scene 1: thermal noise floor -----
function sceneNoiseFloor() {
const s = scene('scene-noise-floor', {
height: 360,
controls: [
{ key: 'bwLog', label: 'Bandwidth', min: 0, max: 7, value: 4, step: 0.01,
format: v => {
const hz = Math.pow(10, v);
if (hz >= 1e6) return (hz / 1e6).toFixed(2) + ' MHz';
if (hz >= 1e3) return (hz / 1e3).toFixed(2) + ' kHz';
return hz.toFixed(0) + ' Hz';
}
},
{ key: 'T', label: 'Temperature', min: 20, max: 500, value: 290, step: 1, format: v => v.toFixed(0) + ' K' },
{ key: 'signalDbm', label: 'Signal power', min: -180, max: -30, value: -110, step: 0.5, format: v => v.toFixed(1) + ' dBm' },
],
readout: [
{ key: 'floor', label: 'Noise floor' },
{ key: 'snr', label: 'SNR' },
],
caption: 'Every resistor at a temperature above absolute zero hisses. The noise power in a bandwidth B and at temperature T works out to k*T*B watts, which at room temperature is the famous 174 dBm per hertz of bandwidth. Widen the filter, raise the temperature, and the hiss rises. The signal has to sit above that hiss to be worth anything.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 60, r: 20, t: 25, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
// y axis: power (dBm), range -180 to -20
const pMin = -180, pMax = -20;
const xOf = f => margin.l + f * pW; // f: 0..1
const yOf = p => margin.t + (1 - (p - pMin) / (pMax - pMin)) * pH;
// frame + gridlines
ctx.strokeStyle = C.grid;
ctx.strokeRect(margin.l, margin.t, pW, pH);
for (let p = pMax; p >= pMin; p -= 20) {
const y = yOf(p);
ctx.beginPath(); ctx.moveTo(margin.l, y); ctx.lineTo(margin.l + pW, y); ctx.stroke();
text(ctx, `${p} dBm`, margin.l - 6, y, C.dim, 'right', 'middle');
}
text(ctx, 'frequency ->', margin.l + pW / 2, margin.t + pH + 26, C.dim, 'center');
ctx.save();
ctx.translate(margin.l - 48, margin.t + pH / 2);
ctx.rotate(-Math.PI / 2);
text(ctx, 'power (dBm)', 0, 0, C.dim, 'center');
ctx.restore();
// Noise floor as a horizontal band plus a hashy line texture
const bw = Math.pow(10, s.values.bwLog);
const T = s.values.T;
const floorDbm = kTB_dBm(T, bw);
const floorY = yOf(floorDbm);
// subtle fill below noise floor
ctx.fillStyle = 'rgba(86,182,194,0.06)';
ctx.fillRect(margin.l, floorY, pW, margin.t + pH - floorY);
// noise floor line
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(margin.l, floorY); ctx.lineTo(margin.l + pW, floorY); ctx.stroke();
// simulated noise squiggle just above the floor
ctx.strokeStyle = C.cyan;
ctx.globalAlpha = 0.4;
ctx.lineWidth = 1;
ctx.beginPath();
for (let i = 0; i <= pW; i += 3) {
const jitter = (Math.sin(i * 0.23) + Math.sin(i * 0.71 + 1) + Math.sin(i * 1.37 + 2)) * 1.7;
const y = floorY - 1 - Math.abs(jitter);
if (i === 0) ctx.moveTo(margin.l + i, y); else ctx.lineTo(margin.l + i, y);
}
ctx.stroke();
ctx.globalAlpha = 1;
// Signal peak
const sig = s.values.signalDbm;
const sigX = margin.l + pW * 0.55;
const sigY = yOf(sig);
ctx.strokeStyle = sig > floorDbm ? C.green : C.red;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(sigX - 30, floorY);
ctx.lineTo(sigX - 6, sigY + 4);
ctx.lineTo(sigX, sigY);
ctx.lineTo(sigX + 6, sigY + 4);
ctx.lineTo(sigX + 30, floorY);
ctx.stroke();
// Labels
text(ctx, `noise floor ${floorDbm.toFixed(1)} dBm`, margin.l + 8, floorY - 6, C.cyan);
text(ctx, `signal ${sig.toFixed(1)} dBm`, sigX, sigY - 10, sig > floorDbm ? C.green : C.red, 'center');
const snr = sig - floorDbm;
s.setReadout('floor', `${floorDbm.toFixed(1)} dBm`);
s.setReadout('snr', `${snr.toFixed(1)} dB`);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ----- Scene 2: single stage noise figure -----
function sceneSingleStage() {
const s = scene('scene-single-stage', {
height: 320,
controls: [
{ key: 'gain', label: 'Gain', min: 0, max: 40, value: 20, step: 0.5, format: v => v.toFixed(1) + ' dB' },
{ key: 'nf', label: 'Noise figure', min: 0.1, max: 20, value: 2.5, step: 0.1, format: v => v.toFixed(1) + ' dB' },
{ key: 'inSnr', label: 'Input SNR', min: 0, max: 40, value: 15, step: 0.5, format: v => v.toFixed(1) + ' dB' },
],
readout: [
{ key: 'outSnr', label: 'Output SNR' },
{ key: 'loss', label: 'SNR degradation' },
],
caption: 'Put a signal and some noise into an amplifier. The amplifier multiplies both by its gain, which by itself changes nothing about their ratio. But the amplifier also adds its own internal noise at the output, and that extra noise is what makes SNR drop. The noise figure is a compact way to say how much.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 30, r: 30, t: 30, b: 30 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
// block in the center
const ampW = 140, ampH = 80;
const ampX = margin.l + (pW - ampW) / 2;
const ampY = margin.t + (pH - ampH) / 2;
// draw connecting wire
ctx.strokeStyle = C.dim;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(margin.l + 10, ampY + ampH / 2);
ctx.lineTo(ampX, ampY + ampH / 2);
ctx.moveTo(ampX + ampW, ampY + ampH / 2);
ctx.lineTo(margin.l + pW - 10, ampY + ampH / 2);
ctx.stroke();
// amplifier triangle
ctx.fillStyle = C.panel;
ctx.strokeStyle = C.cyan;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ampX, ampY);
ctx.lineTo(ampX + ampW, ampY + ampH / 2);
ctx.lineTo(ampX, ampY + ampH);
ctx.closePath();
ctx.fill();
ctx.stroke();
text(ctx, `G = ${s.values.gain.toFixed(1)} dB`, ampX + ampW / 3, ampY + ampH / 2 - 6, C.fg, 'center');
text(ctx, `NF = ${s.values.nf.toFixed(1)} dB`, ampX + ampW / 3, ampY + ampH / 2 + 10, C.yellow, 'center');
// Input/output bar visualizations
const inSNR = s.values.inSnr;
const gain = s.values.gain;
const nf = s.values.nf;
const outSNR = inSNR - nf;
const barH = 40;
const barTop = ampY - barH - 10;
const barBottom = barTop + barH;
// Input side
drawSignalNoiseBars(ctx, margin.l + 10, barTop, 80, barH, inSNR, 'input', C.green);
// Output side
drawSignalNoiseBars(ctx, margin.l + pW - 90, barTop, 80, barH, outSNR, 'output', outSNR > 0 ? C.green : C.red);
// Wires from each bar down to the main signal wire (no text in the way now)
ctx.strokeStyle = C.dim;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(margin.l + 50, barBottom);
ctx.lineTo(margin.l + 50, ampY + ampH / 2);
ctx.moveTo(margin.l + pW - 50, barBottom);
ctx.lineTo(margin.l + pW - 50, ampY + ampH / 2);
ctx.stroke();
s.setReadout('outSnr', `${outSNR.toFixed(1)} dB`);
s.setReadout('loss', `${nf.toFixed(2)} dB (= NF)`);
requestAnimationFrame(draw);
}
// Draw a simple bar representing signal and noise levels. Both labels go
// ABOVE the bar so the space below is free for the connecting wire.
function drawSignalNoiseBars(ctx, x, y, w, h, snrDb, label, signalColor) {
// Fixed noise height at h*0.5, signal proportional to snr above that
const noiseH = h * 0.5;
const maxExtra = h * 0.5;
const sigExtra = Math.max(0, Math.min(1, snrDb / 30)) * maxExtra;
// noise
ctx.fillStyle = C.cyan;
ctx.globalAlpha = 0.6;
ctx.fillRect(x, y + h - noiseH, w, noiseH);
ctx.globalAlpha = 1;
// signal
ctx.fillStyle = signalColor;
ctx.fillRect(x, y + h - noiseH - sigExtra, w, sigExtra);
// border
ctx.strokeStyle = C.grid;
ctx.strokeRect(x, y, w, h);
// labels stacked above the bar
text(ctx, label, x + w / 2, y - 20, C.dim, 'center');
text(ctx, `SNR ${snrDb.toFixed(1)} dB`, x + w / 2, y - 6, C.fg, 'center');
}
requestAnimationFrame(draw);
}
// ----- Scene 3: cascade builder with Friis -----
function sceneCascade() {
// Block library
const LIB = [
{ id: 'none', name: '(empty)', gainDb: 0, nfDb: 0 },
{ id: 'feedline', name: 'feedline -3 dB',gainDb: -3, nfDb: 3 },
{ id: 'lna_low', name: 'LNA NF 0.6', gainDb: 20, nfDb: 0.6 },
{ id: 'lna_std', name: 'LNA NF 1.5', gainDb: 18, nfDb: 1.5 },
{ id: 'lna_cheap', name: 'LNA NF 3', gainDb: 15, nfDb: 3 },
{ id: 'mixer', name: 'Mixer (7 dB)', gainDb: -7, nfDb: 7 },
{ id: 'if_amp', name: 'IF amp', gainDb: 25, nfDb: 4 },
{ id: 'post_amp', name: 'Post-IF amp', gainDb: 20, nfDb: 6 },
];
const SLOTS = 5;
const s = scene('scene-cascade', {
height: 420,
controls: Array.from({ length: SLOTS }, (_, i) => ({
key: `slot${i}`,
label: `Stage ${i + 1}`,
min: 0,
max: LIB.length - 1,
value: i === 0 ? 2 : i === 1 ? 5 : i === 2 ? 6 : i === 3 ? 7 : 0,
step: 1,
format: v => LIB[Math.round(v)].name,
})),
readout: [
{ key: 'total', label: 'Total NF' },
{ key: 'gain', label: 'Total gain' },
],
caption: 'Slide each stage through the block library. The chain visualization updates, cumulative NF is drawn below each block, and the total NF at the output appears at the end. Try: LNA first vs feedline first. The first stage almost always dominates.',
threeCol: true,
});
if (!s) return;
function currentStages() {
const stages = [];
for (let i = 0; i < SLOTS; i++) {
const pick = LIB[Math.round(s.values[`slot${i}`])];
if (pick.id === 'none') continue;
stages.push({ gainDb: pick.gainDb, nfDb: pick.nfDb, name: pick.name });
}
return stages;
}
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const stages = currentStages();
const margin = { l: 20, r: 20, t: 40, b: 30 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
// Chain row: boxes for each stage
const boxW = Math.min(150, (pW - (stages.length + 1) * 14) / Math.max(1, stages.length));
const boxH = 70;
const rowY = margin.t + 10;
let x = margin.l;
// antenna symbol at the left
ctx.strokeStyle = C.fg;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + 6, rowY + boxH / 2);
ctx.lineTo(x + 6, rowY + boxH / 2 - 16);
ctx.moveTo(x, rowY + boxH / 2 - 16);
ctx.lineTo(x + 12, rowY + boxH / 2 - 16);
ctx.stroke();
text(ctx, 'ant', x + 6, rowY + boxH / 2 + 18, C.dim, 'center');
x += 28;
// cascade compute
const cascade = cascadeNF(stages);
const cumAtStage = [];
{
let F = 1, Gcum = 1;
stages.forEach((st, i) => {
const Fi = dB2lin(st.nfDb);
F += i === 0 ? (Fi - 1) : (Fi - 1) / Gcum;
Gcum *= dB2lin(st.gainDb);
cumAtStage.push({ F, G: Gcum, nfDb: lin2dB(F), gainDbCum: lin2dB(Gcum) });
});
}
stages.forEach((st, i) => {
// connecting wire
ctx.strokeStyle = C.dim;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - 14, rowY + boxH / 2);
ctx.lineTo(x, rowY + boxH / 2);
ctx.stroke();
// box
ctx.fillStyle = C.panel;
ctx.strokeStyle = st.gainDb < 0 ? C.orange : C.cyan;
ctx.lineWidth = 2;
ctx.fillRect(x, rowY, boxW, boxH);
ctx.strokeRect(x, rowY, boxW, boxH);
text(ctx, st.name, x + boxW / 2, rowY + 16, C.fg, 'center');
text(ctx, `G = ${st.gainDb.toFixed(1)} dB`, x + boxW / 2, rowY + 34, C.dim, 'center');
text(ctx, `NF = ${st.nfDb.toFixed(1)} dB`, x + boxW / 2, rowY + 52, C.yellow, 'center');
// cumulative NF below this stage
const cum = cumAtStage[i];
text(ctx, `cum NF: ${cum.nfDb.toFixed(2)} dB`, x + boxW / 2, rowY + boxH + 18, C.green, 'center');
text(ctx, `cum G: ${cum.gainDbCum.toFixed(1)} dB`, x + boxW / 2, rowY + boxH + 34, C.dim, 'center');
x += boxW + 14;
});
if (stages.length === 0) {
text(ctx, '(add stages via the sliders below)', margin.l + pW / 2, rowY + boxH / 2, C.dim, 'center');
}
// Contribution bar chart (how much of the final noise each stage adds)
if (stages.length > 0) {
const totalAdd = cascade.contributions.reduce((a, b) => a + b.add, 0);
const chartX = margin.l, chartY = rowY + boxH + 60;
const chartH = 40;
let cx = chartX;
const chartW = Math.max(300, pW * 0.9);
cascade.contributions.forEach((c, i) => {
const frac = c.add / totalAdd;
const segW = frac * chartW;
ctx.fillStyle = i % 2 === 0 ? C.cyan : C.orange;
ctx.globalAlpha = 0.8;
ctx.fillRect(cx, chartY, segW, chartH);
ctx.globalAlpha = 1;
if (segW > 30) {
text(ctx, `${(frac * 100).toFixed(0)}%`, cx + segW / 2, chartY + chartH / 2 + 4, '#111', 'center');
}
cx += segW;
});
ctx.strokeStyle = C.grid;
ctx.strokeRect(chartX, chartY, chartW, chartH);
text(ctx, 'share of output noise contributed by each stage', chartX, chartY - 6, C.dim);
}
// Friis formula readout
s.setReadout('total', stages.length ? `${cascade.nfDb.toFixed(2)} dB` : 'n/a');
s.setReadout('gain', stages.length ? `${lin2dB(cascade.Gcum).toFixed(1)} dB` : 'n/a');
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
}
// ----- Scene 4: LNA placement -----
function sceneLNAPlacement() {
const s = scene('scene-lna-placement', {
height: 360,
controls: [
{ key: 'lossDb', label: 'Feedline loss', min: 0, max: 12, value: 4, step: 0.1, format: v => `${v.toFixed(1)} dB` },
{ key: 'lnaNf', label: 'LNA noise figure', min: 0.3, max: 6, value: 1.2, step: 0.1, format: v => `${v.toFixed(1)} dB` },
{ key: 'lnaGain',label: 'LNA gain', min: 6, max: 35, value: 20, step: 0.5, format: v => `${v.toFixed(1)} dB` },
],
readout: [
{ key: 'atTop', label: 'LNA at antenna' },
{ key: 'atRadio', label: 'LNA at the radio' },
{ key: 'diff', label: 'Difference' },
],
caption: 'The LNA can sit up at the antenna (before the feedline loss) or down at the radio (after it). The first arrangement lets the LNA see the full signal and swamps the feedline loss with its gain. The second arrangement lets the feedline loss sit in front of the LNA, which is the same as putting a 4 dB attenuator in front of your best amplifier.',
threeCol: true,
});
if (!s) return;
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
clear(ctx, w, h);
const margin = { l: 30, r: 30, t: 30, b: 40 };
const pW = w - margin.l - margin.r;
const pH = h - margin.t - margin.b;
const rxRadio = { gainDb: 10, nfDb: 8, name: 'radio front-end' };
const feedline = { gainDb: -s.values.lossDb, nfDb: s.values.lossDb, name: `feedline ${s.values.lossDb.toFixed(1)} dB` };
const lna = { gainDb: s.values.lnaGain, nfDb: s.values.lnaNf, name: `LNA NF ${s.values.lnaNf.toFixed(1)}` };
const chainA = [lna, feedline, rxRadio];
const chainB = [feedline, lna, rxRadio];
const resA = cascadeNF(chainA);
const resB = cascadeNF(chainB);
// Draw two rows of chains with their NF numbers
const rowA_y = margin.t + 20;
const rowB_y = margin.t + pH - 60;
drawChain(ctx, margin.l, rowA_y, pW, 'LNA at the antenna', chainA, resA.nfDb, C.green);
drawChain(ctx, margin.l, rowB_y, pW, 'LNA at the radio', chainB, resB.nfDb, C.red);
s.setReadout('atTop', `NF ${resA.nfDb.toFixed(2)} dB`);
s.setReadout('atRadio', `NF ${resB.nfDb.toFixed(2)} dB`);
s.setReadout('diff', `${(resB.nfDb - resA.nfDb).toFixed(2)} dB`);
requestAnimationFrame(draw);
}
function drawChain(ctx, x0, y0, totalW, label, stages, nfDb, nfColor) {
text(ctx, label, x0, y0 - 6, C.fg);
const boxW = (totalW - 80 - (stages.length - 1) * 10) / stages.length;
const boxH = 48;
let x = x0;
// antenna
ctx.strokeStyle = C.fg;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + 6, y0 + boxH / 2);
ctx.lineTo(x + 6, y0 + boxH / 2 - 14);
ctx.moveTo(x, y0 + boxH / 2 - 14);
ctx.lineTo(x + 12, y0 + boxH / 2 - 14);
ctx.stroke();
x += 26;
stages.forEach((st) => {
ctx.strokeStyle = C.dim;
ctx.beginPath();
ctx.moveTo(x - 10, y0 + boxH / 2); ctx.lineTo(x, y0 + boxH / 2);
ctx.stroke();
ctx.fillStyle = C.panel;
ctx.strokeStyle = st.gainDb < 0 ? C.orange : C.cyan;
ctx.fillRect(x, y0, boxW, boxH);
ctx.strokeRect(x, y0, boxW, boxH);
text(ctx, st.name, x + boxW / 2, y0 + 18, C.fg, 'center');
text(ctx, `G=${st.gainDb.toFixed(1)} NF=${st.nfDb.toFixed(1)}`, x + boxW / 2, y0 + 36, C.dim, 'center');
x += boxW + 10;
});
// output NF label
ctx.fillStyle = nfColor;
text(ctx, `Total NF: ${nfDb.toFixed(2)} dB`, x0 + totalW, y0 + boxH + 18, nfColor, 'right');
}
requestAnimationFrame(draw);
}
function boot() {
const host = document.querySelector('.scene-host, [data-scene-host]');
if (host) host.classList.add('scene-host');
sceneNoiseFloor();
sceneSingleStage();
sceneCascade();
sceneLNAPlacement();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();

View file

@ -112,9 +112,68 @@
// ----- sequence-diagram primitives -----
// Draw a packet as a slanted arrow from (fromX, yStart) to (toX, yEnd).
// If lost === true, dash the line and draw an X near the midpoint where it gets dropped.
// dropFrac (0..1) controls how far along the loss happens.
// Draw a partial / completed packet. `frac` in 0..1 controls how far along
// the arrow has advanced from (fromX, yStart) toward (toX, yEnd). If the
// packet is lost, the arrow dashes out and an X appears where it dies.
function drawPartialPacket(ctx, fromX, toX, yStart, yEnd, frac, label, color, { lost = false, dropFrac = 0.5 } = {}) {
frac = Math.max(0, Math.min(1, frac));
if (frac <= 0) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 1.8;
if (lost && frac >= dropFrac) {
// Drew up to drop point then stop.
const mx = fromX + (toX - fromX) * dropFrac;
const my = yStart + (yEnd - yStart) * dropFrac;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(fromX, yStart); ctx.lineTo(mx, my);
ctx.stroke();
ctx.setLineDash([]);
ctx.strokeStyle = C.red;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(mx - 6, my - 6); ctx.lineTo(mx + 6, my + 6);
ctx.moveTo(mx - 6, my + 6); ctx.lineTo(mx + 6, my - 6);
ctx.stroke();
} else {
const endX = fromX + (toX - fromX) * frac;
const endY = yStart + (yEnd - yStart) * frac;
ctx.beginPath();
ctx.moveTo(fromX, yStart);
ctx.lineTo(endX, endY);
if (lost) ctx.setLineDash([4, 4]);
ctx.stroke();
if (lost) ctx.setLineDash([]);
// arrowhead on the advancing tip only when the packet is not lost yet
if (!lost) {
const dx = toX - fromX, dy = yEnd - yStart;
const L = Math.hypot(dx, dy);
const ux = dx / L, uy = dy / L;
const ax = endX - ux * 8;
const ay = endY - uy * 8;
const px = -uy, py = ux;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(endX, endY);
ctx.lineTo(ax + px * 4, ay + py * 4);
ctx.lineTo(ax - px * 4, ay - py * 4);
ctx.closePath();
ctx.fill();
}
}
// Label at midpoint of the traveled line, fading in as the packet advances
const lx = (fromX + (toX - fromX) * frac * 0.5) + fromX * 0 + (frac === 1 ? (fromX + toX) / 2 - fromX : 0);
const midX = fromX + (toX - fromX) * frac / 2;
const midY = yStart + (yEnd - yStart) * frac / 2 - 4;
ctx.globalAlpha = Math.min(1, frac * 2);
text(ctx, label, midX, midY, color, 'center');
ctx.globalAlpha = 1;
ctx.restore();
}
// Back-compat: the original non-animated draw that's still used by the
// handshake scene's progress bar drawing and a couple of other places.
function drawPacket(ctx, fromX, toX, yStart, yEnd, label, color, lost = false, dropFrac = 0.5) {
ctx.save();
ctx.strokeStyle = color;
@ -334,6 +393,81 @@
});
if (!s) return;
// Scenario data: sequence of sends and the receiver response.
const scenarios = [
// 0: none lost
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: false },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
],
// 1: one lost (seq 2)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 2: two lost (seq 2 and 4)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
// 3: three lost
[
{ kind: 'data', seq: 1, lost: true },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'retx', seq: 1, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 4: burst loss
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: true },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 3, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
];
// Timing parameters for the animation.
const PACKET_DUR = 1.0; // seconds per packet in flight
const GAP = 0.25; // seconds of pause between each send/ack step
const PAUSE_END = 2.0; // hold time after the cycle completes before looping
const startWall = performance.now();
let lastScenario = -1;
// Precomputed rows (each row = one send or one ack) per scenario.
const scenarioRows = scenarios.map(scenario => {
const rows = [];
let nextExpected = 1;
scenario.forEach(pkt => {
rows.push({ type: 'send', pkt });
if (!pkt.lost) {
if (pkt.seq === nextExpected) nextExpected++;
rows.push({ type: 'ack', expect: nextExpected });
} else {
rows.push({ type: 'ack', expect: nextExpected, dup: true });
}
});
// attach t0 / t1 to each row sequentially
let t = 0;
rows.forEach(r => {
r.t0 = t;
r.t1 = t + PACKET_DUR;
t = r.t1 + GAP;
});
return { rows, total: t };
});
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
@ -343,99 +477,49 @@
const leftX = 90, rightX = w - 90;
drawTimelines(ctx, leftX, rightX, topY, botY);
// Scenario data: sequence of sends and the receiver response.
// Each packet occupies one "row". Lost packets don't arrive; ACKs keep
// asking for the lost byte.
const scenarios = [
// 0: none lost
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: false },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
],
// 1: one lost (seq 2)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 2: two lost (seq 2 and 4)
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
// 3: three lost
[
{ kind: 'data', seq: 1, lost: true },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: false },
{ kind: 'retx', seq: 1, lost: false },
{ kind: 'retx', seq: 2, lost: false },
],
// 4: burst loss
[
{ kind: 'data', seq: 1, lost: false },
{ kind: 'data', seq: 2, lost: true },
{ kind: 'data', seq: 3, lost: true },
{ kind: 'data', seq: 4, lost: true },
{ kind: 'retx', seq: 2, lost: false },
{ kind: 'retx', seq: 3, lost: false },
{ kind: 'retx', seq: 4, lost: false },
],
];
const scenario = scenarios[Math.floor(s.values.loss)];
const scenarioIdx = Math.floor(s.values.loss);
const { rows, total } = scenarioRows[scenarioIdx];
const cycleDur = total + PAUSE_END;
// restart the animation clock when scenario changes so users always
// see the full sequence when they flip the slider.
if (scenarioIdx !== lastScenario) lastScenario = scenarioIdx;
const now = ((performance.now() - startWall) / 1000) % cycleDur;
const slant = 22;
// Compute rows: each data/retx gets a row; if not lost, the ACK comes
// back just below on the next slant.
const rows = [];
let nextExpected = 1;
scenario.forEach(pkt => {
rows.push({ type: 'send', pkt });
if (!pkt.lost) {
if (pkt.seq === nextExpected) nextExpected++;
rows.push({ type: 'ack', expect: nextExpected });
} else {
// duplicate ACK still goes out for the last in-order byte
rows.push({ type: 'ack', expect: nextExpected, dup: true });
}
});
const rowH = Math.min(34, (botY - topY - slant - 10) / rows.length);
let sent = 0, retx = 0;
rows.forEach((r, i) => {
const y = topY + 10 + i * rowH;
const frac = Math.max(0, Math.min(1, (now - r.t0) / (r.t1 - r.t0)));
if (r.type === 'send') {
const color = r.pkt.kind === 'retx' ? C.orange : C.cyan;
const label = r.pkt.kind === 'retx' ? `retx seq=${r.pkt.seq}` : `DATA seq=${r.pkt.seq}`;
drawPacket(ctx, leftX, rightX, y, y + slant, label, color, r.pkt.lost, 0.5);
sent++;
if (r.pkt.kind === 'retx') retx++;
drawPartialPacket(ctx, leftX, rightX, y, y + slant, frac, label, color, { lost: r.pkt.lost, dropFrac: 0.5 });
if (frac > 0 && r.pkt.kind === 'retx') retx++;
if (frac > 0) sent++;
} else {
const label = r.dup ? `ACK ack=${r.expect} (dup)` : `ACK ack=${r.expect}`;
const color = r.dup ? C.red : C.green;
drawPacket(ctx, rightX, leftX, y, y + slant, label, color, false);
drawPartialPacket(ctx, rightX, leftX, y, y + slant, frac, label, color);
}
});
// Time progress bar at the top
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.cyan;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / cycleDur), barY, 3, 0, Math.PI * 2);
ctx.fill();
s.setReadout('sent', sent);
s.setReadout('retx', retx);
requestAnimationFrame(() => {});
}
// Re-render whenever slider changes: attach input listeners via rAF loop.
function loop() {
draw();
requestAnimationFrame(loop);
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
}
@ -454,6 +538,14 @@
});
if (!s) return;
// Animation timing: in a single RTT all `win` packets go out in quick
// succession, they arrive at the server after half an RTT, the acks come
// back during the second half, and then the next batch goes.
const RTT_DUR = 2.2; // seconds per round trip
const INTRA_STAGGER = 0.08;// stagger between packets within a batch
const PAUSE_END = 1.6;
const startWall = performance.now();
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
@ -468,38 +560,55 @@
const rtts = Math.ceil(totalPackets / win);
const slant = 20;
// Compute vertical layout adaptively so every seq/ack label gets
// enough space to be legible regardless of window size.
const avail = botY - topY - 20;
const rowsCount = totalPackets * 2; // send rows + ack rows
const rowsCount = totalPackets * 2;
const gapCount = rtts - 1;
const unit = avail / (rowsCount + gapCount * 0.7);
const rowStep = Math.max(slant + 8, Math.min(32, unit));
const rttGap = Math.min(rowStep * 0.7, 24);
let y = topY + 12;
const cycleDur = rtts * RTT_DUR + PAUSE_END;
const now = ((performance.now() - startWall) / 1000) % cycleDur;
let yCursor = topY + 12;
let packetN = 1;
for (let batch = 0; batch < rtts; batch++) {
const batchSize = Math.min(win, totalPackets - batch * win);
// Send batch
const batchStart = batch * RTT_DUR;
// Send phase: 0 -> RTT/2 of this batch, staggered
for (let i = 0; i < batchSize; i++) {
drawPacket(ctx, leftX, rightX, y, y + slant, `seq=${packetN}`, C.cyan);
const sendT0 = batchStart + i * INTRA_STAGGER;
const sendT1 = sendT0 + RTT_DUR / 2;
const frac = Math.max(0, Math.min(1, (now - sendT0) / (sendT1 - sendT0)));
drawPartialPacket(ctx, leftX, rightX, yCursor, yCursor + slant, frac, `seq=${packetN}`, C.cyan);
packetN++;
y += rowStep;
yCursor += rowStep;
}
// ACKs for the batch
// ACK phase: RTT/2 -> RTT of this batch, staggered
for (let i = 0; i < batchSize; i++) {
const ackT0 = batchStart + RTT_DUR / 2 + i * INTRA_STAGGER;
const ackT1 = ackT0 + RTT_DUR / 2;
const frac = Math.max(0, Math.min(1, (now - ackT0) / (ackT1 - ackT0)));
const ackNum = batch * win + i + 2;
drawPacket(ctx, rightX, leftX, y, y + slant, `ack=${ackNum}`, C.green);
y += rowStep;
drawPartialPacket(ctx, rightX, leftX, yCursor, yCursor + slant, frac, `ack=${ackNum}`, C.green);
yCursor += rowStep;
}
// Extra gap between RTTs so each round-trip is visually distinct.
if (batch < rtts - 1) y += rttGap;
if (batch < rtts - 1) yCursor += rttGap;
}
// Time progress bar
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.cyan;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / cycleDur), barY, 3, 0, Math.PI * 2);
ctx.fill();
s.setReadout('rtts', `${rtts}`);
const speedup = Math.ceil(totalPackets / rtts);
s.setReadout('throughput', `${speedup}× faster`);
s.setReadout('throughput', `${speedup}x faster`);
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();
@ -600,7 +709,7 @@
});
// Phase legend
text(ctx, 'slow start → congestion avoidance → loss → repeat', margin.l + 6, margin.t + 14, C.dim);
text(ctx, 'slow start -> congestion avoidance -> loss -> repeat', margin.l + 6, margin.t + 14, C.dim);
s.setReadout('peak', `${peak.toFixed(0)} segments`);
s.setReadout('final', `${points[points.length - 1].cwnd.toFixed(0)} segments`);
@ -633,6 +742,11 @@
return x - Math.floor(x);
}
const PACKET_DUR = 0.8;
const GAP = 0.15;
const PAUSE_END = 2.2;
const startWall = performance.now();
function draw() {
const { w, h } = s.getSize();
const ctx = s.ctx;
@ -647,19 +761,42 @@
const slant = 18;
const rowH = Math.min(28, (botY - topY - slant - 10) / totalPackets);
const cycleDur = totalPackets * (PACKET_DUR + GAP) + PAUSE_END;
const now = ((performance.now() - startWall) / 1000) % cycleDur;
let received = 0;
for (let i = 0; i < totalPackets; i++) {
const y = topY + 10 + i * rowH;
const lost = hash(1, i) < lossPct;
drawPacket(ctx, leftX, rightX, y, y + slant, `UDP seq=${i + 1}`, C.magenta, lost, 0.5 + (hash(2, i) - 0.5) * 0.3);
if (!lost) received++;
const dropFrac = 0.5 + (hash(2, i) - 0.5) * 0.3;
const t0 = i * (PACKET_DUR + GAP);
const t1 = t0 + PACKET_DUR;
const frac = Math.max(0, Math.min(1, (now - t0) / (t1 - t0)));
drawPartialPacket(ctx, leftX, rightX, y, y + slant, frac, `UDP seq=${i + 1}`, C.magenta, { lost, dropFrac });
// count received only once the packet has completed traveling
if (!lost && frac >= 1) received++;
}
s.setReadout('sent', `${totalPackets}`);
s.setReadout('rcvd', `${received}`);
s.setReadout('recovery', received === totalPackets ? 'none needed' : 'none (UDP does not care)');
// Time progress bar
const barY = 18, barX0 = 20, barX1 = w - 20;
ctx.strokeStyle = C.grid;
ctx.beginPath(); ctx.moveTo(barX0, barY); ctx.lineTo(barX1, barY); ctx.stroke();
ctx.fillStyle = C.magenta;
ctx.beginPath();
ctx.arc(barX0 + (barX1 - barX0) * (now / cycleDur), barY, 3, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(() => {});
// totals — show once the full cycle has played
const cycleFraction = now / cycleDur;
const totalCycled = cycleFraction > 0.95 ? totalPackets : Math.min(totalPackets, Math.ceil(now / (PACKET_DUR + GAP)));
let arrived = 0;
for (let i = 0; i < totalCycled; i++) {
const lost = hash(1, i) < lossPct;
if (!lost) arrived++;
}
s.setReadout('sent', `${totalCycled}`);
s.setReadout('rcvd', `${arrived}`);
s.setReadout('recovery', arrived === totalCycled ? 'none needed' : 'none (UDP does not care)');
}
function loop() { draw(); requestAnimationFrame(loop); }
loop();

View file

@ -510,7 +510,7 @@
ctx.restore();
// Shade the three regions lightly
// cutoff: V_BE < ~0.6 V_out ≈ V_CC
// cutoff: V_BE < ~0.6 -> V_out ≈ V_CC
// active: ~0.6 to ~0.72
// saturation: above
const cutoffEdge = 0.6, satEdge = 0.72;