From 5cfe752a140174407662e01ed06f284b8a8d7779 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 22:24:01 +0000 Subject: [PATCH] feat: add quantitative views, time controls, and finish dead wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sim's physics and a11y were complete; what was missing was what a learner can see and do with them. Quantitative views - IntensityProfileNode: a bamboo trace of intensity across a cut through the detector, on Michelson and both Mach-Zehnder ports. Turns the photograph into a measurement — the modulation depth is the visibility, the ripple count is the fringe count. With both ports plus the dashed total it draws I_A + I_B = const, which doc/model.md calls the point of that screen and which two percentages could only imply. - CoherenceEnvelopeNode: visibility against path difference on Michelson, with a marker at the current OPD. Makes doc/model.md §2 visible — flat for a laser, a needle for white light, and the sodium doublet's nulls and revivals. The span comes from the source's own feature scale, so metres and micrometres are both legible. - Both draw through the existing physics: a new pure intensityProfile() beside intensityAt(), and spectrumVisibility() unchanged. Time controls - TimeModel (previously unused) is composed into the two models that evolve on their own, each with stepOnce() for single-frame advance. Photon accumulation can now be paused and stepped a few photons at a time; the Fabry-Pérot sweep can be stopped on a transmission peak. Its scan checkbox is replaced by the clock rather than joined to it. Finished features that were built but never reached the view - showOpticalPath preference: plumbed from main.ts to the table nodes, labelling each element with what it adds to the path difference. The Michelson's contributions are doubled and the Mach-Zehnder's are not, which makes the factor of two concrete. - Mach-Zehnder vertical mirror tilt, which had a model Property and an a11y string but no control; and its visibility readout. - Path difference in wavelengths, using the unused units.waves pattern. - Home-screen icons, which were three blank rectangles. Also - New charts carry accessibleParagraph descriptions derived from the same samples they draw; all strings added in en/es/fr. - Fixed a leak the memory-leak suite caught: a chart must dispose the formatter Properties behind its description, not just its Multilink. - CSP: allow the two inline onclick handlers scenery writes on disabled controls, scoped to script-src-attr by hash. Without this the step button logs a violation on every enable/disable and fails the fuzz run. 138 tests (was 124), including that the two ports' profiles sum to a constant and that the sodium null falls inside the mirror's travel. --- CLAUDE.md | 16 +- README.md | 17 +- doc/implementation-notes.md | 51 ++- src/InterferometryLabColors.ts | 16 + src/common/InterferometryLabScreenIcons.ts | 96 ++++- src/common/model/fringeIntensity.ts | 29 ++ src/common/view/IntensityProfileNode.ts | 347 ++++++++++++++++++ src/common/view/controlFactory.ts | 45 ++- src/common/view/formatters.ts | 46 +++ src/fabry-perot/FabryPerotScreen.ts | 10 +- src/fabry-perot/model/FabryPerotModel.ts | 61 ++- src/fabry-perot/view/FabryPerotCavityPanel.ts | 30 +- src/fabry-perot/view/FabryPerotScreenView.ts | 9 +- src/fabry-perot/view/FabryPerotTableNode.ts | 13 +- src/i18n/StringManager.ts | 5 + src/i18n/strings_en.json | 23 +- src/i18n/strings_es.json | 23 +- src/i18n/strings_fr.json | 23 +- src/mach-zehnder/MachZehnderScreen.ts | 10 +- src/mach-zehnder/model/MachZehnderModel.ts | 54 ++- src/mach-zehnder/view/MachZehnderArmsPanel.ts | 26 +- .../view/MachZehnderScreenView.ts | 84 ++++- src/mach-zehnder/view/MachZehnderTableNode.ts | 24 +- src/main.ts | 6 +- src/michelson/MichelsonScreen.ts | 10 +- src/michelson/model/MichelsonModel.ts | 12 +- src/michelson/view/CoherenceEnvelopeNode.ts | 252 +++++++++++++ src/michelson/view/MichelsonScreenView.ts | 57 ++- src/michelson/view/MichelsonTableNode.ts | 28 +- tests/fringeIntensity.test.ts | 63 +++- tests/interferometerModels.test.ts | 85 ++++- tests/memory-leak.test.ts | 65 +++- vite.config.ts | 17 + 33 files changed, 1545 insertions(+), 108 deletions(-) create mode 100644 src/common/view/IntensityProfileNode.ts create mode 100644 src/michelson/view/CoherenceEnvelopeNode.ts diff --git a/CLAUDE.md b/CLAUDE.md index d5b5b60..62a42aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,9 @@ branches to the renderer. | `src/common/model/refractiveIndex.ts` | Gladstone-Dale gases, N-BK7 Sellmeier, tilted plates | | `src/common/model/LightSourceModel.ts` | Source selection → spectral groups | | `src/common/view/FringePatternNode.ts` | The renderer (CanvasNode) | +| `src/common/view/IntensityProfileNode.ts` | Bamboo chart of intensity across a cut through the detector | +| `src/michelson/view/CoherenceEnvelopeNode.ts` | Bamboo chart of visibility vs path difference | +| `src/common/TimeModel.ts` | Play/pause clock composed into the Mach-Zehnder and Fabry-Pérot models | | `src/common/view/spectralColor.ts` | CIE XYZ colour pipeline — see the carve-out below | | `src/common/view/InterferometryLabNumberControl.ts` | Themed slider; requires an accessible name and explicit keyboard steps | | `src/{michelson,mach-zehnder,fabry-perot}/` | One folder per screen, `model/` + `view/` | @@ -61,6 +64,13 @@ branches to the renderer. - **Zero path difference with parallel mirrors shows a single flat tone, not fringes.** That is correct. `opdSpread()` exists so the a11y description says so rather than claiming rings. - **Fringe counts are derived from a reference, never accumulated**, so they cannot drift. +- **A chart node must dispose every Property it created**, not just its `Multilink`. The + formatter Properties behind an `accessibleParagraph` each link a model Property of their own, so + leaving them alive keeps the node reachable from a model that outlives it. `tests/memory-leak.test.ts` + catches this; it caught it once already. +- **The analysis charts pad their value axis** past what the physics can reach, because the traces + that matter most are flat ones (a dark port at 0, a constant total at 1, a laser's visibility at + 1). Against the frame those look like an empty chart. ## Accessibility @@ -90,15 +100,15 @@ micrometer, mirror tilt) only reach their useful precision via shift-arrow. ## Testing -124 vitest specs; `happy-dom`, template `tests/setup.ts`. +138 vitest specs; `happy-dom`, template `tests/setup.ts`. | Path | Covers | |---|---| | `tests/spectrum.test.ts` | coherence, visibility envelopes, doublet beats, line splitting | -| `tests/fringeIntensity.test.ts` | detector geometry, two-beam and Airy intensity, finesse | +| `tests/fringeIntensity.test.ts` | detector geometry, two-beam and Airy intensity, finesse, `intensityProfile` | | `tests/refractiveIndex.test.ts` | Sellmeier vs published indices, Gladstone-Dale, tilted plates | | `tests/interferometerModels.test.ts` | all three models: derived values, controls, reset | -| `tests/memory-leak.test.ts` | dispose/WeakRef, extended to `FringePatternNode` + `PhotonMarksNode` | +| `tests/memory-leak.test.ts` | dispose/WeakRef, extended to the four nodes that link model Properties | Several assertions are anchored to **published** values (N-BK7 at 632.8 nm and 587.6 nm, its Abbe number, air's refractivity) rather than to the implementation, so a wrong constant fails instead diff --git a/README.md b/README.md index 07a299a..4b327de 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,25 @@ produce rather than drawing a picture of one. - **Michelson** — coarse and micrometer mirror travel, mirror tilt to drive circular fringes into straight ones, an evacuable gas cell for the classic index-of-refraction measurement, a - compensator plate, and a fringe counter + compensator plate, a fringe counter, an intensity trace across the detector, and a visibility + curve that shows the coherence envelope — flat for a laser, a needle for white light, and the + sodium doublet's nulls and revivals - **Mach-Zehnder** — both complementary output ports shown at once, an insertable sample slide with adjustable thickness, index and tilt, and a single-photon mode that builds the pattern one - detection at a time, with a which-path marker that erases it + detection at a time (pausable and steppable), with a which-path marker that erases it — and a + trace of both ports whose dashed total stays flat, so interference visibly moves light rather + than destroying it - **Fabry-Pérot** — mirror reflectance from 0.04 to 0.99, absorption, cavity spacing and a - scanning mode, with live finesse, free spectral range and resolving power, plus a transmission - spectrum showing whether two lines are resolved + scanning mode you can pause and step onto a transmission peak, with live finesse, free spectral + range and resolving power, plus a transmission spectrum showing whether two lines are resolved - **Six light sources** from a helium-neon laser to white light, spanning 200 mm to 1 µm of coherence length — including the sodium doublet and its visibility beats - Physically computed colour: white-light fringes come out with the correct achromatic centre and coloured orders, summed in linear light through CIE XYZ -- Full keyboard access and live screen-reader descriptions of the pattern +- An optional Preferences overlay labelling each optical element with what it contributes to the + optical path difference — including the factor of two a Michelson arm carries and a + Mach-Zehnder's does not +- Full keyboard access and live screen-reader descriptions of the pattern and of both charts - English, Spanish, and French localization via `StringManager` - Default and projector color profiles - Progressive Web App (installable, offline-capable) diff --git a/doc/implementation-notes.md b/doc/implementation-notes.md index 29d0167..5ea6dca 100644 --- a/doc/implementation-notes.md +++ b/doc/implementation-notes.md @@ -64,6 +64,51 @@ shared physics module. **Repaint policy.** The pattern is repainted when its `FringeSpec` changes, not on a clock. A static scene costs nothing. +## The charts share the renderer's physics, not a copy of it + +Three screens now carry a quantitative second view alongside the detector image: +`IntensityProfileNode` (Michelson and Mach-Zehnder), `CoherenceEnvelopeNode` (Michelson) and the +older `TransmissionSpectrumNode` (Fabry-Pérot). All three are bamboo charts built the same way — +`ChartTransform` + `ChartRectangle` + `LinePlot`, redrawn from a `Multilink` on the model +Properties they read. + +None of them re-implements any physics. The intensity trace calls `intensityProfile()` in +`common/model/fringeIntensity.ts`, which is a thin loop over the same `intensityAt()` the renderer +uses per pixel; the visibility curve calls `spectrumVisibility()` from `spectrum.ts`, the same +function behind the Michelson's visibility readout. Sampling the physics from the model layer +rather than the view is what lets `tests/fringeIntensity.test.ts` assert that the two Mach-Zehnder +ports' profiles sum to a constant — the claim `doc/model.md` §6 makes and the dashed total on that +chart draws. + +**Both charts pad their value axis past the reachable range** (−0.06 to 1.08 rather than 0 to 1). +The traces that matter most are the flat ones: a dark port pinned at zero, a constant total pinned +at one, a laser's visibility flat at full contrast. Drawn hard against the frame those read as an +empty box rather than as a result, so the padding and the half-scale gridlines are what make "flat" +legible as an answer. + +**The coherence curve rescales itself**, because a laser's coherence length is 200 mm and white +light's is a micrometre and both have to be readable. The span comes from the source's own feature +scale — the smaller of its coherence length and, for a doublet, its beat period `λ₀²/δλ` — clamped +to the mirror stage's reach. This is the same trick `TransmissionSpectrumNode` already used to zoom +around a line separation. + +**Dispose is not optional here.** Each chart links Properties it does not own, and so do the +formatter Properties feeding its `accessibleParagraph`. Disposing only the multilink leaves those +intermediates listening, which keeps the node reachable from a model that has outlived it; +`tests/memory-leak.test.ts` covers both nodes for exactly that. + +## Time controls + +`common/TimeModel.ts` is composed into the two models that evolve on their own: `MachZehnderModel` +(starting played, so photons flow immediately) and `FabryPerotModel` (starting paused). Each also +exposes `stepOnce()`, which advances one frame's worth regardless of the clock — a handful of +photons, or a three-hundredth of a cavity sweep. `createTimeControl()` in `controlFactory.ts` binds +both to a `TimeControlNode`. + +The Fabry-Pérot's "Scan the spacing" checkbox was replaced by that control rather than joined to +it: `scanningProperty` and `timer.isPlayingProperty` would have been two ways to stop the same +sweep. + ## Colour is computed in linear light through CIE XYZ `src/common/view/spectralColor.ts` converts wavelengths to colour and adds them the way light @@ -105,6 +150,7 @@ src/ SourceType.ts view/ FringePatternNode.ts the renderer + IntensityProfileNode.ts intensity across a cut through the detector DetectorScreenNode.ts bezelled detector + overlay layer OpticalTableNode.ts breadboard background BeamPathNode.ts a beam: bright core in a soft halo @@ -145,15 +191,16 @@ several thousand of them, they are never interactive, and at that size the path ## Tests -124 vitest specs under `tests/`, environment `happy-dom` with the template's `tests/setup.ts`. +138 vitest specs under `tests/`, environment `happy-dom` with the template's `tests/setup.ts`. | File | Covers | |---|---| | `spectrum.test.ts` | coherence length, visibility envelopes, doublet beats, line splitting | +| `fringeIntensity.test.ts` | …and `intensityProfile`, including the two ports summing to a constant | | `fringeIntensity.test.ts` | detector geometry, two-beam and Airy intensity, finesse | | `refractiveIndex.test.ts` | Sellmeier against published indices, Gladstone-Dale, tilted plates | | `interferometerModels.test.ts` | all three screen models: derived values, controls, reset | -| `memory-leak.test.ts` | dispose/WeakRef regression, extended to the two listening nodes | +| `memory-leak.test.ts` | dispose/WeakRef regression, extended to the four listening nodes | | `TimeModel.test.ts` | template model retained | Several assertions are anchored to published numbers rather than to the implementation — N-BK7's diff --git a/src/InterferometryLabColors.ts b/src/InterferometryLabColors.ts index 2c4b5a0..3583078 100644 --- a/src/InterferometryLabColors.ts +++ b/src/InterferometryLabColors.ts @@ -182,6 +182,22 @@ const InterferometryLabColors = { projector: "#1565a8", }), + /** + * A second plot trace, for charts that draw two curves at once — the + * Mach-Zehnder's two output ports. Chosen warm against `plotTrace`'s cool blue + * so the pair stays distinguishable without relying on hue alone. + */ + plotTraceAltColorProperty: new ProfileColorProperty(InterferometryLabNamespace, "plotTraceAlt", { + default: "#ff9e6d", + projector: "#b34a12", + }), + + /** The dashed total of several traces, drawn quieter than the traces themselves. */ + plotSumColorProperty: new ProfileColorProperty(InterferometryLabNamespace, "plotSum", { + default: "#9aa8bd", + projector: "#6b7285", + }), + plotAxisColorProperty: new ProfileColorProperty(InterferometryLabNamespace, "plotAxis", { default: "#7a86a0", projector: "#5a6070", diff --git a/src/common/InterferometryLabScreenIcons.ts b/src/common/InterferometryLabScreenIcons.ts index b78f290..a1a97fa 100644 --- a/src/common/InterferometryLabScreenIcons.ts +++ b/src/common/InterferometryLabScreenIcons.ts @@ -3,19 +3,64 @@ * * Programmatic home-screen / navigation-bar icons for each screen. * Drawn on the standard PhET 548 × 373 canvas using InterferometryLabColors. - * Replace the stub backgrounds with screen-specific motifs. + * + * Each icon is a miniature of what that screen's detector actually shows, which + * is the only thing about the three screens a learner can tell apart at a + * glance. They have to survive being shrunk to navigation-bar size, so the three + * differ in shape as well as colour: broad rings, a pair of complementary + * patches, and narrow rings. */ -import { Node, Rectangle } from "scenerystack/scenery"; +import { Circle, Node, Rectangle } from "scenerystack/scenery"; import { ScreenIcon } from "scenerystack/sim"; import InterferometryLabColors from "../InterferometryLabColors.js"; const W = 548; const H = 373; +/** Side of the detector square each icon is built around, view units. */ +const FACE_SIZE = 260; + function background(): Rectangle { return new Rectangle(0, 0, W, H, { fill: InterferometryLabColors.backgroundColorProperty }); } +/** A detector face: the dark square a pattern is drawn on. */ +function face(size: number, centerX: number): Rectangle { + return new Rectangle(centerX - size / 2, (H - size) / 2, size, size, { + fill: InterferometryLabColors.detectorFaceColorProperty, + stroke: InterferometryLabColors.tableBorderColorProperty, + lineWidth: 2, + }); +} + +/** + * Concentric fringes of equal inclination, clipped to a detector face. + * + * Radii go as √n because that is where the rings actually fall: the path + * difference varies as cos θ, so successive orders crowd together outwards. It + * costs nothing to draw them correctly and it is the shape the screen produces. + */ +function rings(count: number, lineWidth: number, color: Rectangle["fill"], centerX: number): Node { + const children: Node[] = []; + const maxRadius = FACE_SIZE * 0.72; + + for (let n = 1; n <= count; n++) { + children.push( + new Circle(maxRadius * Math.sqrt(n / count), { + centerX, + centerY: H / 2, + stroke: color, + lineWidth, + }), + ); + } + + return new Node({ + children, + clipArea: new Rectangle(centerX - FACE_SIZE / 2, (H - FACE_SIZE) / 2, FACE_SIZE, FACE_SIZE).getShape(), + }); +} + function iconFrom(content: Node): ScreenIcon { return new ScreenIcon(content, { maxIconWidthProportion: 1, @@ -24,26 +69,67 @@ function iconFrom(content: Node): ScreenIcon { }); } +/** Broad circular fringes — what a Michelson with parallel mirrors puts on its screen. */ export function createMichelsonIcon(): ScreenIcon { return iconFrom( new Node({ - children: [background()], + children: [ + background(), + face(FACE_SIZE, W / 2), + rings(7, 14, InterferometryLabColors.accentColorProperty, W / 2), + ], }), ); } +/** + * Two detectors carrying complementary straight fringes: bright where the other + * is dark. The pair is the Mach-Zehnder screen's whole subject. + */ export function createMachZehnderIcon(): ScreenIcon { + const portSize = 200; + const gap = 44; + const leftX = W / 2 - (portSize + gap) / 2; + const rightX = W / 2 + (portSize + gap) / 2; + const barCount = 4; + const barWidth = portSize / (2 * barCount); + + const bars = (centerX: number, offset: number): Node => { + const children: Node[] = []; + for (let i = 0; i < barCount; i++) { + children.push( + new Rectangle(centerX - portSize / 2 + (2 * i + offset) * barWidth, (H - portSize) / 2, barWidth, portSize, { + fill: InterferometryLabColors.accentColorProperty, + }), + ); + } + return new Node({ children }); + }; + return iconFrom( new Node({ - children: [background()], + children: [ + background(), + face(portSize, leftX), + bars(leftX, 0), + face(portSize, rightX), + // Offset by one bar: where port A is bright, port B is dark. + bars(rightX, 1), + ], }), ); } +/** + * Narrow, sharp rings. The same geometry as the Michelson's, drawn thin and + * numerous, because that is exactly what raising the mirror reflectance does to + * the Airy pattern — and the difference between the two icons is the difference + * between two-beam and multi-beam interference. + */ export function createFabryPerotIcon(): ScreenIcon { return iconFrom( new Node({ - children: [background()], + children: [background(), face(FACE_SIZE, W / 2), rings(9, 6, InterferometryLabColors.valueColorProperty, W / 2)], }), ); } diff --git a/src/common/model/fringeIntensity.ts b/src/common/model/fringeIntensity.ts index 6bcbc60..f5eddec 100644 --- a/src/common/model/fringeIntensity.ts +++ b/src/common/model/fringeIntensity.ts @@ -189,6 +189,34 @@ export function axialIntensity(spec: FringeSpec): number { return intensityAt(spec, 0, 0, scratch); } +/** + * Intensity along a horizontal cut through the centre of the detector: `v = 0`, + * `u` running −1 to +1, sampled at pixel centres the same way the renderer + * samples its grid. + * + * This is the trace a slit detector scanned across the pattern would record, and + * it is what turns the image into a measurement — the depth of the modulation + * *is* the visibility, and the number of ripples *is* the fringe count. It lives + * here rather than in the plotting node so that it stays a pure function of a + * {@link FringeSpec}, testable without a canvas, like the rest of the physics. + * + * @param spec - the pattern description + * @param sampleCount - number of points across the detector + * @param out - optional buffer of at least `sampleCount` entries to fill + * @returns the filled buffer + */ +export function intensityProfile(spec: FringeSpec, sampleCount: number, out?: Float64Array): Float64Array { + const result = out && out.length >= sampleCount ? out : new Float64Array(sampleCount); + const scratch = new Float64Array(spec.groups.length); + + for (let i = 0; i < sampleCount; i++) { + const u = (2 * (i + 0.5)) / sampleCount - 1; + result[i] = intensityAt(spec, u, 0, scratch); + } + + return result; +} + InterferometryLabNamespace.register("fringeIntensity", { axialCosine, opticalPathDifference, @@ -200,4 +228,5 @@ InterferometryLabNamespace.register("fringeIntensity", { airyIntensity, intensityAt, axialIntensity, + intensityProfile, }); diff --git a/src/common/view/IntensityProfileNode.ts b/src/common/view/IntensityProfileNode.ts new file mode 100644 index 0000000..cc4264e --- /dev/null +++ b/src/common/view/IntensityProfileNode.ts @@ -0,0 +1,347 @@ +/** + * IntensityProfileNode.ts + * + * Intensity along a horizontal cut through the centre of the detector. + * + * The detector image is photometric but it is not a measurement: the eye is bad + * at judging absolute brightness and worse at judging the ratio of two + * brightnesses. This plot is the same physics read off a slit detector scanned + * across the pattern, and in it the two numbers that matter become lengths on a + * page. The depth of the modulation *is* the fringe visibility, + * `(I_max − I_min)/(I_max + I_min)`; the number of ripples *is* the fringe count. + * + * It takes one trace per pattern, so a Michelson passes its single detector and + * a Mach-Zehnder passes both output ports at once. With two traces the optional + * dashed total is the point of the whole screen: the ports are in antiphase and + * their sum is flat, so interference is moving light about rather than + * destroying it. That claim is invisible in two separate images and obvious here. + */ + +import { + DerivedProperty, + Multilink, + StringProperty, + type TReadOnlyProperty, + type UnknownMultilink, +} from "scenerystack/axon"; +import { ChartRectangle, ChartTransform, GridLineSet, LinePlot } from "scenerystack/bamboo"; +import { Bounds2, Range, toFixed, Vector2 } from "scenerystack/dot"; +import { Orientation } from "scenerystack/phet-core"; +import { StringUtils } from "scenerystack/phetcommon"; +import { type Color, HBox, Line, Node, Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import InterferometryLabColors from "../../InterferometryLabColors.js"; +import { LABEL_FONT_SIZE, PANEL_CORNER_RADIUS } from "../../InterferometryLabConstants.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { FringeSpec } from "../model/FringeSpec.js"; +import { intensityProfile } from "../model/fringeIntensity.js"; + +/** + * Points sampled across the detector. Matched to the renderer's monochromatic + * grid so the trace and the image agree about how much detail there is; sampling + * finer would draw fringes the detector above it cannot resolve. + */ +const SAMPLE_COUNT = 240; + +/** + * Below this modulation the trace is called flat rather than fringed. A pattern + * this shallow is a single fringe filling the field, which is a real and + * commonly reached state — see `opdSpread` in the model. + */ +const FLAT_CONTRAST = 0.02; + +/** + * Bounds of the intensity axis. + * + * Both ends are padded past the values the physics can reach, because the traces + * this plot most needs to be readable are the flat ones — a dark port sitting at + * zero, a constant total sitting at one. Drawn hard against the frame those read + * as an empty box rather than as a result. + */ +const INTENSITY_MIN = -0.06; +const INTENSITY_MAX = 1.08; + +/** Spacing of the horizontal gridlines, in intensity. */ +const INTENSITY_GRID_SPACING = 0.5; + +/** + * Stands in for a screen-specific description suffix when a screen has none, so + * the derivation keeps a fixed dependency list. + */ +const NO_SUFFIX = new StringProperty(""); + +/** Detector coordinate of sample `index`, sampled at bin centres like the renderer. */ +function detectorU(index: number): number { + return (2 * (index + 0.5)) / SAMPLE_COUNT - 1; +} + +/** A sampled profile as chart points. */ +function toPoints(values: Float64Array): Vector2[] { + const points: Vector2[] = []; + values.forEach((value, index) => { + points.push(new Vector2(detectorU(index), value)); + }); + return points; +} + +export type IntensityTrace = { + /** The pattern this trace follows. */ + readonly specProperty: TReadOnlyProperty; + + /** Stroke colour of the trace. */ + readonly colorProperty: TReadOnlyProperty; + + /** Legend label. Omit on a single-trace plot, where a legend says nothing. */ + readonly label?: TReadOnlyProperty; +}; + +export type IntensityProfileNodeOptions = { + readonly width: number; + readonly height: number; + + /** Draws the dashed sum of every trace. Only meaningful with two or more. */ + readonly showSum?: boolean; + + /** Legend label for the sum. Required when `showSum` is set and traces are labelled. */ + readonly sumLabel?: TReadOnlyProperty; + + /** Appended to the screen-reader description, for anything screen-specific. */ + readonly descriptionSuffix?: TReadOnlyProperty; +}; + +export class IntensityProfileNode extends VBox { + private readonly multilink: UnknownMultilink; + + /** + * The screen-reader description. It links the spec Properties too, so it has to + * be disposed alongside the multilink or the node stays reachable from a model + * it no longer belongs to. + */ + private readonly description: TReadOnlyProperty; + + /** + * @param traces - at least one pattern to plot; a profile of nothing is meaningless, + * which the tuple type says rather than leaving to a runtime check + * @param options + */ + public constructor(traces: readonly [IntensityTrace, ...IntensityTrace[]], options: IntensityProfileNodeOptions) { + const strings = StringManager.getInstance(); + const common = strings.getCommon(); + + const chartTransform = new ChartTransform({ + viewWidth: options.width, + viewHeight: options.height, + // u runs edge to edge across the detector, exactly as the renderer uses it. + modelXRange: new Range(-1, 1), + modelYRange: new Range(INTENSITY_MIN, INTENSITY_MAX), + }); + + const chartRectangle = new ChartRectangle(chartTransform, { + fill: InterferometryLabColors.tableColorProperty, + stroke: InterferometryLabColors.tableBorderColorProperty, + cornerXRadius: PANEL_CORNER_RADIUS, + cornerYRadius: PANEL_CORNER_RADIUS, + }); + + // One entry per trace, each carrying its own plot and its own reusable + // sample buffer, so nothing downstream has to index parallel arrays. + const series = traces.map((trace) => ({ + specProperty: trace.specProperty, + plot: new LinePlot(chartTransform, [], { + stroke: trace.colorProperty, + lineWidth: 1.6, + }), + buffer: new Float64Array(SAMPLE_COUNT), + })); + + // Drawn under the traces: it is context for them, not a result of its own. + const sumPlot = options.showSum + ? new LinePlot(chartTransform, [], { + stroke: InterferometryLabColors.plotSumColorProperty, + lineWidth: 1.4, + lineDash: [5, 4], + }) + : null; + + const plots = series.map((entry) => entry.plot); + + // Gridlines at zero, half and full scale. Without them the reader has no way + // to tell a trace pinned at zero from a trace that is simply near the bottom. + const gridLines = new GridLineSet(chartTransform, Orientation.VERTICAL, INTENSITY_GRID_SPACING, { + stroke: InterferometryLabColors.plotAxisColorProperty, + lineWidth: 0.5, + }); + + const clipped = new Node({ + children: sumPlot ? [gridLines, sumPlot, ...plots] : [gridLines, ...plots], + clipArea: chartRectangle.getShape(), + }); + + const update = (): void => { + for (const entry of series) { + intensityProfile(entry.specProperty.value, SAMPLE_COUNT, entry.buffer); + entry.plot.setDataSet(toPoints(entry.buffer)); + } + + if (sumPlot) { + const totals: number[] = []; + for (const entry of series) { + entry.buffer.forEach((value, index) => { + totals[index] = (totals[index] ?? 0) + value; + }); + } + sumPlot.setDataSet(totals.map((total, index) => new Vector2(detectorU(index), total))); + } + }; + + const specProperties = traces.map((trace) => trace.specProperty); + // Multilink rather than one link per trace, so a change to either port + // redraws both curves and the sum in a single pass. + const multilink = Multilink.multilinkAny(specProperties, update); + + const chart = new Node({ + children: [chartRectangle, clipped], + localBounds: new Bounds2(0, 0, options.width, options.height), + }); + + const title = new Text(common.intensityProfileStringProperty, { + font: new PhetFont({ size: LABEL_FONT_SIZE, weight: "bold" }), + fill: InterferometryLabColors.textColorProperty, + maxWidth: options.width, + }); + + const axisLabel = new Text(common.detectorPositionStringProperty, { + font: new PhetFont(LABEL_FONT_SIZE - 1), + fill: InterferometryLabColors.plotAxisColorProperty, + maxWidth: options.width, + }); + + // The axis label and the legend share a row when both are present. They are + // both one line of small print about the same chart, and the vertical space + // a second row costs is space the control panels below need. + const legend = createLegend(traces, options); + const footer = legend ? new HBox({ spacing: 16, children: [axisLabel, legend] }) : axisLabel; + + const children: Node[] = [title, chart, footer]; + + const description = describeProfile(traces[0].specProperty, options.descriptionSuffix); + + super({ + spacing: 5, + align: "center", + children, + accessibleParagraph: description, + }); + + this.multilink = multilink; + this.description = description; + } + + public override dispose(): void { + super.dispose(); + this.multilink.dispose(); + this.description.dispose(); + } +} + +/** + * A row of coloured swatches naming each trace. Returns null when the traces are + * unlabelled — a legend for one curve is noise. + */ +function createLegend(traces: readonly IntensityTrace[], options: IntensityProfileNodeOptions): Node | null { + const entries: { readonly label: TReadOnlyProperty; readonly color: TReadOnlyProperty }[] = []; + + for (const trace of traces) { + if (trace.label) { + entries.push({ label: trace.label, color: trace.colorProperty }); + } + } + if (options.showSum && options.sumLabel) { + entries.push({ label: options.sumLabel, color: InterferometryLabColors.plotSumColorProperty }); + } + if (entries.length === 0) { + return null; + } + + return new HBox({ + spacing: 12, + children: entries.map( + (entry) => + new HBox({ + spacing: 4, + children: [ + new Line(0, 0, 14, 0, { stroke: entry.color, lineWidth: 2.5 }), + new Text(entry.label, { + font: new PhetFont(LABEL_FONT_SIZE - 1), + fill: InterferometryLabColors.textColorProperty, + }), + ], + }), + ), + }); +} + +/** + * What the trace looks like, in words: how many bright fringes cross the field + * and how deep the modulation is. Derived from the same samples the plot draws, + * so the two never disagree. + */ +function describeProfile( + specProperty: TReadOnlyProperty, + suffix?: TReadOnlyProperty, +): TReadOnlyProperty { + const a11y = StringManager.getInstance().getCommonA11yStrings(); + const units = StringManager.getInstance().getUnits(); + + return new DerivedProperty( + [ + specProperty, + a11y.intensityProfileFringesStringProperty, + a11y.intensityProfileFlatStringProperty, + units.percentStringProperty, + suffix ?? NO_SUFFIX, + ], + (spec, fringesPattern, flatText, percentPattern, suffixText) => { + const values = intensityProfile(spec, SAMPLE_COUNT); + + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const value of values) { + min = Math.min(min, value); + max = Math.max(max, value); + } + const contrast = max + min > 0 ? (max - min) / (max + min) : 0; + + if (contrast < FLAT_CONTRAST) { + return flatText; + } + + // Count the peaks the trace actually draws, by walking it and marking each + // crossing back down through the midpoint. A midpoint threshold keeps + // sampling ripple in a nearly flat trace from being counted as fringes, + // and counting crossings rather than local maxima is immune to a plateau + // at the top of a broad fringe being counted twice. + const threshold = (max + min) / 2; + let peaks = 0; + let above = false; + for (const value of values) { + if (value > threshold) { + above = true; + } else if (above) { + above = false; + peaks++; + } + } + // A fringe still above the midpoint when the trace runs off the edge. + if (above) { + peaks++; + } + + const sentence = StringUtils.fillIn(fringesPattern, { + count: peaks.toString(), + contrast: StringUtils.fillIn(percentPattern, { value: toFixed(100 * contrast, 0) }), + }); + return sentence + suffixText; + }, + ); +} diff --git a/src/common/view/controlFactory.ts b/src/common/view/controlFactory.ts index 9eac12d..2c74918 100644 --- a/src/common/view/controlFactory.ts +++ b/src/common/view/controlFactory.ts @@ -9,13 +9,17 @@ * review checklist. */ -import type { PhetioProperty, TReadOnlyProperty } from "scenerystack/axon"; +import type { PhetioProperty, Property, TReadOnlyProperty } from "scenerystack/axon"; import { Text } from "scenerystack/scenery"; -import { PhetFont } from "scenerystack/scenery-phet"; +import { PhetFont, TimeControlNode } from "scenerystack/scenery-phet"; import { Checkbox, RectangularPushButton } from "scenerystack/sun"; import InterferometryLabColors from "../../InterferometryLabColors.js"; import { LABEL_FONT_SIZE } from "../../InterferometryLabConstants.js"; -import { FLAT_RECTANGULAR_BUTTON_OPTIONS, LIGHT_SURFACE_TEXT_FILL } from "../InterferometryLabButtonOptions.js"; +import { + FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS, + FLAT_RECTANGULAR_BUTTON_OPTIONS, + LIGHT_SURFACE_TEXT_FILL, +} from "../InterferometryLabButtonOptions.js"; /** * A checkbox with a text label, themed for the sim's panels. @@ -75,3 +79,38 @@ export function createPushButton( yMargin: 5, }); } + +/** + * Play / pause with a step-forward button, themed flat like the rest of the sim. + * + * Both of the sim's time-evolving screens want the same thing: the ability to + * stop what the clock is doing and then advance it in single frames — one + * frame's worth of photons, or one three-hundredth of a cavity sweep. Neither + * screen wants a speed selector, because the step button already gives finer + * control than a slow speed would. + * + * `TimeControlNode` supplies its own accessible heading; `accessibleHeading` + * here replaces the generic "Time Controls" with what this particular clock + * drives. + * + * @param isPlayingProperty - the clock's run state + * @param stepOnce - advances the model by one frame while paused + * @param accessibleHeading - names what the clock controls, for a screen reader + */ +export function createTimeControl( + isPlayingProperty: Property, + stepOnce: () => void, + accessibleHeading: TReadOnlyProperty, +): TimeControlNode { + return new TimeControlNode(isPlayingProperty, { + accessibleHeading, + playPauseStepButtonOptions: { + ...FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS, + includeStepForwardButton: true, + stepForwardButtonOptions: { + ...FLAT_PLAY_PAUSE_STEP_BUTTON_OPTIONS.stepForwardButtonOptions, + listener: stepOnce, + }, + }, + }); +} diff --git a/src/common/view/formatters.ts b/src/common/view/formatters.ts index ded4c74..b85f6ca 100644 --- a/src/common/view/formatters.ts +++ b/src/common/view/formatters.ts @@ -100,6 +100,52 @@ export function percentProperty(fractionProperty: TReadOnlyProperty, dec ); } +/** + * Formats a path difference as a number of wavelengths. + * + * A path difference in micrometres is a length; in wavelengths it is a fringe + * count, and that is the form in which it answers the question the screen is + * actually about. It is also the conversion students most often get wrong, + * because for a Michelson the mirror has only moved half as far. + * + * @param nanometersProperty - the path difference, nm + * @param wavelengthProperty - the wavelength to divide by, nm + * @param decimals - digits after the decimal point + */ +export function wavesProperty( + nanometersProperty: TReadOnlyProperty, + wavelengthProperty: TReadOnlyProperty, + decimals = 1, +): TReadOnlyProperty { + const units = StringManager.getInstance().getUnits(); + + return new DerivedProperty( + [nanometersProperty, wavelengthProperty, units.wavesStringProperty], + (nanometers, wavelengthNm, pattern) => + StringUtils.fillIn(pattern, { + value: toFixed(wavelengthNm > 0 ? nanometers / wavelengthNm : 0, decimals), + }), + ); +} + +/** + * Formats an optical length as a labelled path-difference contribution, "Δ 29.3 µm". + * + * Used for the labels drawn on the optical table, where the element the number + * belongs to is right beside it and only the quantity needs naming. + */ +export function pathDeltaProperty( + nanometersProperty: TReadOnlyProperty, + decimals = 1, +): TReadOnlyProperty { + const units = StringManager.getInstance().getUnits(); + const length = lengthProperty(nanometersProperty, decimals); + + return new DerivedProperty([length, units.pathDeltaStringProperty], (value, pattern) => + StringUtils.fillIn(pattern, { value }), + ); +} + /** * Formats an integer count with no unit. */ diff --git a/src/fabry-perot/FabryPerotScreen.ts b/src/fabry-perot/FabryPerotScreen.ts index a8b2d7f..d8f0eac 100644 --- a/src/fabry-perot/FabryPerotScreen.ts +++ b/src/fabry-perot/FabryPerotScreen.ts @@ -15,6 +15,7 @@ import { Screen } from "scenerystack/sim"; import type { Tandem } from "scenerystack/tandem"; import { createFabryPerotIcon } from "../common/InterferometryLabScreenIcons.js"; import InterferometryLabColors from "../InterferometryLabColors.js"; +import type { InterferometryLabPreferencesModel } from "../preferences/InterferometryLabPreferencesModel.js"; import { FabryPerotModel } from "./model/FabryPerotModel.js"; import { FabryPerotKeyboardHelpContent } from "./view/FabryPerotKeyboardHelpContent.js"; import { FabryPerotScreenView } from "./view/FabryPerotScreenView.js"; @@ -23,13 +24,18 @@ import { FabryPerotScreenView } from "./view/FabryPerotScreenView.js"; type FabryPerotScreenOptions = ScreenOptions & { tandem: Tandem }; export class FabryPerotScreen extends Screen { - public constructor(options: FabryPerotScreenOptions) { + /** + * @param preferences - simulation preferences the view reads; the optical-path + * labels are a preference, so the view needs to see them + * @param options + */ + public constructor(preferences: InterferometryLabPreferencesModel, options: FabryPerotScreenOptions) { super( // Model factory — called once when the screen is first shown () => new FabryPerotModel(), // View factory — receives the model instance (model) => - new FabryPerotScreenView(model, { + new FabryPerotScreenView(model, preferences, { tandem: options.tandem.createTandem("view"), }), optionize()( diff --git a/src/fabry-perot/model/FabryPerotModel.ts b/src/fabry-perot/model/FabryPerotModel.ts index 1391fb8..dda9cfb 100644 --- a/src/fabry-perot/model/FabryPerotModel.ts +++ b/src/fabry-perot/model/FabryPerotModel.ts @@ -35,6 +35,7 @@ import type { TModel } from "scenerystack/joist"; import { type FringeSpec, toFringeGroups } from "../../common/model/FringeSpec.js"; import { airyPeakTransmission, reflectiveFinesse } from "../../common/model/fringeIntensity.js"; import type { SpectralGroup } from "../../common/model/spectrum.js"; +import { TimeModel } from "../../common/TimeModel.js"; import { ABSORPTANCE_RANGE, CAVITY_SPACING_RANGE_UM, @@ -63,6 +64,9 @@ const SCAN_AMPLITUDE_WAVES = 1.5; /** Scan period, seconds. */ const SCAN_PERIOD_S = 6; +/** One frame's worth of sweep, seconds — what the step-forward button advances. */ +const MANUAL_STEP_DT = 1 / 60; + /** * Starting spacing, nm: exactly 340 half-waves of the default 589 nm line. * @@ -95,8 +99,13 @@ export class FabryPerotModel implements TModel { /** Nominal mirror spacing, nm. */ public readonly spacingProperty: NumberProperty; - /** Whether the spacing is being swept, as a scanning etalon does. */ - public readonly scanningProperty: BooleanProperty; + /** + * The scan clock. Playing sweeps the spacing the way a scanning etalon's piezo + * does; paused, the cavity holds still. Being able to stop on a transmission + * peak, and to creep up on one a frame at a time, is what makes the sweep + * something to read rather than something to watch go past. + */ + public readonly timer = new TimeModel(); /** Sweep offset added to the spacing while scanning, nm. */ public readonly scanOffsetProperty: NumberProperty; @@ -104,6 +113,13 @@ export class FabryPerotModel implements TModel { /** Spacing actually in effect, nm. */ public readonly effectiveSpacingProperty: TReadOnlyProperty; + /** + * Optical path of one round trip inside the cavity, 2nd (nm) — the path + * difference between consecutive emerging beams, and the quantity the order + * and the free spectral range are both built from. + */ + public readonly roundTripPathProperty: TReadOnlyProperty; + /** The source's spectral lines. */ public readonly linesProperty: TReadOnlyProperty; @@ -131,9 +147,6 @@ export class FabryPerotModel implements TModel { /** The ring pattern on the detector. */ public readonly fringeSpecProperty: TReadOnlyProperty; - /** Elapsed scan time, seconds. */ - private scanTime = 0; - public constructor() { this.wavelengthProperty = new NumberProperty(589, { range: WAVELENGTH_RANGE_NM, units: "nm" }); this.twinLineProperty = new BooleanProperty(false); @@ -146,7 +159,6 @@ export class FabryPerotModel implements TModel { units: "nm", }); - this.scanningProperty = new BooleanProperty(false); this.scanOffsetProperty = new NumberProperty(0, { units: "nm" }); this.effectiveSpacingProperty = new DerivedProperty( @@ -154,6 +166,11 @@ export class FabryPerotModel implements TModel { (spacing, offset) => spacing + offset, ); + this.roundTripPathProperty = new DerivedProperty( + [this.effectiveSpacingProperty], + (spacing) => 2 * CAVITY_INDEX * spacing, + ); + this.linesProperty = new DerivedProperty( [this.wavelengthProperty, this.twinLineProperty, this.lineSeparationProperty], (wavelengthNm, twinLine, separationPm): readonly SpectralGroup[] => { @@ -246,23 +263,33 @@ export class FabryPerotModel implements TModel { this.reflectanceProperty.reset(); this.absorptanceProperty.reset(); this.spacingProperty.reset(); - this.scanningProperty.reset(); this.scanOffsetProperty.reset(); - this.scanTime = 0; + this.timer.reset(); } /** - * Sweeps the spacing when scanning is on. A real scanning Fabry-Pérot pushes - * one mirror with a piezo through a wavelength or two, so the transmission - * peaks march across the source's spectrum and the rings collapse into the - * centre — the trace this produces *is* the measured spectrum. + * Sweeps the spacing while the scan clock is running. A real scanning + * Fabry-Pérot pushes one mirror with a piezo through a wavelength or two, so + * the transmission peaks march across the source's spectrum and the rings + * collapse into the centre — the trace this produces *is* the measured + * spectrum. + * + * The clock only advances while playing, so a paused cavity holds the spacing + * it stopped at rather than snapping back. */ public step(dt: number): void { - if (!this.scanningProperty.value) { - return; - } - this.scanTime += dt; - const phase = (2 * Math.PI * this.scanTime) / SCAN_PERIOD_S; + this.timer.step(dt); + this.updateScanOffset(); + } + + /** Advances the sweep by one frame regardless of the clock — the step button. */ + public stepOnce(): void { + this.timer.timeProperty.value += MANUAL_STEP_DT; + this.updateScanOffset(); + } + + private updateScanOffset(): void { + const phase = (2 * Math.PI * this.timer.timeProperty.value) / SCAN_PERIOD_S; this.scanOffsetProperty.value = SCAN_AMPLITUDE_WAVES * this.wavelengthProperty.value * 0.5 * Math.sin(phase); } } diff --git a/src/fabry-perot/view/FabryPerotCavityPanel.ts b/src/fabry-perot/view/FabryPerotCavityPanel.ts index 82f006b..655eef1 100644 --- a/src/fabry-perot/view/FabryPerotCavityPanel.ts +++ b/src/fabry-perot/view/FabryPerotCavityPanel.ts @@ -14,10 +14,13 @@ */ import { UnitConversionProperty } from "scenerystack/axon"; -import { createCheckbox } from "../../common/view/controlFactory.js"; +import { Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { createTimeControl } from "../../common/view/controlFactory.js"; import { InterferometryLabNumberControl } from "../../common/view/InterferometryLabNumberControl.js"; import { TitledPanel } from "../../common/view/TitledPanel.js"; -import { ABSORPTANCE_RANGE, NM_PER_UM, REFLECTANCE_RANGE } from "../../InterferometryLabConstants.js"; +import InterferometryLabColors from "../../InterferometryLabColors.js"; +import { ABSORPTANCE_RANGE, LABEL_FONT_SIZE, NM_PER_UM, REFLECTANCE_RANGE } from "../../InterferometryLabConstants.js"; import { StringManager } from "../../i18n/StringManager.js"; import type { FabryPerotModel } from "../model/FabryPerotModel.js"; @@ -81,13 +84,24 @@ export class FabryPerotCavityPanel extends TitledPanel { }, ); - const scanCheckbox = createCheckbox( - model.scanningProperty, - fabryPerot.scanCavityStringProperty, - a11y.scanCavityStringProperty, - ); + // The sweep is a clock, not a setting, so it gets a clock's controls. Being + // able to stop it is the point: a transmission peak is narrow at high + // finesse and goes past in a fraction of a second, and the step button walks + // onto one a frame at a time. + const scanControl = new VBox({ + align: "left", + spacing: 4, + children: [ + new Text(fabryPerot.scanCavityStringProperty, { + font: new PhetFont(LABEL_FONT_SIZE), + fill: InterferometryLabColors.textColorProperty, + maxWidth: contentWidth, + }), + createTimeControl(model.timer.isPlayingProperty, () => model.stepOnce(), a11y.scanCavityStringProperty), + ], + }); - super(fabryPerot.cavityStringProperty, [reflectanceControl, absorptanceControl, spacingControl, scanCheckbox], { + super(fabryPerot.cavityStringProperty, [reflectanceControl, absorptanceControl, spacingControl, scanControl], { contentWidth, }); } diff --git a/src/fabry-perot/view/FabryPerotScreenView.ts b/src/fabry-perot/view/FabryPerotScreenView.ts index ac58b19..813cdff 100644 --- a/src/fabry-perot/view/FabryPerotScreenView.ts +++ b/src/fabry-perot/view/FabryPerotScreenView.ts @@ -21,6 +21,7 @@ import { ReadoutBlock } from "../../common/view/ReadoutBlock.js"; import { TitledPanel } from "../../common/view/TitledPanel.js"; import { CONTROL_PANEL_WIDTH, PANEL_SPACING, SCREEN_VIEW_MARGIN } from "../../InterferometryLabConstants.js"; import { StringManager } from "../../i18n/StringManager.js"; +import type { InterferometryLabPreferencesModel } from "../../preferences/InterferometryLabPreferencesModel.js"; import type { FabryPerotModel } from "../model/FabryPerotModel.js"; import { FabryPerotCavityPanel } from "./FabryPerotCavityPanel.js"; import { FabryPerotScreenSummaryContent } from "./FabryPerotScreenSummaryContent.js"; @@ -41,7 +42,11 @@ const PLOT_WIDTH = 370; const PLOT_HEIGHT = 196; export class FabryPerotScreenView extends ScreenView { - public constructor(model: FabryPerotModel, providedOptions?: FabryPerotScreenViewOptions) { + public constructor( + model: FabryPerotModel, + preferences: InterferometryLabPreferencesModel, + providedOptions?: FabryPerotScreenViewOptions, + ) { const options = optionize()( { screenSummaryContent: new FabryPerotScreenSummaryContent(model) }, providedOptions, @@ -53,7 +58,7 @@ export class FabryPerotScreenView extends ScreenView { const fabryPerot = strings.getFabryPerotStrings(); const units = strings.getUnits(); - const tableNode = new FabryPerotTableNode(model); + const tableNode = new FabryPerotTableNode(model, preferences); const detectorNode = new DetectorScreenNode(model.fringeSpecProperty, { size: RING_SIZE, diff --git a/src/fabry-perot/view/FabryPerotTableNode.ts b/src/fabry-perot/view/FabryPerotTableNode.ts index 6782665..20e2ddd 100644 --- a/src/fabry-perot/view/FabryPerotTableNode.ts +++ b/src/fabry-perot/view/FabryPerotTableNode.ts @@ -14,6 +14,7 @@ import { DerivedProperty } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { Node } from "scenerystack/scenery"; import { BeamPathNode } from "../../common/view/BeamPathNode.js"; +import { pathDeltaProperty } from "../../common/view/formatters.js"; import { OpticalTableNode } from "../../common/view/OpticalTableNode.js"; import { createDetectorPlateNode, @@ -24,6 +25,7 @@ import { } from "../../common/view/opticNodes.js"; import { sourceColorProperty } from "../../common/view/sourceColor.js"; import { StringManager } from "../../i18n/StringManager.js"; +import type { InterferometryLabPreferencesModel } from "../../preferences/InterferometryLabPreferencesModel.js"; import type { FabryPerotModel } from "../model/FabryPerotModel.js"; /** Table size, view pixels. */ @@ -48,7 +50,7 @@ const DRAWN_BOUNCES = 4; const BOUNCE_STEP = 9; export class FabryPerotTableNode extends Node { - public constructor(model: FabryPerotModel) { + public constructor(model: FabryPerotModel, preferences: InterferometryLabPreferencesModel) { super(); const strings = StringManager.getInstance(); @@ -115,6 +117,14 @@ export class FabryPerotTableNode extends Node { cavityLabel.centerX = (FIRST_MIRROR_X + SECOND_MIRROR_X) / 2; cavityLabel.top = AXIS_Y + 44; + // The cavity's contribution is the round trip 2nd, not the spacing d — which + // is exactly the distinction the order m = 2nd/λ readout depends on, and the + // one that is easiest to lose when reading the spacing slider. + const cavityPathLabel = createTableLabel(pathDeltaProperty(model.roundTripPathProperty, 1)); + cavityPathLabel.centerX = (FIRST_MIRROR_X + SECOND_MIRROR_X) / 2; + cavityPathLabel.top = AXIS_Y + 60; + cavityPathLabel.visibleProperty = preferences.showOpticalPathProperty; + const screenLabel = createTableLabel(common.screenStringProperty); screenLabel.centerX = SCREEN_X - 6; screenLabel.top = AXIS_Y + 44; @@ -129,6 +139,7 @@ export class FabryPerotTableNode extends Node { screen, sourceLabel, cavityLabel, + cavityPathLabel, screenLabel, ]; } diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index 616c355..e70f3c2 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -113,6 +113,11 @@ export class StringManager { return stringProperties.fabryPerot; } + /** Accessibility strings for view components shared by more than one screen. */ + public getCommonA11yStrings() { + return stringProperties.a11y.common; + } + /** Accessibility strings for the Michelson screen. */ public getMichelsonA11yStrings() { return stringProperties.a11y.michelson; diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 181496a..1d4b01e 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -18,8 +18,11 @@ "bandwidth": "Bandwidth", "coherenceLength": "Coherence length", "pathDifference": "Path difference", + "inWavelengths": "In wavelengths", "visibility": "Fringe visibility", "intensity": "Intensity", + "intensityProfile": "Intensity across the detector", + "detectorPosition": "Position on the detector", "detector": "Detector", "readings": "Readings", "laser": "Laser", @@ -41,7 +44,8 @@ "percent": "{{value}}%", "perSecond": "{{value}}/s", "plain": "{{value}}", - "waves": "{{value}} λ" + "waves": "{{value}} λ", + "pathDelta": "Δ {{value}}" }, "michelson": { "arm": "Movable Mirror", @@ -58,12 +62,15 @@ "insertCell": "Insert the gas cell", "pressure": "Pressure", "indexOfRefraction": "Index of refraction", - "fringesShifted": "Fringes shifted" + "fringesShifted": "Fringes shifted", + "visibilityCurve": "Visibility vs path difference" }, "machZehnder": { "pathImbalance": "Path imbalance", "arms": "Arms", - "tilt": "Tilt M₂", + "tiltHorizontal": "Tilt M₂ about the vertical axis", + "tiltVertical": "Tilt M₂ about the horizontal axis", + "sumOfPorts": "A + B", "sample": "Sample", "insertSample": "Insert the sample slide", "thickness": "Thickness", @@ -101,6 +108,10 @@ "unresolved": "The two lines are not resolved" }, "a11y": { + "common": { + "intensityProfileFringes": "A trace of intensity across the middle of the detector: {{count}} bright fringes, at {{contrast}} contrast.", + "intensityProfileFlat": "A trace of intensity across the middle of the detector. It is flat — the whole field sits on a single fringe." + }, "michelson": { "screenSummary": { "playArea": "A Michelson interferometer on an optical table. Light from the source is split in two by a beam splitter, sent to a movable mirror and a fixed mirror, and recombined onto a detector screen where it forms fringes.", @@ -112,6 +123,7 @@ "patternStraight": "straight fringes", "patternUniform": "a single fringe filling the whole field", "patternWashedOut": "no fringes, only uniform light", + "visibilityCurve": "A curve of fringe visibility against optical path difference. The visibility is {{visibility}} at the current path difference of {{pathDifference}}, and the source's coherence length is {{coherenceLength}}.", "controls": { "sourcePicker": "Light source", "wavelength": "Source wavelength", @@ -137,6 +149,7 @@ "classicalDetail": "The beam is continuous.", "photonDetail": "{{emitted}} photons have been emitted; {{countsA}} landed at port A and {{countsB}} at port B.", "whichPathDetail": "The which-path marker is on, so the two paths no longer interfere.", + "profileComplementary": " The two ports are in antiphase, and their sum is the same everywhere.", "controls": { "sourcePicker": "Light source", "wavelength": "Source wavelength", @@ -177,7 +190,7 @@ }, "preferences": { "title": "Simulation", - "showOpticalPath": "Show optical path lengths", - "showOpticalPathDescription": "Label each beam segment on the table with the optical path length it accumulates." + "showOpticalPath": "Show path-difference contributions", + "showOpticalPathDescription": "Label each optical element on the table with how much it adds to the optical path difference." } } diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json index e9107eb..00e5f49 100644 --- a/src/i18n/strings_es.json +++ b/src/i18n/strings_es.json @@ -18,8 +18,11 @@ "bandwidth": "Ancho de banda", "coherenceLength": "Longitud de coherencia", "pathDifference": "Diferencia de camino", + "inWavelengths": "En longitudes de onda", "visibility": "Visibilidad de las franjas", "intensity": "Intensidad", + "intensityProfile": "Intensidad en el detector", + "detectorPosition": "Posición en el detector", "detector": "Detector", "readings": "Lecturas", "laser": "Láser", @@ -41,7 +44,8 @@ "percent": "{{value}} %", "perSecond": "{{value}}/s", "plain": "{{value}}", - "waves": "{{value}} λ" + "waves": "{{value}} λ", + "pathDelta": "Δ {{value}}" }, "michelson": { "arm": "Espejo móvil", @@ -58,12 +62,15 @@ "insertCell": "Insertar la celda de gas", "pressure": "Presión", "indexOfRefraction": "Índice de refracción", - "fringesShifted": "Franjas desplazadas" + "fringesShifted": "Franjas desplazadas", + "visibilityCurve": "Visibilidad frente a diferencia de camino" }, "machZehnder": { "pathImbalance": "Desequilibrio de camino", "arms": "Brazos", - "tilt": "Inclinar M₂", + "tiltHorizontal": "Inclinar M₂ alrededor del eje vertical", + "tiltVertical": "Inclinar M₂ alrededor del eje horizontal", + "sumOfPorts": "A + B", "sample": "Muestra", "insertSample": "Insertar la lámina de muestra", "thickness": "Espesor", @@ -101,6 +108,10 @@ "unresolved": "Las dos líneas no están resueltas" }, "a11y": { + "common": { + "intensityProfileFringes": "Un trazo de la intensidad por el centro del detector: {{count}} franjas brillantes, con un contraste de {{contrast}}.", + "intensityProfileFlat": "Un trazo de la intensidad por el centro del detector. Es plano: todo el campo está sobre una sola franja." + }, "michelson": { "screenSummary": { "playArea": "Un interferómetro de Michelson sobre una mesa óptica. Un divisor de haz separa en dos la luz de la fuente, la envía a un espejo móvil y a un espejo fijo, y la recombina sobre una pantalla detectora donde se forman las franjas.", @@ -112,6 +123,7 @@ "patternStraight": "franjas rectas", "patternUniform": "una sola franja que llena todo el campo", "patternWashedOut": "ninguna franja, solo luz uniforme", + "visibilityCurve": "Una curva de la visibilidad de las franjas frente a la diferencia de camino óptico. La visibilidad es de {{visibility}} en la diferencia de camino actual de {{pathDifference}}, y la longitud de coherencia de la fuente es de {{coherenceLength}}.", "controls": { "sourcePicker": "Fuente de luz", "wavelength": "Longitud de onda de la fuente", @@ -137,6 +149,7 @@ "classicalDetail": "El haz es continuo.", "photonDetail": "Se han emitido {{emitted}} fotones; {{countsA}} llegaron al puerto A y {{countsB}} al puerto B.", "whichPathDetail": "El marcador de camino está activado, así que los dos caminos ya no interfieren.", + "profileComplementary": " Los dos puertos están en oposición de fase, y su suma es la misma en todas partes.", "controls": { "sourcePicker": "Fuente de luz", "wavelength": "Longitud de onda de la fuente", @@ -177,7 +190,7 @@ }, "preferences": { "title": "Simulación", - "showOpticalPath": "Mostrar los caminos ópticos", - "showOpticalPathDescription": "Etiquetar cada tramo del haz en la mesa con el camino óptico que acumula." + "showOpticalPath": "Mostrar las contribuciones a la diferencia de camino", + "showOpticalPathDescription": "Etiquetar cada elemento óptico de la mesa con lo que añade a la diferencia de camino." } } diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 415f8e1..a612494 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -18,8 +18,11 @@ "bandwidth": "Largeur spectrale", "coherenceLength": "Longueur de cohérence", "pathDifference": "Différence de marche", + "inWavelengths": "En longueurs d'onde", "visibility": "Visibilité des franges", "intensity": "Intensité", + "intensityProfile": "Intensité sur le détecteur", + "detectorPosition": "Position sur le détecteur", "detector": "Détecteur", "readings": "Mesures", "laser": "Laser", @@ -41,7 +44,8 @@ "percent": "{{value}} %", "perSecond": "{{value}}/s", "plain": "{{value}}", - "waves": "{{value}} λ" + "waves": "{{value}} λ", + "pathDelta": "Δ {{value}}" }, "michelson": { "arm": "Miroir mobile", @@ -58,12 +62,15 @@ "insertCell": "Insérer la cuve à gaz", "pressure": "Pression", "indexOfRefraction": "Indice de réfraction", - "fringesShifted": "Franges défilées" + "fringesShifted": "Franges défilées", + "visibilityCurve": "Visibilité selon la différence de marche" }, "machZehnder": { "pathImbalance": "Déséquilibre des bras", "arms": "Bras", - "tilt": "Incliner M₂", + "tiltHorizontal": "Incliner M₂ autour de l'axe vertical", + "tiltVertical": "Incliner M₂ autour de l'axe horizontal", + "sumOfPorts": "A + B", "sample": "Échantillon", "insertSample": "Insérer la lame échantillon", "thickness": "Épaisseur", @@ -101,6 +108,10 @@ "unresolved": "Les deux raies ne sont pas résolues" }, "a11y": { + "common": { + "intensityProfileFringes": "Un tracé de l'intensité au milieu du détecteur : {{count}} franges brillantes, avec un contraste de {{contrast}}.", + "intensityProfileFlat": "Un tracé de l'intensité au milieu du détecteur. Il est plat — tout le champ repose sur une seule frange." + }, "michelson": { "screenSummary": { "playArea": "Un interféromètre de Michelson sur une table optique. Une séparatrice divise en deux la lumière de la source, l'envoie vers un miroir mobile et un miroir fixe, puis la recombine sur un écran détecteur où se forment les franges.", @@ -112,6 +123,7 @@ "patternStraight": "des franges rectilignes", "patternUniform": "une seule frange qui remplit tout le champ", "patternWashedOut": "aucune frange, seulement une lumière uniforme", + "visibilityCurve": "Une courbe de la visibilité des franges en fonction de la différence de marche. La visibilité est de {{visibility}} à la différence de marche actuelle de {{pathDifference}}, et la longueur de cohérence de la source est de {{coherenceLength}}.", "controls": { "sourcePicker": "Source lumineuse", "wavelength": "Longueur d'onde de la source", @@ -137,6 +149,7 @@ "classicalDetail": "Le faisceau est continu.", "photonDetail": "{{emitted}} photons ont été émis ; {{countsA}} sont arrivés au port A et {{countsB}} au port B.", "whichPathDetail": "Le marqueur de chemin est activé, donc les deux chemins n'interfèrent plus.", + "profileComplementary": " Les deux ports sont en opposition de phase, et leur somme est partout la même.", "controls": { "sourcePicker": "Source lumineuse", "wavelength": "Longueur d'onde de la source", @@ -177,7 +190,7 @@ }, "preferences": { "title": "Simulation", - "showOpticalPath": "Afficher les chemins optiques", - "showOpticalPathDescription": "Indiquer sur chaque tronçon de faisceau le chemin optique qu'il accumule." + "showOpticalPath": "Afficher les contributions à la différence de marche", + "showOpticalPathDescription": "Indiquer sur chaque élément optique de la table ce qu'il ajoute à la différence de marche." } } diff --git a/src/mach-zehnder/MachZehnderScreen.ts b/src/mach-zehnder/MachZehnderScreen.ts index 959ce35..59da9ef 100644 --- a/src/mach-zehnder/MachZehnderScreen.ts +++ b/src/mach-zehnder/MachZehnderScreen.ts @@ -15,6 +15,7 @@ import { Screen } from "scenerystack/sim"; import type { Tandem } from "scenerystack/tandem"; import { createMachZehnderIcon } from "../common/InterferometryLabScreenIcons.js"; import InterferometryLabColors from "../InterferometryLabColors.js"; +import type { InterferometryLabPreferencesModel } from "../preferences/InterferometryLabPreferencesModel.js"; import { MachZehnderModel } from "./model/MachZehnderModel.js"; import { MachZehnderKeyboardHelpContent } from "./view/MachZehnderKeyboardHelpContent.js"; import { MachZehnderScreenView } from "./view/MachZehnderScreenView.js"; @@ -23,13 +24,18 @@ import { MachZehnderScreenView } from "./view/MachZehnderScreenView.js"; type MachZehnderScreenOptions = ScreenOptions & { tandem: Tandem }; export class MachZehnderScreen extends Screen { - public constructor(options: MachZehnderScreenOptions) { + /** + * @param preferences - simulation preferences the view reads; the optical-path + * labels are a preference, so the view needs to see them + * @param options + */ + public constructor(preferences: InterferometryLabPreferencesModel, options: MachZehnderScreenOptions) { super( // Model factory — called once when the screen is first shown () => new MachZehnderModel(), // View factory — receives the model instance (model) => - new MachZehnderScreenView(model, { + new MachZehnderScreenView(model, preferences, { tandem: options.tandem.createTandem("view"), }), optionize()( diff --git a/src/mach-zehnder/model/MachZehnderModel.ts b/src/mach-zehnder/model/MachZehnderModel.ts index 5d20424..0cdc9d7 100644 --- a/src/mach-zehnder/model/MachZehnderModel.ts +++ b/src/mach-zehnder/model/MachZehnderModel.ts @@ -41,6 +41,7 @@ import { LightSourceModel } from "../../common/model/LightSourceModel.js"; import { plateOpticalPathDelta } from "../../common/model/refractiveIndex.js"; import { SourceType } from "../../common/model/SourceType.js"; import { spectrumVisibility } from "../../common/model/spectrum.js"; +import { TimeModel } from "../../common/TimeModel.js"; import { BEAM_HALF_WIDTH_NM, DETECTOR_FOCAL_LENGTH_NM, @@ -63,6 +64,9 @@ const ROUTE_INTENSITY = 0.5; /** Two equal routes peak at 2; halving puts a bright fringe at full scale. */ const EXPOSURE = 0.5; +/** One frame's worth of emission, seconds — what the step-forward button advances. */ +const MANUAL_STEP_DT = 1 / 60; + /** * A photon landing on a detector: where it hit, in detector coordinates. */ @@ -117,6 +121,15 @@ export class MachZehnderModel implements TModel { /** Bumped whenever a photon is added, so the view knows to repaint. */ public readonly photonRevisionProperty: NumberProperty; + /** + * The emission clock. Single photons arrive far too fast to watch: at the + * default rate the pattern is drawn in a couple of seconds, and "one photon at + * a time" is a claim about a process nobody gets to see happen. Pausing, and + * stepping a frame at a time, is what turns the accumulation into something + * that can be examined while it is still sparse. + */ + public readonly timer = new TimeModel(true); + /** Optical path added by the sample slide, nm. */ public readonly samplePathProperty: TReadOnlyProperty; @@ -126,6 +139,9 @@ export class MachZehnderModel implements TModel { /** Fringe contrast, 0 when the which-path marker is on. */ public readonly contrastProperty: TReadOnlyProperty; + /** Fringe visibility at the current path difference, 0–1. */ + public readonly visibilityProperty: TReadOnlyProperty; + /** Patterns at the two output ports. */ public readonly portASpecProperty: TReadOnlyProperty; public readonly portBSpecProperty: TReadOnlyProperty; @@ -189,6 +205,14 @@ export class MachZehnderModel implements TModel { this.contrastProperty = new DerivedProperty([this.whichPathProperty], (whichPath) => (whichPath ? 0 : 1)); + // Two separate losses of contrast multiply here: the which-path marker, + // which destroys interference outright, and the source's own coherence + // envelope, which fades it as the arms are pulled apart. + this.visibilityProperty = new DerivedProperty( + [this.contrastProperty, this.lightSource.spectrumProperty, this.pathDifferenceProperty], + (contrast, spectrum, pathDifference) => contrast * spectrumVisibility(spectrum.groups, pathDifference), + ); + const geometryProperty = new DerivedProperty( [this.pathDifferenceProperty, this.tiltHorizontalProperty, this.tiltVerticalProperty], (pathDifference, tiltHorizontal, tiltVertical) => ({ @@ -228,17 +252,6 @@ export class MachZehnderModel implements TModel { this.portBFractionProperty = new DerivedProperty([this.portBSpecProperty], (spec) => portFraction(spec)); } - /** - * Fringe visibility of the current source at the current path difference. - * Exposed for the readouts; the renderer computes it per pixel. - */ - public get visibility(): number { - return ( - this.contrastProperty.value * - spectrumVisibility(this.lightSource.spectrumProperty.value.groups, this.pathDifferenceProperty.value) - ); - } - /** Clears the photon counts and the accumulated marks. */ public clearCounts(): void { this.countsAProperty.value = 0; @@ -261,6 +274,7 @@ export class MachZehnderModel implements TModel { this.beamModeProperty.reset(); this.photonRateProperty.reset(); this.whichPathProperty.reset(); + this.timer.reset(); this.clearCounts(); this.photonAccumulator = 0; } @@ -275,6 +289,24 @@ export class MachZehnderModel implements TModel { * fringes stop appearing. */ public step(dt: number): void { + this.timer.step(dt); + if (!this.timer.isPlayingProperty.value) { + return; + } + this.emit(dt); + } + + /** + * Emits one frame's worth of photons regardless of the clock — what the + * step-forward button does while paused. At the lowest emission rate a frame + * is worth a third of a photon, so stepping really does deliver them one at a + * time. + */ + public stepOnce(): void { + this.emit(MANUAL_STEP_DT); + } + + private emit(dt: number): void { if (this.beamModeProperty.value !== BeamMode.SINGLE_PHOTON) { return; } diff --git a/src/mach-zehnder/view/MachZehnderArmsPanel.ts b/src/mach-zehnder/view/MachZehnderArmsPanel.ts index cb0e37c..1fb17d3 100644 --- a/src/mach-zehnder/view/MachZehnderArmsPanel.ts +++ b/src/mach-zehnder/view/MachZehnderArmsPanel.ts @@ -40,8 +40,12 @@ export class MachZehnderArmsPanel extends TitledPanel { }, ); + // Both axes, as on the Michelson. The model has always carried a vertical + // tilt and fed it into the wedge term; without a control for it the fringes + // could only ever be made vertical, which quietly implies that is the only + // orientation a wedge can have. const tiltHorizontalControl = new InterferometryLabNumberControl( - machZehnder.tiltStringProperty, + machZehnder.tiltHorizontalStringProperty, model.tiltHorizontalProperty, MIRROR_TILT_RANGE_URAD, { @@ -56,6 +60,24 @@ export class MachZehnderArmsPanel extends TitledPanel { }, ); - super(machZehnder.armsStringProperty, [imbalanceControl, tiltHorizontalControl], { contentWidth }); + const tiltVerticalControl = new InterferometryLabNumberControl( + machZehnder.tiltVerticalStringProperty, + model.tiltVerticalProperty, + MIRROR_TILT_RANGE_URAD, + { + accessibleName: a11y.tiltVerticalStringProperty, + valuePattern: units.microradiansStringProperty, + decimals: 0, + delta: 1, + keyboardStep: 5, + shiftKeyboardStep: 1, + pageKeyboardStep: 50, + majorTicks: [{ value: 0, label: "0" }], + }, + ); + + super(machZehnder.armsStringProperty, [imbalanceControl, tiltHorizontalControl, tiltVerticalControl], { + contentWidth, + }); } } diff --git a/src/mach-zehnder/view/MachZehnderScreenView.ts b/src/mach-zehnder/view/MachZehnderScreenView.ts index 8b68ed9..1704fc8 100644 --- a/src/mach-zehnder/view/MachZehnderScreenView.ts +++ b/src/mach-zehnder/view/MachZehnderScreenView.ts @@ -17,12 +17,16 @@ import { HBox, Node, VBox } from "scenerystack/scenery"; import { ResetAllButton } from "scenerystack/scenery-phet"; import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; import { FLAT_RESET_ALL_BUTTON_OPTIONS } from "../../common/InterferometryLabButtonOptions.js"; +import { createTimeControl } from "../../common/view/controlFactory.js"; import { DetectorScreenNode } from "../../common/view/DetectorScreenNode.js"; -import { lengthProperty, percentProperty } from "../../common/view/formatters.js"; +import { lengthProperty, percentProperty, wavesProperty } from "../../common/view/formatters.js"; +import { IntensityProfileNode } from "../../common/view/IntensityProfileNode.js"; import { LightSourcePanel } from "../../common/view/LightSourcePanel.js"; import { ReadoutBlock } from "../../common/view/ReadoutBlock.js"; +import InterferometryLabColors from "../../InterferometryLabColors.js"; import { CONTROL_PANEL_WIDTH, PANEL_SPACING, SCREEN_VIEW_MARGIN } from "../../InterferometryLabConstants.js"; import { StringManager } from "../../i18n/StringManager.js"; +import type { InterferometryLabPreferencesModel } from "../../preferences/InterferometryLabPreferencesModel.js"; import { BeamMode } from "../model/BeamMode.js"; import type { MachZehnderModel } from "../model/MachZehnderModel.js"; import { MachZehnderArmsPanel } from "./MachZehnderArmsPanel.js"; @@ -38,11 +42,20 @@ export type MachZehnderScreenViewOptions = ScreenViewOptions; const PANEL_CONTENT_WIDTH = CONTROL_PANEL_WIDTH - 24; /** Side length of each of the two port detectors, view pixels. Smaller than the - * single detector on the other screens, because there are two of them. */ -const PORT_DETECTOR_SIZE = 168; + * single detector on the other screens, because there are two of them and they + * share the column with the trace drawn underneath. */ +const PORT_DETECTOR_SIZE = 132; + +/** The intensity trace under the two ports, view pixels. Spans both of them. */ +const PROFILE_WIDTH = 2 * PORT_DETECTOR_SIZE + PANEL_SPACING; +const PROFILE_HEIGHT = 68; export class MachZehnderScreenView extends ScreenView { - public constructor(model: MachZehnderModel, providedOptions?: MachZehnderScreenViewOptions) { + public constructor( + model: MachZehnderModel, + preferences: InterferometryLabPreferencesModel, + providedOptions?: MachZehnderScreenViewOptions, + ) { const options = optionize()( { screenSummaryContent: new MachZehnderScreenSummaryContent(model) }, providedOptions, @@ -62,7 +75,7 @@ export class MachZehnderScreenView extends ScreenView { (mode) => mode === BeamMode.SINGLE_PHOTON, ); - const tableNode = new MachZehnderTableNode(model); + const tableNode = new MachZehnderTableNode(model, preferences); /** * One port: the pattern, the accumulated photon marks over it, and the share @@ -106,12 +119,65 @@ export class MachZehnderScreenView extends ScreenView { const pathReadout = new ReadoutBlock([ { label: common.pathDifferenceStringProperty, value: lengthProperty(model.pathDifferenceProperty, 0) }, + { + label: common.inWavelengthsStringProperty, + value: wavesProperty(model.pathDifferenceProperty, model.lightSource.meanWavelengthProperty, 2), + }, + { label: common.visibilityStringProperty, value: percentProperty(model.visibilityProperty, 0) }, ]); + // The screen's whole argument, drawn: two traces exactly out of step and a + // flat dashed total. Two images and two percentages can only ever suggest + // that the ports are complementary; here it is a straight line you can look + // at, and it stays straight when the which-path marker collapses both traces + // to a flat half. + const profileNode = new IntensityProfileNode( + [ + { + specProperty: model.portASpecProperty, + colorProperty: InterferometryLabColors.plotTraceColorProperty, + label: machZehnder.portAStringProperty, + }, + { + specProperty: model.portBSpecProperty, + colorProperty: InterferometryLabColors.plotTraceAltColorProperty, + label: machZehnder.portBStringProperty, + }, + ], + { + width: PROFILE_WIDTH, + height: PROFILE_HEIGHT, + showSum: true, + sumLabel: machZehnder.sumOfPortsStringProperty, + descriptionSuffix: strings.getMachZehnderA11yStrings().profileComplementaryStringProperty, + }, + ); + + // Only meaningful in single-photon mode: the continuous beam has nothing + // that evolves, so a play button there would suggest motion that is not + // there. + const timeControl = createTimeControl( + model.timer.isPlayingProperty, + () => model.stepOnce(), + a11y.modeStringProperty, + ); + timeControl.visibleProperty = isSinglePhotonProperty; + + // The readings and the clock sit beside the trace rather than under it. The + // column is already four items deep — two detectors, a trace, and the + // numbers — and stacking them all would push the control panels off the + // bottom of the screen. const detectorColumn = new VBox({ spacing: 8, - align: "center", - children: [new HBox({ spacing: PANEL_SPACING, align: "top", children: [portA, portB] }), pathReadout], + align: "left", + children: [ + new HBox({ spacing: PANEL_SPACING, align: "top", children: [portA, portB] }), + new HBox({ + spacing: PANEL_SPACING + 10, + align: "top", + children: [profileNode, new VBox({ spacing: 10, align: "left", children: [pathReadout, timeControl] })], + }), + ], }); const topRow = new HBox({ @@ -158,7 +224,9 @@ export class MachZehnderScreenView extends ScreenView { this.addChild( new Node({ - pdomOrder: [sourcePanel, armsPanel, samplePanel, modePanel, resetAllButton], + // The time control comes after the mode panel that reveals it: it is + // only meaningful once single-photon mode is chosen. + pdomOrder: [sourcePanel, armsPanel, samplePanel, modePanel, timeControl, resetAllButton], }), ); } diff --git a/src/mach-zehnder/view/MachZehnderTableNode.ts b/src/mach-zehnder/view/MachZehnderTableNode.ts index 7d1cbdf..147fdd7 100644 --- a/src/mach-zehnder/view/MachZehnderTableNode.ts +++ b/src/mach-zehnder/view/MachZehnderTableNode.ts @@ -14,6 +14,7 @@ import { DerivedProperty } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { Node } from "scenerystack/scenery"; import { BeamPathNode } from "../../common/view/BeamPathNode.js"; +import { pathDeltaProperty } from "../../common/view/formatters.js"; import { OpticalTableNode } from "../../common/view/OpticalTableNode.js"; import { createBeamSplitterNode, @@ -25,6 +26,7 @@ import { } from "../../common/view/opticNodes.js"; import { sourceColorProperty } from "../../common/view/sourceColor.js"; import { StringManager } from "../../i18n/StringManager.js"; +import type { InterferometryLabPreferencesModel } from "../../preferences/InterferometryLabPreferencesModel.js"; import type { MachZehnderModel } from "../model/MachZehnderModel.js"; /** Table size, view pixels. */ @@ -49,7 +51,7 @@ const SAMPLE = new Vector2(230, 68); const OPTIC_WIDTH = 42; export class MachZehnderTableNode extends Node { - public constructor(model: MachZehnderModel) { + public constructor(model: MachZehnderModel, preferences: InterferometryLabPreferencesModel) { super(); const strings = StringManager.getInstance(); @@ -126,6 +128,24 @@ export class MachZehnderTableNode extends Node { mirrorLabel.right = MIRROR_UPPER.x - 26; mirrorLabel.centerY = MIRROR_UPPER.y; + // ── Path-difference contributions ──────────────────────────────────────── + // Unlike the Michelson, nothing here is doubled: the two arms are separate + // routes crossed once each, so the imbalance and the slide contribute + // exactly what they are. Seeing the two screens' labels side by side is the + // clearest statement of why a Michelson's factor of two exists at all. + const imbalanceLabel = createTableLabel(pathDeltaProperty(model.pathImbalanceProperty, 0)); + imbalanceLabel.centerX = MIRROR_UPPER.x + 44; + imbalanceLabel.bottom = MIRROR_UPPER.y - 12; + imbalanceLabel.visibleProperty = preferences.showOpticalPathProperty; + + const samplePathLabel = createTableLabel(pathDeltaProperty(model.samplePathProperty, 0)); + samplePathLabel.centerX = SAMPLE.x; + samplePathLabel.bottom = SAMPLE.y - 14; + samplePathLabel.visibleProperty = new DerivedProperty( + [preferences.showOpticalPathProperty, model.sampleEnabledProperty], + (show, enabled) => show && enabled, + ); + const sampleLabel = createTableLabel(machZehnder.sampleStringProperty); sampleLabel.centerX = SAMPLE.x; sampleLabel.top = SAMPLE.y + 16; @@ -147,6 +167,8 @@ export class MachZehnderTableNode extends Node { portBLabel, mirrorLabel, sampleLabel, + imbalanceLabel, + samplePathLabel, ]; } } diff --git a/src/main.ts b/src/main.ts index 03b7027..5f9c2d9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,17 +36,17 @@ onReadyToLaunch(() => { const simPreferences = new InterferometryLabPreferencesModel(Tandem.ROOT.createTandem("preferences")); const screens = [ - new MichelsonScreen({ + new MichelsonScreen(simPreferences, { name: stringManager.getScreenNames().michelsonStringProperty, tandem: Tandem.ROOT.createTandem("michelsonScreen"), backgroundColorProperty: InterferometryLabColors.backgroundColorProperty, }), - new MachZehnderScreen({ + new MachZehnderScreen(simPreferences, { name: stringManager.getScreenNames().machZehnderStringProperty, tandem: Tandem.ROOT.createTandem("machZehnderScreen"), backgroundColorProperty: InterferometryLabColors.backgroundColorProperty, }), - new FabryPerotScreen({ + new FabryPerotScreen(simPreferences, { name: stringManager.getScreenNames().fabryPerotStringProperty, tandem: Tandem.ROOT.createTandem("fabryPerotScreen"), backgroundColorProperty: InterferometryLabColors.backgroundColorProperty, diff --git a/src/michelson/MichelsonScreen.ts b/src/michelson/MichelsonScreen.ts index 2f5c51a..fb07320 100644 --- a/src/michelson/MichelsonScreen.ts +++ b/src/michelson/MichelsonScreen.ts @@ -15,6 +15,7 @@ import { Screen } from "scenerystack/sim"; import type { Tandem } from "scenerystack/tandem"; import { createMichelsonIcon } from "../common/InterferometryLabScreenIcons.js"; import InterferometryLabColors from "../InterferometryLabColors.js"; +import type { InterferometryLabPreferencesModel } from "../preferences/InterferometryLabPreferencesModel.js"; import { MichelsonModel } from "./model/MichelsonModel.js"; import { MichelsonKeyboardHelpContent } from "./view/MichelsonKeyboardHelpContent.js"; import { MichelsonScreenView } from "./view/MichelsonScreenView.js"; @@ -23,13 +24,18 @@ import { MichelsonScreenView } from "./view/MichelsonScreenView.js"; type MichelsonScreenOptions = ScreenOptions & { tandem: Tandem }; export class MichelsonScreen extends Screen { - public constructor(options: MichelsonScreenOptions) { + /** + * @param preferences - simulation preferences the view reads; the optical-path + * labels are a preference, so the view needs to see them + * @param options + */ + public constructor(preferences: InterferometryLabPreferencesModel, options: MichelsonScreenOptions) { super( // Model factory — called once when the screen is first shown () => new MichelsonModel(), // View factory — receives the model instance (model) => - new MichelsonScreenView(model, { + new MichelsonScreenView(model, preferences, { tandem: options.tandem.createTandem("view"), }), optionize()( diff --git a/src/michelson/model/MichelsonModel.ts b/src/michelson/model/MichelsonModel.ts index c2a94aa..455ed67 100644 --- a/src/michelson/model/MichelsonModel.ts +++ b/src/michelson/model/MichelsonModel.ts @@ -106,6 +106,12 @@ export class MichelsonModel implements TModel { /** Refractive index of the gas in the cell. */ public readonly gasIndexProperty: TReadOnlyProperty; + /** + * Path difference contributed by the movable mirror alone, nm — twice its + * displacement, because the arm is traversed both ways. + */ + public readonly mirrorPathProperty: TReadOnlyProperty; + /** Path difference contributed by the gas cell alone, nm. */ public readonly gasCellOpdProperty: TReadOnlyProperty; @@ -156,6 +162,8 @@ export class MichelsonModel implements TModel { (coarse, fine) => coarse + fine, ); + this.mirrorPathProperty = new DerivedProperty([this.mirrorOffsetProperty], (offset) => 2 * offset); + this.gasIndexProperty = new DerivedProperty([this.gasCellPressureProperty], (pressureKPa) => gasIndex(pressureKPa, ROOM_TEMPERATURE_K), ); @@ -167,8 +175,8 @@ export class MichelsonModel implements TModel { ); this.pathDifferenceProperty = new DerivedProperty( - [this.mirrorOffsetProperty, this.gasCellOpdProperty], - (offset, cellOpd) => 2 * offset + cellOpd, + [this.mirrorPathProperty, this.gasCellOpdProperty], + (mirrorPath, cellOpd) => mirrorPath + cellOpd, ); this.visibilityProperty = new DerivedProperty( diff --git a/src/michelson/view/CoherenceEnvelopeNode.ts b/src/michelson/view/CoherenceEnvelopeNode.ts new file mode 100644 index 0000000..2f94256 --- /dev/null +++ b/src/michelson/view/CoherenceEnvelopeNode.ts @@ -0,0 +1,252 @@ +/** + * CoherenceEnvelopeNode.ts + * + * Fringe visibility against optical path difference — the interferogram's + * envelope, with a marker showing where on it the instrument currently sits. + * + * Coherence is the hardest thing on this screen to discover by dragging. The + * detector shows the visibility at *one* path difference, so a student hunting + * for the edge of the fringes is sampling a curve they cannot see, one point at + * a time, across a stage whose full travel is four hundred micrometres. The + * shape of that curve is the whole of §2 of the model documentation, and every + * source has a different one: + * + * - a laser is flat at 1 — its coherence length is metres, so nothing the stage + * can do will fade the fringes, which is exactly why lasers are used; + * - a filtered lamp decays as a Gaussian whose width *is* its coherence length; + * - white light is a needle a micrometre wide, which is why the white-light + * fringe is so hard to find; + * - and sodium does not decay at all — it beats, dying and reviving with a + * period set by the D-line spacing. Measuring that period is the classic + * undergraduate determination of the doublet separation, and it is invisible + * without this plot. + * + * The horizontal span therefore cannot be fixed: metres and micrometres both + * have to be legible. It is chosen from the source's own feature scale, the same + * way `TransmissionSpectrumNode` rescales itself around the line separation. + */ + +import { DerivedProperty, Multilink, type TReadOnlyProperty, type UnknownMultilink } from "scenerystack/axon"; +import { ChartRectangle, ChartTransform, GridLineSet, LinePlot } from "scenerystack/bamboo"; +import { Bounds2, Range, Vector2 } from "scenerystack/dot"; +import { Orientation } from "scenerystack/phet-core"; +import { StringUtils } from "scenerystack/phetcommon"; +import { Line, Node, Text, VBox } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { spectrumVisibility } from "../../common/model/spectrum.js"; +import { lengthProperty, percentProperty } from "../../common/view/formatters.js"; +import InterferometryLabColors from "../../InterferometryLabColors.js"; +import { LABEL_FONT_SIZE, MICHELSON_COARSE_RANGE_NM, PANEL_CORNER_RADIUS } from "../../InterferometryLabConstants.js"; +import { StringManager } from "../../i18n/StringManager.js"; +import type { MichelsonModel } from "../model/MichelsonModel.js"; + +/** Points sampled across the plot. */ +const SAMPLE_COUNT = 500; + +/** + * Widest path difference the plot ever shows, nm. The mirror is traversed twice, + * so the stage's travel is worth double in path difference; there is no point + * plotting past what the instrument can actually reach. + */ +const MAX_HALF_SPAN_NM = 2 * MICHELSON_COARSE_RANGE_NM.max; + +/** + * Narrowest span, nm. White light's coherence length is about a micrometre and + * the curve would otherwise collapse onto the axis; a couple of wavelengths of + * margin keeps the needle a shape rather than a spike. + */ +const MIN_HALF_SPAN_NM = 2000; + +/** + * How much of the source's feature scale to show either side of zero. Enough to + * get past the first null of a doublet, or well down the tail of a Gaussian. + */ +const SPAN_IN_FEATURES = 1.4; + +/** + * Bounds of the visibility axis, padded at both ends. A laser's curve is flat at + * 1 and a doublet's touches 0; against the frame either would read as an empty + * box rather than as the answer. + */ +const VISIBILITY_MIN = -0.06; +const VISIBILITY_MAX = 1.08; + +/** Spacing of the horizontal gridlines, in visibility. */ +const VISIBILITY_GRID_SPACING = 0.5; + +export type CoherenceEnvelopeNodeOptions = { + readonly width: number; + readonly height: number; +}; + +export class CoherenceEnvelopeNode extends VBox { + private readonly multilink: UnknownMultilink; + + /** + * The screen-reader description and the formatter Properties feeding it. All + * of them link model Properties, so all of them have to be let go on dispose. + */ + private readonly description: ReturnType; + + public constructor(model: MichelsonModel, options: CoherenceEnvelopeNodeOptions) { + const strings = StringManager.getInstance(); + const common = strings.getCommon(); + const michelson = strings.getMichelsonStrings(); + + const chartTransform = new ChartTransform({ + viewWidth: options.width, + viewHeight: options.height, + modelXRange: new Range(-1, 1), + modelYRange: new Range(VISIBILITY_MIN, VISIBILITY_MAX), + }); + + const chartRectangle = new ChartRectangle(chartTransform, { + fill: InterferometryLabColors.tableColorProperty, + stroke: InterferometryLabColors.tableBorderColorProperty, + cornerXRadius: PANEL_CORNER_RADIUS, + cornerYRadius: PANEL_CORNER_RADIUS, + }); + + const curve = new LinePlot(chartTransform, [], { + stroke: InterferometryLabColors.plotTraceColorProperty, + lineWidth: 1.6, + }); + + // Where the instrument is standing on the curve. Drawn full height so it can + // be read against the trace without hunting for an intersection. + const marker = new Line(0, 0, 0, options.height, { + stroke: InterferometryLabColors.valueColorProperty, + lineWidth: 1.4, + lineDash: [4, 3], + }); + + // Gridlines at zero, half and full visibility, so "the fringes are gone" and + // "the fringes are faint" are distinguishable at a glance. + const gridLines = new GridLineSet(chartTransform, Orientation.VERTICAL, VISIBILITY_GRID_SPACING, { + stroke: InterferometryLabColors.plotAxisColorProperty, + lineWidth: 0.5, + }); + + const clipped = new Node({ + children: [gridLines, curve, marker], + clipArea: chartRectangle.getShape(), + }); + + const update = (): void => { + const spectrum = model.lightSource.spectrumProperty.value; + const halfSpanNm = halfSpan(model.lightSource.coherenceLengthProperty.value, spectrum); + + const points: Vector2[] = []; + for (let i = 0; i < SAMPLE_COUNT; i++) { + const fraction = i / (SAMPLE_COUNT - 1); + const x = -1 + 2 * fraction; + points.push(new Vector2(x, spectrumVisibility(spectrum.groups, x * halfSpanNm))); + } + curve.setDataSet(points); + + // Off-scale means the fringes died long ago; saying so by hiding the + // marker is better than pinning it to an edge it is not at. + const pathDifferenceNm = model.pathDifferenceProperty.value; + const markerX = pathDifferenceNm / halfSpanNm; + marker.visible = Math.abs(markerX) <= 1; + if (marker.visible) { + const viewX = chartTransform.modelToViewX(markerX); + marker.setLine(viewX, 0, viewX, options.height); + } + }; + + const multilink = Multilink.multilinkAny( + [model.lightSource.spectrumProperty, model.pathDifferenceProperty], + update, + ); + + const chart = new Node({ + children: [chartRectangle, clipped], + localBounds: new Bounds2(0, 0, options.width, options.height), + }); + + const title = new Text(michelson.visibilityCurveStringProperty, { + font: new PhetFont({ size: LABEL_FONT_SIZE, weight: "bold" }), + fill: InterferometryLabColors.textColorProperty, + maxWidth: options.width, + }); + + const axisLabel = new Text(common.pathDifferenceStringProperty, { + font: new PhetFont(LABEL_FONT_SIZE - 1), + fill: InterferometryLabColors.plotAxisColorProperty, + maxWidth: options.width, + }); + + const description = describeEnvelope(model); + + super({ + spacing: 5, + align: "center", + children: [title, chart, axisLabel], + accessibleParagraph: description.paragraph, + }); + + this.multilink = multilink; + this.description = description; + } + + public override dispose(): void { + super.dispose(); + this.multilink.dispose(); + this.description.paragraph.dispose(); + for (const part of this.description.parts) { + part.dispose(); + } + } +} + +/** + * Half-width of the plotted path-difference range, nm. + * + * The scale worth showing is whichever feature arrives first: the decay of the + * coherence envelope, or — for a doublet, which does not decay on this scale at + * all — the beat period `λ₀²/δλ`. Either can be infinite (a perfectly + * monochromatic line has no decay; a single line has no beat), in which case the + * plot falls back to the full travel of the stage, where a laser correctly reads + * as flat at full contrast. + */ +function halfSpan(coherenceLengthNm: number, spectrum: { centerNm: number; doubletSeparationNm: number }): number { + const beatPeriodNm = + spectrum.doubletSeparationNm > 0 + ? (spectrum.centerNm * spectrum.centerNm) / spectrum.doubletSeparationNm + : Number.POSITIVE_INFINITY; + + const featureNm = Math.min(coherenceLengthNm, beatPeriodNm); + if (!Number.isFinite(featureNm)) { + return MAX_HALF_SPAN_NM; + } + return Math.min(MAX_HALF_SPAN_NM, Math.max(MIN_HALF_SPAN_NM, SPAN_IN_FEATURES * featureNm)); +} + +/** + * The curve in words, for a reader who cannot see it. + * + * Returns the intermediate formatter Properties along with the description. + * Each of them links a model Property of its own, so disposing only the + * description would leave three live listeners holding this node in memory. + */ +function describeEnvelope(model: MichelsonModel): { + readonly paragraph: TReadOnlyProperty; + readonly parts: readonly TReadOnlyProperty[]; +} { + const a11y = StringManager.getInstance().getMichelsonA11yStrings(); + + const parts = [ + percentProperty(model.visibilityProperty, 0), + lengthProperty(model.pathDifferenceProperty, 1), + lengthProperty(model.lightSource.coherenceLengthProperty, 1), + ] as const; + + const paragraph = new DerivedProperty( + [a11y.visibilityCurveStringProperty, ...parts], + (pattern, visibility, pathDifference, coherenceLength) => + StringUtils.fillIn(pattern, { visibility, pathDifference, coherenceLength }), + ); + + return { paragraph, parts }; +} diff --git a/src/michelson/view/MichelsonScreenView.ts b/src/michelson/view/MichelsonScreenView.ts index 6a2b4d4..4a22c39 100644 --- a/src/michelson/view/MichelsonScreenView.ts +++ b/src/michelson/view/MichelsonScreenView.ts @@ -16,9 +16,11 @@ import { ResetAllButton } from "scenerystack/scenery-phet"; import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; import { FLAT_RESET_ALL_BUTTON_OPTIONS } from "../../common/InterferometryLabButtonOptions.js"; import { DetectorScreenNode } from "../../common/view/DetectorScreenNode.js"; -import { lengthProperty, percentProperty } from "../../common/view/formatters.js"; +import { lengthProperty, percentProperty, wavesProperty } from "../../common/view/formatters.js"; +import { IntensityProfileNode } from "../../common/view/IntensityProfileNode.js"; import { LightSourcePanel } from "../../common/view/LightSourcePanel.js"; import { ReadoutBlock } from "../../common/view/ReadoutBlock.js"; +import { sourceColorProperty } from "../../common/view/sourceColor.js"; import { CONTROL_PANEL_WIDTH, DETECTOR_VIEW_SIZE, @@ -26,7 +28,9 @@ import { SCREEN_VIEW_MARGIN, } from "../../InterferometryLabConstants.js"; import { StringManager } from "../../i18n/StringManager.js"; +import type { InterferometryLabPreferencesModel } from "../../preferences/InterferometryLabPreferencesModel.js"; import type { MichelsonModel } from "../model/MichelsonModel.js"; +import { CoherenceEnvelopeNode } from "./CoherenceEnvelopeNode.js"; import { MichelsonAlignmentPanel } from "./MichelsonAlignmentPanel.js"; import { MichelsonGasCellPanel } from "./MichelsonGasCellPanel.js"; import { MichelsonMirrorPanel } from "./MichelsonMirrorPanel.js"; @@ -38,8 +42,16 @@ export type MichelsonScreenViewOptions = ScreenViewOptions; /** Width of each panel's content in the bottom row, view pixels. */ const PANEL_CONTENT_WIDTH = CONTROL_PANEL_WIDTH - 24; +/** The analysis column to the right of the detector, view pixels. */ +const PLOT_WIDTH = 264; +const PLOT_HEIGHT = 104; + export class MichelsonScreenView extends ScreenView { - public constructor(model: MichelsonModel, providedOptions?: MichelsonScreenViewOptions) { + public constructor( + model: MichelsonModel, + preferences: InterferometryLabPreferencesModel, + providedOptions?: MichelsonScreenViewOptions, + ) { const options = optionize()( { screenSummaryContent: new MichelsonScreenSummaryContent(model) }, providedOptions, @@ -55,15 +67,23 @@ export class MichelsonScreenView extends ScreenView { const popupLayer = new Node(); // ── Top row: the table, and the detector it feeds ──────────────────────── - const tableNode = new MichelsonTableNode(model); + const tableNode = new MichelsonTableNode(model, preferences); const detectorNode = new DetectorScreenNode(model.fringeSpecProperty, { size: DETECTOR_VIEW_SIZE, title: common.detectorStringProperty, }); + // The same path difference twice, in the two units it means something in: a + // length, which is what the stage moved, and a number of wavelengths, which + // is how many fringes went past. Students routinely convert between them + // wrongly, and the pair sitting together is the cheapest possible fix. const detectorReadouts = new ReadoutBlock([ { label: common.pathDifferenceStringProperty, value: lengthProperty(model.pathDifferenceProperty, 1) }, + { + label: common.inWavelengthsStringProperty, + value: wavesProperty(model.pathDifferenceProperty, model.lightSource.meanWavelengthProperty, 1), + }, { label: common.visibilityStringProperty, value: percentProperty(model.visibilityProperty, 0) }, ]); @@ -76,10 +96,37 @@ export class MichelsonScreenView extends ScreenView { children: [detectorNode, detectorReadouts], }); + // ── Analysis column: the image turned into two measurements ────────────── + // The cut across the detector says what the pattern is doing here and now; + // the visibility curve says what it will do as the mirror travels. Together + // they are the difference between watching fringes and measuring them. + // The trace takes the source's own colour, so the curve and the image above + // it read as the same light rather than as two unrelated displays. + const profileNode = new IntensityProfileNode( + [ + { + specProperty: model.fringeSpecProperty, + colorProperty: sourceColorProperty(model.lightSource.groupsProperty), + }, + ], + { width: PLOT_WIDTH, height: PLOT_HEIGHT }, + ); + + const envelopeNode = new CoherenceEnvelopeNode(model, { + width: PLOT_WIDTH, + height: PLOT_HEIGHT, + }); + + const analysisColumn = new VBox({ + spacing: PANEL_SPACING + 2, + align: "center", + children: [profileNode, envelopeNode], + }); + const topRow = new HBox({ - spacing: PANEL_SPACING + 8, + spacing: PANEL_SPACING, align: "top", - children: [tableNode, detectorColumn], + children: [tableNode, detectorColumn, analysisColumn], }); topRow.left = this.layoutBounds.minX + SCREEN_VIEW_MARGIN; topRow.top = this.layoutBounds.minY + SCREEN_VIEW_MARGIN; diff --git a/src/michelson/view/MichelsonTableNode.ts b/src/michelson/view/MichelsonTableNode.ts index 479bb2a..9dae04a 100644 --- a/src/michelson/view/MichelsonTableNode.ts +++ b/src/michelson/view/MichelsonTableNode.ts @@ -15,6 +15,7 @@ import { DerivedProperty, type TReadOnlyProperty } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { Node } from "scenerystack/scenery"; import { BeamPathNode } from "../../common/view/BeamPathNode.js"; +import { pathDeltaProperty } from "../../common/view/formatters.js"; import { OpticalTableNode } from "../../common/view/OpticalTableNode.js"; import { createBeamSplitterNode, @@ -28,6 +29,7 @@ import { import { sourceColorProperty } from "../../common/view/sourceColor.js"; import { MICHELSON_COARSE_RANGE_NM } from "../../InterferometryLabConstants.js"; import { StringManager } from "../../i18n/StringManager.js"; +import type { InterferometryLabPreferencesModel } from "../../preferences/InterferometryLabPreferencesModel.js"; import type { MichelsonModel } from "../model/MichelsonModel.js"; /** Table size, view pixels. */ @@ -53,7 +55,7 @@ const OPTIC_WIDTH = 46; const MIRROR_TRAVEL_PIXELS = 14; export class MichelsonTableNode extends Node { - public constructor(model: MichelsonModel) { + public constructor(model: MichelsonModel, preferences: InterferometryLabPreferencesModel) { super(); const strings = StringManager.getInstance(); @@ -145,6 +147,28 @@ export class MichelsonTableNode extends Node { gasCellLabel.centerY = GAS_CELL.y; gasCellLabel.visibleProperty = model.gasCellEnabledProperty; + // ── Path-difference contributions ──────────────────────────────────────── + // Optional, because the numbers are already in the readouts and a permanent + // second copy on the table would be clutter. Switched on they answer the + // question the readouts cannot: *where* the path difference is coming from. + // Both are doubled, because both are in an arm the light crosses twice — + // which is the single most common slip in reading a Michelson, and drawing + // it next to the element makes the factor of two hard to miss. + const mirrorPathLabel = createTableLabel(pathDeltaProperty(model.mirrorPathProperty, 1)); + mirrorPathLabel.left = MOVABLE_MIRROR.x + 30; + mirrorPathLabel.centerY = MOVABLE_MIRROR.y; + mirrorPathLabel.visibleProperty = preferences.showOpticalPathProperty; + + // Left of the cell rather than under it: below is where the compensator's + // own label runs, and the two would collide. + const cellPathLabel = createTableLabel(pathDeltaProperty(model.gasCellOpdProperty, 2)); + cellPathLabel.right = GAS_CELL.x - 24; + cellPathLabel.centerY = GAS_CELL.y; + cellPathLabel.visibleProperty = new DerivedProperty( + [preferences.showOpticalPathProperty, model.gasCellEnabledProperty], + (show, enabled) => show && enabled, + ); + this.children = [ table, beams, @@ -162,6 +186,8 @@ export class MichelsonTableNode extends Node { detectorLabel, compensatorLabel, gasCellLabel, + mirrorPathLabel, + cellPathLabel, ]; } } diff --git a/tests/fringeIntensity.test.ts b/tests/fringeIntensity.test.ts index 306c368..b491d97 100644 --- a/tests/fringeIntensity.test.ts +++ b/tests/fringeIntensity.test.ts @@ -6,12 +6,13 @@ */ import { describe, expect, it } from "vitest"; -import type { FringeGeometry, MultiBeamTerms, TwoBeamTerms } from "../src/common/model/FringeSpec.js"; +import type { FringeGeometry, FringeSpec, MultiBeamTerms, TwoBeamTerms } from "../src/common/model/FringeSpec.js"; import { airyIntensity, airyPeakTransmission, axialCosine, coefficientOfFinesse, + intensityProfile, opdSpread, opticalPathDifference, reflectiveFinesse, @@ -196,3 +197,63 @@ describe("airyIntensity", () => { expect(flattened).toBeCloseTo(1 / Math.sqrt(1 + coefficientOfFinesse(0.9)), 10); }); }); + +describe("intensityProfile", () => { + /** A single monochromatic line, as a laser produces. */ + const laserGroup = { wavelengthNm: 600, bandwidthNm: 0, weight: 1, opdOffsetNm: 0 }; + + /** The trace across a pattern with a horizontal wedge and nothing else. */ + const wedgeSpec = (tiltXNm: number, extraPhaseRad = 0): FringeSpec => ({ + geometry: { ...flatGeometry, apertureTanTheta: 0, tiltXNm }, + groups: [laserGroup], + terms: { ...equalRoutes, extraPhaseRad }, + contrast: 1, + exposure: 0.5, + }); + + it("is flat when the path difference does not vary across the detector", () => { + const values = intensityProfile(wedgeSpec(0), 64); + expect(values).toHaveLength(64); + for (const value of values) { + expect(value).toBeCloseTo(values[0] ?? 0, 12); + } + }); + + it("draws one bright fringe per wavelength of wedge across the field", () => { + // tiltX is the extra path difference at the u = +1 edge, so the path + // difference sweeps 2·tiltX from edge to edge: four wavelengths here, and so + // four bright fringes. + const values = intensityProfile(wedgeSpec(2 * 600), 4000); + + let peaks = 0; + let above = false; + const midpoint = 0.5; + for (const value of values) { + if (value > midpoint && !above) { + above = true; + } else if (value <= midpoint && above) { + above = false; + peaks++; + } + } + expect(peaks).toBe(4); + }); + + it("keeps the two Mach-Zehnder ports' traces summing to a constant", () => { + // The claim the Mach-Zehnder screen exists to make: the second splitter's + // outputs are a half-wave apart, so whatever leaves one port is missing from + // the other and the total is the same everywhere. Interference redistributes + // light; it never destroys it. + const portA = intensityProfile(wedgeSpec(1500, 0), 200); + const portB = intensityProfile(wedgeSpec(1500, Math.PI), 200); + + portA.forEach((value, index) => { + expect(value + (portB[index] ?? 0)).toBeCloseTo(1, 12); + }); + }); + + it("fills a caller-supplied buffer instead of allocating", () => { + const buffer = new Float64Array(32); + expect(intensityProfile(wedgeSpec(900), 32, buffer)).toBe(buffer); + }); +}); diff --git a/tests/interferometerModels.test.ts b/tests/interferometerModels.test.ts index 798b9fc..ca10df7 100644 --- a/tests/interferometerModels.test.ts +++ b/tests/interferometerModels.test.ts @@ -11,10 +11,12 @@ import { intensityAt, reflectiveFinesse } from "../src/common/model/fringeIntens import { buildSpectrum } from "../src/common/model/LightSourceModel.js"; import { gasIndex } from "../src/common/model/refractiveIndex.js"; import { SourceType } from "../src/common/model/SourceType.js"; +import { spectrumVisibility } from "../src/common/model/spectrum.js"; import { FabryPerotModel } from "../src/fabry-perot/model/FabryPerotModel.js"; import { GAS_CELL_LENGTH_NM, HENE_WAVELENGTH_NM, + MICHELSON_COARSE_RANGE_NM, NM_PER_UM, STANDARD_PRESSURE_KPA, } from "../src/InterferometryLabConstants.js"; @@ -42,6 +44,20 @@ describe("buildSpectrum", () => { expect(spectrum.doubletSeparationNm).toBeCloseTo(0.597, 3); }); + it("puts the sodium lamp's first visibility null inside the mirror's travel", () => { + // What the Michelson's visibility curve is there to show. The D lines beat + // rather than decay, with the first null at λ₀²/2δλ ≈ 291 µm of path + // difference and a full revival at twice that. Both have to be reachable, or + // the classic measurement of the doublet spacing cannot be done on this + // screen at all: the stage travels ±0.2 mm, worth ±0.4 mm of path difference. + const spectrum = buildSpectrum(SourceType.SODIUM_LAMP, 550, 10); + const firstNullNm = (spectrum.centerNm * spectrum.centerNm) / (2 * spectrum.doubletSeparationNm); + + expect(firstNullNm).toBeLessThan(2 * MICHELSON_COARSE_RANGE_NM.max); + expect(spectrumVisibility(spectrum.groups, firstNullNm)).toBeLessThan(0.05); + expect(spectrumVisibility(spectrum.groups, 2 * firstNullNm)).toBeGreaterThan(0.9); + }); + it("splits white light into many groups spanning the visible band", () => { const spectrum = buildSpectrum(SourceType.WHITE_LIGHT, 550, 10); expect(spectrum.groups.length).toBeGreaterThan(5); @@ -65,6 +81,20 @@ describe("MichelsonModel", () => { expect(model.pathDifferenceProperty.value).toBeCloseTo(2500, 10); }); + it("splits the path difference into the mirror's contribution and the cell's", () => { + const model = new MichelsonModel(); + model.coarseOffsetProperty.value = 1000; + model.fineOffsetProperty.value = 0; + model.gasCellEnabledProperty.value = true; + + // The two labelled contributions are the whole of the path difference. + expect(model.mirrorPathProperty.value).toBeCloseTo(2000, 10); + expect(model.pathDifferenceProperty.value).toBeCloseTo( + model.mirrorPathProperty.value + model.gasCellOpdProperty.value, + 10, + ); + }); + it("zeroes the arms when asked", () => { const model = new MichelsonModel(); model.coarseOffsetProperty.value = 50_000; @@ -284,6 +314,39 @@ describe("MachZehnderModel", () => { expect(fractionA).toBeLessThan(0.6); }); + it("emits no photons while the clock is paused, and resumes when it is played", () => { + const model = new MachZehnderModel(); + model.beamModeProperty.value = BeamMode.SINGLE_PHOTON; + model.timer.isPlayingProperty.value = false; + + model.step(1); + expect(model.photonsEmittedProperty.value).toBe(0); + + model.timer.isPlayingProperty.value = true; + model.step(1); + expect(model.photonsEmittedProperty.value).toBeGreaterThan(0); + }); + + it("emits a frame's worth of photons per manual step while paused", () => { + const model = new MachZehnderModel(); + model.beamModeProperty.value = BeamMode.SINGLE_PHOTON; + model.timer.isPlayingProperty.value = false; + model.photonRateProperty.value = 600; + + // 600 per second at one sixtieth of a second is exactly ten per press. + model.stepOnce(); + expect(model.photonsEmittedProperty.value).toBe(10); + model.stepOnce(); + expect(model.photonsEmittedProperty.value).toBe(20); + }); + + it("loses all fringe visibility once the paths are marked", () => { + const model = new MachZehnderModel(); + expect(model.visibilityProperty.value).toBeCloseTo(1, 6); + model.whichPathProperty.value = true; + expect(model.visibilityProperty.value).toBe(0); + }); + it("clears the counts and the accumulated marks", () => { const model = new MachZehnderModel(); model.beamModeProperty.value = BeamMode.SINGLE_PHOTON; @@ -309,6 +372,16 @@ describe("FabryPerotModel", () => { expect(model.finesseProperty.value).toBeCloseTo(before, 10); }); + it("reports the round trip 2nd, which is the order times the wavelength", () => { + const model = new FabryPerotModel(); + model.spacingProperty.value = 100 * NM_PER_UM; + expect(model.roundTripPathProperty.value).toBeCloseTo(2 * 100 * NM_PER_UM, 10); + expect(model.orderProperty.value).toBeCloseTo( + model.roundTripPathProperty.value / model.wavelengthProperty.value, + 6, + ); + }); + it("derives the free spectral range as λ²/2nd", () => { const model = new FabryPerotModel(); model.wavelengthProperty.value = 600; @@ -368,16 +441,24 @@ describe("FabryPerotModel", () => { expect(model.peakTransmissionProperty.value).toBeLessThan(0.4); }); - it("does not move the spacing unless scanning", () => { + it("does not move the spacing while the scan clock is paused", () => { const model = new FabryPerotModel(); const before = model.effectiveSpacingProperty.value; model.step(1); expect(model.effectiveSpacingProperty.value).toBe(before); }); + it("steps the sweep forward while paused, so a peak can be walked onto", () => { + const model = new FabryPerotModel(); + const before = model.scanOffsetProperty.value; + model.stepOnce(); + expect(model.scanOffsetProperty.value).not.toBe(before); + expect(model.timer.isPlayingProperty.value).toBe(false); + }); + it("sweeps the spacing by about a wavelength while scanning", () => { const model = new FabryPerotModel(); - model.scanningProperty.value = true; + model.timer.isPlayingProperty.value = true; let minimum = Number.POSITIVE_INFINITY; let maximum = Number.NEGATIVE_INFINITY; for (let i = 0; i < 200; i++) { diff --git a/tests/memory-leak.test.ts b/tests/memory-leak.test.ts index 06ce9dd..772d7c3 100644 --- a/tests/memory-leak.test.ts +++ b/tests/memory-leak.test.ts @@ -6,20 +6,24 @@ * WeakRef that the object was collected. V8 requires a function boundary (not merely * a block scope) so local strong references die when the helper returns. * - * Beyond the template's TimeModel checks, this covers the two nodes in this sim - * that hold listeners on model Properties: the fringe renderer and the photon - * mark overlay. Both are created per screen rather than per frame, but both - * subscribe to Properties they do not own, which is the shape of leak this suite - * exists to catch. + * Beyond the template's TimeModel checks, this covers every node in this sim that + * holds listeners on model Properties: the fringe renderer, the photon mark + * overlay, and the two analysis charts. All are created per screen rather than + * per frame, but all subscribe to Properties they do not own, which is the shape + * of leak this suite exists to catch. */ import { NumberProperty, Property } from "scenerystack/axon"; +import { Color } from "scenerystack/scenery"; import { describe, expect, it } from "vitest"; import type { FringeSpec } from "../src/common/model/FringeSpec.js"; import { TimeModel } from "../src/common/TimeModel.js"; import { FringePatternNode } from "../src/common/view/FringePatternNode.js"; +import { IntensityProfileNode } from "../src/common/view/IntensityProfileNode.js"; import type { PhotonMark } from "../src/mach-zehnder/model/MachZehnderModel.js"; import { PhotonMarksNode } from "../src/mach-zehnder/view/PhotonMarksNode.js"; +import { MichelsonModel } from "../src/michelson/model/MichelsonModel.js"; +import { CoherenceEnvelopeNode } from "../src/michelson/view/CoherenceEnvelopeNode.js"; /** * Force garbage collection with multiple passes, stopping as soon as `collected` @@ -77,6 +81,23 @@ function createAndDisposePhotonMarksNode(revisionProperty: NumberProperty): Weak return ref; } +function createAndDisposeIntensityProfileNode(specProperty: Property): WeakRef { + const node = new IntensityProfileNode([{ specProperty, colorProperty: new Property(new Color("#4fc3f7")) }], { + width: 64, + height: 32, + }); + const ref = new WeakRef(node); + node.dispose(); + return ref; +} + +function createAndDisposeCoherenceEnvelopeNode(model: MichelsonModel): WeakRef { + const node = new CoherenceEnvelopeNode(model, { width: 64, height: 32 }); + const ref = new WeakRef(node); + node.dispose(); + return ref; +} + describe("Memory leak regression", () => { it("global.gc is available (--expose-gc)", () => { expect(globalThis.gc).toBeDefined(); @@ -141,4 +162,38 @@ describe("Memory leak regression", () => { await forceGC(() => ref.deref() === undefined); expect(ref.deref()).toBeUndefined(); }); + + it("IntensityProfileNode unlinks from the spec it does not own", () => { + const specProperty = new Property(makeSpec()); + expect(specProperty.hasListeners()).toBe(false); + const node = new IntensityProfileNode([{ specProperty, colorProperty: new Property(new Color("#4fc3f7")) }], { + width: 64, + height: 32, + }); + expect(specProperty.hasListeners()).toBe(true); + node.dispose(); + expect(specProperty.hasListeners()).toBe(false); + }); + + it("IntensityProfileNode is collected after dispose", async () => { + const specProperty = new Property(makeSpec()); + const ref = createAndDisposeIntensityProfileNode(specProperty); + await forceGC(() => ref.deref() === undefined); + expect(ref.deref()).toBeUndefined(); + }); + + /** + * The model outlives the node here, which is what makes this a real check: a + * listener left behind on any of the model's Properties would keep the node + * reachable and the WeakRef alive. A `hasListeners` check cannot be used for + * this node — the model derives its own visibility and fringe count from the + * same Properties, so they have listeners before the node is even built. + */ + it("CoherenceEnvelopeNode is collected after dispose, with its model still alive", async () => { + const model = new MichelsonModel(); + const ref = createAndDisposeCoherenceEnvelopeNode(model); + await forceGC(() => ref.deref() === undefined); + expect(ref.deref()).toBeUndefined(); + expect(model.pathDifferenceProperty.value).toBeDefined(); + }); }); diff --git a/vite.config.ts b/vite.config.ts index b0cdb11..a4fdc96 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,23 @@ const securityHeaders: Record = { "default-src 'self'", // 'unsafe-eval' is required for SceneryStack query parameter parsing "script-src 'self' 'unsafe-eval'", + // Event-handler attributes are governed separately from inline