diff --git a/src/RadioactivityAndStatisticsConstants.ts b/src/RadioactivityAndStatisticsConstants.ts index b0cd4b0..98be63f 100644 --- a/src/RadioactivityAndStatisticsConstants.ts +++ b/src/RadioactivityAndStatisticsConstants.ts @@ -77,9 +77,37 @@ export const TABLE_WIDTH = 230; /** Counting interval in seconds: how long each measurement accumulates. */ export const COUNTING_INTERVAL_RANGE = new Range(0.5, 20); +/** Arrow-button step for the counting interval. */ +export const COUNTING_INTERVAL_DELTA = 0.5; + +/** Decimal places shown for the counting interval. */ +export const COUNTING_INTERVAL_DECIMAL_PLACES = 1; + +/** + * Counting interval range on the Simulation screen, whose fastest rate is not + * limited by any hardware polling rate the way a real Geiger counter is. + */ +export const SIMULATION_COUNTING_INTERVAL_RANGE = new Range(0.25, 20); + +/** Arrow-button step for the counting interval on the Simulation screen. */ +export const SIMULATION_COUNTING_INTERVAL_DELTA = 0.25; + +/** Decimal places shown for the counting interval on the Simulation screen. */ +export const SIMULATION_COUNTING_INTERVAL_DECIMAL_PLACES = 2; + /** Default counting interval, in seconds. */ export const DEFAULT_COUNTING_INTERVAL = 1; +/** + * Selectable factors by which the Simulation screen's clock can run faster + * than real time. Offered only on the Simulation screen — a real source's + * decays cannot be sped up, so the Device screen leaves this at 1. + */ +export const SPEED_MULTIPLIER_CHOICES = [1, 10, 100]; + +/** Default speed multiplier: real time. */ +export const DEFAULT_SPEED_MULTIPLIER = 1; + /** How many intervals a run collects before recording stops on its own. */ export const SAMPLES_PER_RUN_RANGE = new Range(5, 200); @@ -111,8 +139,15 @@ export const BIN_WIDTH_RANGE = new Range(1, 20); */ export const MAXIMUM_STEP_DT = 0.5; -/** Cap on intervals completed in a single frame, as a runaway-loop guard. */ -export const MAXIMUM_INTERVALS_PER_FRAME = 5; +/** + * Cap on intervals completed in a single frame, as a runaway-loop guard. + * + * Sized to keep up with the fastest speed multiplier (100×) at the shortest + * counting interval (0.25 s): a 60 fps frame then needs to complete up to + * ~7 intervals to avoid throttling the requested speedup. Any surplus beyond + * the cap is not lost — the remainder carries into the next frame. + */ +export const MAXIMUM_INTERVALS_PER_FRAME = 20; RadioactivityAndStatisticsNamespace.register("RadioactivityAndStatisticsConstants", { SCREEN_VIEW_MARGIN, @@ -128,7 +163,14 @@ RadioactivityAndStatisticsNamespace.register("RadioactivityAndStatisticsConstant TABLE_ROW_HEIGHT, TABLE_WIDTH, COUNTING_INTERVAL_RANGE, + COUNTING_INTERVAL_DELTA, + COUNTING_INTERVAL_DECIMAL_PLACES, + SIMULATION_COUNTING_INTERVAL_RANGE, + SIMULATION_COUNTING_INTERVAL_DELTA, + SIMULATION_COUNTING_INTERVAL_DECIMAL_PLACES, DEFAULT_COUNTING_INTERVAL, + SPEED_MULTIPLIER_CHOICES, + DEFAULT_SPEED_MULTIPLIER, SAMPLES_PER_RUN_RANGE, DEFAULT_SAMPLES_PER_RUN, ACTIVITY_RANGE, diff --git a/src/common/model/RadioactivityModel.ts b/src/common/model/RadioactivityModel.ts index 3b4df20..c4711fd 100644 --- a/src/common/model/RadioactivityModel.ts +++ b/src/common/model/RadioactivityModel.ts @@ -24,13 +24,17 @@ import type { TModel } from "scenerystack/joist"; import { ACTIVITY_RANGE, BIN_WIDTH_RANGE, + COUNTING_INTERVAL_DECIMAL_PLACES, + COUNTING_INTERVAL_DELTA, COUNTING_INTERVAL_RANGE, DEFAULT_ACTIVITY, DEFAULT_COUNTING_INTERVAL, DEFAULT_SAMPLES_PER_RUN, + DEFAULT_SPEED_MULTIPLIER, MAXIMUM_INTERVALS_PER_FRAME, MAXIMUM_STEP_DT, SAMPLES_PER_RUN_RANGE, + SPEED_MULTIPLIER_CHOICES, } from "../../RadioactivityAndStatisticsConstants.js"; import type { CountSample } from "./CountSample.js"; import { CountSourceType, type CountSourceTypeValue, type TCountSource } from "./CountSource.js"; @@ -52,6 +56,15 @@ export type RadioactivityModelOptions = { * {@link CountSourceType.SIMULATED}. */ readonly fixedSourceType?: CountSourceTypeValue; + + /** Range offered for {@link RadioactivityModel.countingIntervalProperty}. Defaults to {@link COUNTING_INTERVAL_RANGE}. */ + readonly countingIntervalRange?: Range; + + /** Arrow-button step for the counting interval control. Defaults to {@link COUNTING_INTERVAL_DELTA}. */ + readonly countingIntervalDelta?: number; + + /** Decimal places shown for the counting interval control. Defaults to {@link COUNTING_INTERVAL_DECIMAL_PLACES}. */ + readonly countingIntervalDecimalPlaces?: number; }; export class RadioactivityModel implements TModel { @@ -74,6 +87,22 @@ export class RadioactivityModel implements TModel { /** Length of one counting interval, in seconds. */ public readonly countingIntervalProperty: NumberProperty; + /** Range offered for {@link countingIntervalProperty}, for the view control. */ + public readonly countingIntervalRange: Range; + + /** Arrow-button step for the counting interval control. */ + public readonly countingIntervalDelta: number; + + /** Decimal places shown for the counting interval control. */ + public readonly countingIntervalDecimalPlaces: number; + + /** + * How many simulated seconds pass per real second. A control on the + * Simulation screen alone ever changes this away from 1 — a real source's + * decays cannot be sped up, so the Device screen leaves it at real time. + */ + public readonly speedMultiplierProperty: Property; + /** How many intervals a run collects before recording stops on its own. */ public readonly samplesPerRunProperty: NumberProperty; @@ -148,10 +177,16 @@ export class RadioactivityModel implements TModel { sourceType === CountSourceType.GEIGER_COUNTER ? this.geigerSource : this.simulatedSource, ); + this.countingIntervalRange = options?.countingIntervalRange ?? COUNTING_INTERVAL_RANGE; + this.countingIntervalDelta = options?.countingIntervalDelta ?? COUNTING_INTERVAL_DELTA; + this.countingIntervalDecimalPlaces = options?.countingIntervalDecimalPlaces ?? COUNTING_INTERVAL_DECIMAL_PLACES; this.countingIntervalProperty = new NumberProperty(DEFAULT_COUNTING_INTERVAL, { - range: COUNTING_INTERVAL_RANGE, + range: this.countingIntervalRange, units: "s", }); + this.speedMultiplierProperty = new Property(DEFAULT_SPEED_MULTIPLIER, { + validValues: SPEED_MULTIPLIER_CHOICES, + }); this.samplesPerRunProperty = new NumberProperty(DEFAULT_SAMPLES_PER_RUN, { range: SAMPLES_PER_RUN_RANGE }); this.isContinuousProperty = new BooleanProperty(false); @@ -258,21 +293,42 @@ export class RadioactivityModel implements TModel { return; } + // The speed multiplier scales simulated time after the backgrounded-tab + // guard above, not before it — so a real wall-clock stall is still capped + // by MAXIMUM_STEP_DT, while a deliberate 10x or 100x speedup is not. + let remainingDt = clampedDt * this.speedMultiplierProperty.value; + const source = this.activeSourceProperty.value; - source.step(clampedDt); + const interval = this.countingIntervalProperty.value; + let completed = 0; - this.intervalElapsedProperty.value += clampedDt; - this.intervalCountsProperty.value = source.totalCountsProperty.value - this.intervalStartTotal; + // A simulated count is one Poisson draw for whatever dt it is given. Once + // a speed multiplier or a short interval lets one frame span more than + // one counting interval, the source must be stepped once per interval + // boundary rather than once for the whole frame — otherwise every + // interval after the first in that frame would close over a count that + // was already spent (and reset to zero) by the one before it. + while (remainingDt > 0) { + const timeToIntervalEnd = + completed < MAXIMUM_INTERVALS_PER_FRAME + ? Math.max(interval - this.intervalElapsedProperty.value, 0) + : remainingDt; + const stepDt = Math.min(remainingDt, timeToIntervalEnd); + + source.step(stepDt); + this.intervalElapsedProperty.value += stepDt; + this.intervalCountsProperty.value = source.totalCountsProperty.value - this.intervalStartTotal; + + if (this.isRecordingProperty.value) { + this.runTimeProperty.value += stepDt; + } - if (this.isRecordingProperty.value) { - this.runTimeProperty.value += clampedDt; - } + remainingDt -= stepDt; - const interval = this.countingIntervalProperty.value; - let completed = 0; - while (this.intervalElapsedProperty.value >= interval && completed < MAXIMUM_INTERVALS_PER_FRAME) { - this.completeInterval(interval); - completed += 1; + if (completed < MAXIMUM_INTERVALS_PER_FRAME && this.intervalElapsedProperty.value >= interval) { + this.completeInterval(interval); + completed += 1; + } } } @@ -281,6 +337,7 @@ export class RadioactivityModel implements TModel { this.samplesProperty.value = []; this.sourceTypeProperty.reset(); this.countingIntervalProperty.reset(); + this.speedMultiplierProperty.reset(); this.samplesPerRunProperty.reset(); this.isContinuousProperty.reset(); this.isAutoBinWidthProperty.reset(); diff --git a/src/common/model/RadioactivityScreenModel.ts b/src/common/model/RadioactivityScreenModel.ts index f17651f..b9a86c6 100644 --- a/src/common/model/RadioactivityScreenModel.ts +++ b/src/common/model/RadioactivityScreenModel.ts @@ -9,6 +9,7 @@ */ import { BooleanProperty, Property } from "scenerystack/axon"; +import type { Range } from "scenerystack/dot"; import type { TModel } from "scenerystack/joist"; import type { RadioactivityAndStatisticsPreferencesModel } from "../../preferences/RadioactivityAndStatisticsPreferencesModel.js"; import type { ChartViewTypeValue } from "./ChartViewType.js"; @@ -21,6 +22,15 @@ export type RadioactivityScreenModelOptions = { /** Which chart the screen opens on. */ readonly initialChartView: ChartViewTypeValue; + + /** Passed through to {@link RadioactivityModel}'s counting-interval range. */ + readonly countingIntervalRange?: Range; + + /** Passed through to {@link RadioactivityModel}'s counting-interval arrow-button step. */ + readonly countingIntervalDelta?: number; + + /** Passed through to {@link RadioactivityModel}'s counting-interval decimal places. */ + readonly countingIntervalDecimalPlaces?: number; }; export class RadioactivityScreenModel implements TModel { @@ -59,6 +69,11 @@ export class RadioactivityScreenModel implements TModel { beepEnabledProperty: preferences.beepEnabledProperty, tubeVoltageProperty: preferences.tubeVoltageProperty, }, + ...(options.countingIntervalRange !== undefined && { countingIntervalRange: options.countingIntervalRange }), + ...(options.countingIntervalDelta !== undefined && { countingIntervalDelta: options.countingIntervalDelta }), + ...(options.countingIntervalDecimalPlaces !== undefined && { + countingIntervalDecimalPlaces: options.countingIntervalDecimalPlaces, + }), }); this.chartViewProperty = new Property(options.initialChartView); } diff --git a/src/common/view/AcquisitionPanel.ts b/src/common/view/AcquisitionPanel.ts index 4505022..840c5fa 100644 --- a/src/common/view/AcquisitionPanel.ts +++ b/src/common/view/AcquisitionPanel.ts @@ -16,11 +16,7 @@ import { Checkbox, RectangularPushButton } from "scenerystack/sun"; import type { ScreenControlA11yStrings } from "../../i18n/StringManager.js"; import { StringManager } from "../../i18n/StringManager.js"; import RadioactivityAndStatisticsColors from "../../RadioactivityAndStatisticsColors.js"; -import { - CONTROL_PANEL_WIDTH, - COUNTING_INTERVAL_RANGE, - SAMPLES_PER_RUN_RANGE, -} from "../../RadioactivityAndStatisticsConstants.js"; +import { CONTROL_PANEL_WIDTH, SAMPLES_PER_RUN_RANGE } from "../../RadioactivityAndStatisticsConstants.js"; import { createExportFilename, samplesToCsv } from "../model/csvExport.js"; import type { RadioactivityModel } from "../model/RadioactivityModel.js"; import { FLAT_PANEL_PUSH_BUTTON_OPTIONS, LIGHT_SURFACE_TEXT_FILL } from "../RadioactivityAndStatisticsButtonOptions.js"; @@ -51,14 +47,14 @@ export class AcquisitionPanel extends RadioactivityAndStatisticsPanel { const intervalControl = new NumberControl( strings.intervalStringProperty, model.countingIntervalProperty, - COUNTING_INTERVAL_RANGE, + model.countingIntervalRange, { ...SIM_NUMBER_CONTROL_OPTIONS, - delta: 0.5, + delta: model.countingIntervalDelta, titleNodeOptions, numberDisplayOptions: { valuePattern: "{{value}} s", - decimalPlaces: 1, + decimalPlaces: model.countingIntervalDecimalPlaces, textOptions: { font: new PhetFont(13) }, }, accessibleName: a11y.intervalControlStringProperty, diff --git a/src/common/view/SourcePanel.ts b/src/common/view/SourcePanel.ts index dc970f4..ca5f674 100644 --- a/src/common/view/SourcePanel.ts +++ b/src/common/view/SourcePanel.ts @@ -16,11 +16,11 @@ import { DerivedProperty, type TReadOnlyProperty } from "scenerystack/axon"; import { toFixed } from "scenerystack/dot"; import { Circle, HBox, type Node, Text, VBox } from "scenerystack/scenery"; import { NumberControl, PhetFont } from "scenerystack/scenery-phet"; -import { RectangularPushButton } from "scenerystack/sun"; +import { AquaRadioButtonGroup, RectangularPushButton } from "scenerystack/sun"; import type { ScreenControlA11yStrings } from "../../i18n/StringManager.js"; import { StringManager } from "../../i18n/StringManager.js"; import RadioactivityAndStatisticsColors from "../../RadioactivityAndStatisticsColors.js"; -import { CONTROL_PANEL_WIDTH } from "../../RadioactivityAndStatisticsConstants.js"; +import { CONTROL_PANEL_WIDTH, SPEED_MULTIPLIER_CHOICES } from "../../RadioactivityAndStatisticsConstants.js"; import { getWebBluetoothStatus, WebBluetoothStatus } from "../hardware/webBluetoothSupport.js"; import { ConnectionState } from "../model/ConnectionState.js"; import { CountSourceType, type CountSourceTypeValue } from "../model/CountSource.js"; @@ -79,7 +79,22 @@ export class SourcePanel extends RadioactivityAndStatisticsPanel { } } -/** Just the simulated activity slider. */ +/** The label for one entry of the speed radio group. */ +function speedLabelStringProperty( + multiplier: number, + strings: ReturnType, +): TReadOnlyProperty { + switch (multiplier) { + case 10: + return strings.speed10xStringProperty; + case 100: + return strings.speed100xStringProperty; + default: + return strings.speed1xStringProperty; + } +} + +/** The simulated activity slider and the speed control that fast-forwards its clock. */ function createSimulatedControls( model: RadioactivityModel, strings: ReturnType, @@ -105,10 +120,34 @@ function createSimulatedControls( }, ); + const speedLabelText = new Text(strings.speedLabelStringProperty, { + font: new PhetFont(13), + fill: RadioactivityAndStatisticsColors.textColorProperty, + maxWidth: CONTROL_PANEL_WIDTH - 40, + }); + + const speedRadioGroup = new AquaRadioButtonGroup( + model.speedMultiplierProperty, + SPEED_MULTIPLIER_CHOICES.map((multiplier) => ({ + value: multiplier, + createNode: () => + new Text(speedLabelStringProperty(multiplier, strings), { + font: new PhetFont(13), + fill: RadioactivityAndStatisticsColors.textColorProperty, + }), + })), + { + orientation: "horizontal", + spacing: 10, + radioButtonOptions: { radius: 7 }, + accessibleName: a11y.speedRadioGroupStringProperty, + }, + ); + return new VBox({ align: "left", spacing: 6, - children: [activityControl], + children: [activityControl, speedLabelText, speedRadioGroup], }); } diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index c5e976a..0eb6989 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -65,6 +65,7 @@ export type ScreenControlA11yStrings = { readonly clearButtonStringProperty: ReadOnlyProperty; readonly exportButtonStringProperty: ReadOnlyProperty; readonly chartViewRadioGroupStringProperty: ReadOnlyProperty; + readonly speedRadioGroupStringProperty: ReadOnlyProperty; readonly poissonCheckboxStringProperty: ReadOnlyProperty; readonly gaussianPredictionCheckboxStringProperty: ReadOnlyProperty; readonly gaussianFitCheckboxStringProperty: ReadOnlyProperty; diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 38170ac..9f30b7f 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -17,7 +17,11 @@ "unsupportedBrowser": "This browser cannot reach Bluetooth devices. Use Chrome or Edge.", "insecureContext": "Bluetooth needs a secure (HTTPS) page.", "tubeVoltage": "Tube voltage", - "rawRegister": "Raw register" + "rawRegister": "Raw register", + "speedLabel": "Simulation speed", + "speed1x": "1×", + "speed10x": "10×", + "speed100x": "100×" }, "acquisition": { "title": "Measurement", @@ -81,7 +85,7 @@ "simulation": { "screenSummary": { "playArea": "The play area shows a histogram or a count-rate chart of a simulated counting source, alongside a panel of summary statistics.", - "controlArea": "The control area lets you set the simulated source's activity, choose which chart is shown, set the counting interval and run length, start and stop recording, choose which theoretical curves to show, set the histogram bin width, export the data, and reset the simulation.", + "controlArea": "The control area lets you set the simulated source's activity, choose a simulation speed, choose which chart is shown, set the counting interval and run length, start and stop recording, choose which theoretical curves to show, set the histogram bin width, export the data, and reset the simulation.", "interactionHint": "Collect a run of measurements, then switch between the histogram and the count-rate chart to see what the data shows." }, "currentDetails": "No measurements have been collected yet.", @@ -99,6 +103,7 @@ "clearButton": "Clear the collected measurements", "exportButton": "Export the collected measurements as a CSV file", "chartViewRadioGroup": "Choose whether to show the histogram or the count rate over time", + "speedRadioGroup": "Choose how much faster than real time the simulation runs", "poissonCheckbox": "Show the Poisson prediction", "gaussianPredictionCheckbox": "Show the Gaussian prediction with sigma equal to the square root of the mean", "gaussianFitCheckbox": "Show the best-fit Gaussian", @@ -127,6 +132,7 @@ "clearButton": "Clear the collected measurements", "exportButton": "Export the collected measurements as a CSV file", "chartViewRadioGroup": "Choose whether to show the histogram or the count rate over time", + "speedRadioGroup": "Choose how much faster than real time the simulation runs", "poissonCheckbox": "Show the Poisson prediction", "gaussianPredictionCheckbox": "Show the Gaussian prediction with sigma equal to the square root of the mean", "gaussianFitCheckbox": "Show the best-fit Gaussian", diff --git a/src/i18n/strings_es.json b/src/i18n/strings_es.json index a28f3a8..048336b 100644 --- a/src/i18n/strings_es.json +++ b/src/i18n/strings_es.json @@ -17,7 +17,11 @@ "unsupportedBrowser": "Este navegador no puede acceder a dispositivos Bluetooth. Use Chrome o Edge.", "insecureContext": "El Bluetooth requiere una página segura (HTTPS).", "tubeVoltage": "Voltaje del tubo", - "rawRegister": "Registro sin procesar" + "rawRegister": "Registro sin procesar", + "speedLabel": "Velocidad de la simulación", + "speed1x": "1×", + "speed10x": "10×", + "speed100x": "100×" }, "acquisition": { "title": "Medición", @@ -81,7 +85,7 @@ "simulation": { "screenSummary": { "playArea": "El área de juego muestra un histograma o un gráfico de la tasa de conteo de una fuente de conteo simulada, junto a un panel de estadísticas.", - "controlArea": "El área de control permite ajustar la actividad de la fuente simulada, elegir qué gráfico se muestra, fijar el intervalo de conteo y la longitud de la serie, iniciar y detener la grabación, elegir qué curvas teóricas mostrar, fijar el ancho de clase, exportar los datos y reiniciar la simulación.", + "controlArea": "El área de control permite ajustar la actividad de la fuente simulada, elegir la velocidad de la simulación, elegir qué gráfico se muestra, fijar el intervalo de conteo y la longitud de la serie, iniciar y detener la grabación, elegir qué curvas teóricas mostrar, fijar el ancho de clase, exportar los datos y reiniciar la simulación.", "interactionHint": "Recoja una serie de mediciones y luego alterne entre el histograma y el gráfico de la tasa de conteo para ver lo que muestran los datos." }, "currentDetails": "Todavía no se ha recogido ninguna medición.", @@ -99,6 +103,7 @@ "clearButton": "Borrar las mediciones recogidas", "exportButton": "Exportar las mediciones recogidas como archivo CSV", "chartViewRadioGroup": "Elegir si se muestra el histograma o la tasa de conteo en el tiempo", + "speedRadioGroup": "Elegir cuánto más rápido que el tiempo real se ejecuta la simulación", "poissonCheckbox": "Mostrar la predicción de Poisson", "gaussianPredictionCheckbox": "Mostrar la predicción gaussiana con sigma igual a la raíz cuadrada de la media", "gaussianFitCheckbox": "Mostrar la gaussiana de mejor ajuste", @@ -127,6 +132,7 @@ "clearButton": "Borrar las mediciones recogidas", "exportButton": "Exportar las mediciones recogidas como archivo CSV", "chartViewRadioGroup": "Elegir si se muestra el histograma o la tasa de conteo en el tiempo", + "speedRadioGroup": "Elegir cuánto más rápido que el tiempo real se ejecuta la simulación", "poissonCheckbox": "Mostrar la predicción de Poisson", "gaussianPredictionCheckbox": "Mostrar la predicción gaussiana con sigma igual a la raíz cuadrada de la media", "gaussianFitCheckbox": "Mostrar la gaussiana de mejor ajuste", diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 0fc3f9a..5bda9bf 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -17,7 +17,11 @@ "unsupportedBrowser": "Ce navigateur ne peut pas accéder aux appareils Bluetooth. Utilisez Chrome ou Edge.", "insecureContext": "Le Bluetooth exige une page sécurisée (HTTPS).", "tubeVoltage": "Tension du tube", - "rawRegister": "Registre brut" + "rawRegister": "Registre brut", + "speedLabel": "Vitesse de la simulation", + "speed1x": "1×", + "speed10x": "10×", + "speed100x": "100×" }, "acquisition": { "title": "Mesure", @@ -81,7 +85,7 @@ "simulation": { "screenSummary": { "playArea": "La zone de jeu affiche un histogramme ou un graphique du taux de comptage d'une source de comptage simulée, à côté d'un panneau de statistiques.", - "controlArea": "La zone de contrôle permet de régler l'activité de la source simulée, de choisir le graphique affiché, de régler l'intervalle de comptage et la longueur de la série, de démarrer et d'arrêter l'enregistrement, de choisir les courbes théoriques affichées, de régler la largeur de classe, d'exporter les données et de réinitialiser la simulation.", + "controlArea": "La zone de contrôle permet de régler l'activité de la source simulée, de choisir la vitesse de la simulation, de choisir le graphique affiché, de régler l'intervalle de comptage et la longueur de la série, de démarrer et d'arrêter l'enregistrement, de choisir les courbes théoriques affichées, de régler la largeur de classe, d'exporter les données et de réinitialiser la simulation.", "interactionHint": "Recueillez une série de mesures, puis basculez entre l'histogramme et le graphique du taux de comptage pour voir ce que les données montrent." }, "currentDetails": "Aucune mesure n'a encore été recueillie.", @@ -99,6 +103,7 @@ "clearButton": "Effacer les mesures recueillies", "exportButton": "Exporter les mesures recueillies en fichier CSV", "chartViewRadioGroup": "Choisir d'afficher l'histogramme ou le taux de comptage dans le temps", + "speedRadioGroup": "Choisir à quelle vitesse, par rapport au temps réel, la simulation s'exécute", "poissonCheckbox": "Afficher la prédiction de Poisson", "gaussianPredictionCheckbox": "Afficher la prédiction gaussienne avec sigma égal à la racine carrée de la moyenne", "gaussianFitCheckbox": "Afficher la gaussienne ajustée", @@ -127,6 +132,7 @@ "clearButton": "Effacer les mesures recueillies", "exportButton": "Exporter les mesures recueillies en fichier CSV", "chartViewRadioGroup": "Choisir d'afficher l'histogramme ou le taux de comptage dans le temps", + "speedRadioGroup": "Choisir à quelle vitesse, par rapport au temps réel, la simulation s'exécute", "poissonCheckbox": "Afficher la prédiction de Poisson", "gaussianPredictionCheckbox": "Afficher la prédiction gaussienne avec sigma égal à la racine carrée de la moyenne", "gaussianFitCheckbox": "Afficher la gaussienne ajustée", diff --git a/src/simulation/model/SimulationModel.ts b/src/simulation/model/SimulationModel.ts index e3870a5..bb3307e 100644 --- a/src/simulation/model/SimulationModel.ts +++ b/src/simulation/model/SimulationModel.ts @@ -13,6 +13,11 @@ import { ChartViewType } from "../../common/model/ChartViewType.js"; import { CountSourceType } from "../../common/model/CountSource.js"; import { RadioactivityScreenModel } from "../../common/model/RadioactivityScreenModel.js"; import type { RadioactivityAndStatisticsPreferencesModel } from "../../preferences/RadioactivityAndStatisticsPreferencesModel.js"; +import { + SIMULATION_COUNTING_INTERVAL_DECIMAL_PLACES, + SIMULATION_COUNTING_INTERVAL_DELTA, + SIMULATION_COUNTING_INTERVAL_RANGE, +} from "../../RadioactivityAndStatisticsConstants.js"; export class SimulationModel extends RadioactivityScreenModel { public constructor(preferences: RadioactivityAndStatisticsPreferencesModel) { @@ -21,6 +26,11 @@ export class SimulationModel extends RadioactivityScreenModel { // Opens on the fluctuating trace, the sim's central point, before any // statistic has been computed from it. initialChartView: ChartViewType.COUNT_RATE, + // Not limited by any hardware polling rate the way a real Geiger + // counter is, so this screen alone offers the finer 0.25 s interval. + countingIntervalRange: SIMULATION_COUNTING_INTERVAL_RANGE, + countingIntervalDelta: SIMULATION_COUNTING_INTERVAL_DELTA, + countingIntervalDecimalPlaces: SIMULATION_COUNTING_INTERVAL_DECIMAL_PLACES, }); } } diff --git a/tests/common/model/RadioactivityModel.test.ts b/tests/common/model/RadioactivityModel.test.ts index e84e053..ba5fcb1 100644 --- a/tests/common/model/RadioactivityModel.test.ts +++ b/tests/common/model/RadioactivityModel.test.ts @@ -6,6 +6,7 @@ * the counting logic rather than floating-point accumulation. */ +import { Range } from "scenerystack/dot"; import { beforeEach, describe, expect, it } from "vitest"; import { CountSourceType } from "../../../src/common/model/CountSource.js"; import { RadioactivityModel } from "../../../src/common/model/RadioactivityModel.js"; @@ -256,6 +257,71 @@ describe("RadioactivityModel", () => { }); }); +describe("RadioactivityModel counting-interval configuration", () => { + it("defaults to the standard 0.5-20 s range", () => { + const model = new RadioactivityModel(); + expect(model.countingIntervalRange.min).toBe(0.5); + expect(model.countingIntervalRange.max).toBe(20); + }); + + it("accepts a narrower or wider range from options, e.g. the Simulation screen's 0.25 s floor", () => { + const model = new RadioactivityModel({ countingIntervalRange: new Range(0.25, 20) }); + expect(model.countingIntervalRange.min).toBe(0.25); + model.countingIntervalProperty.value = 0.25; + expect(model.countingIntervalProperty.value).toBe(0.25); + }); +}); + +describe("RadioactivityModel speed multiplier", () => { + let model: RadioactivityModel; + + beforeEach(() => { + model = new RadioactivityModel(); + model.countingIntervalProperty.value = 1; + model.isContinuousProperty.value = true; + }); + + it("defaults to real time", () => { + expect(model.speedMultiplierProperty.value).toBe(1); + }); + + it("runs the counting clock faster than real time when increased", () => { + model.startRecording(); + advance(model, 1); // 1 real second at 1x -> 1 sample. + expect(model.samplesProperty.value).toHaveLength(1); + + model.speedMultiplierProperty.value = 10; + advance(model, 1); // 1 more real second at 10x -> 10 more samples. + expect(model.samplesProperty.value).toHaveLength(11); + }); + + it("resets to real time", () => { + model.speedMultiplierProperty.value = 100; + model.reset(); + expect(model.speedMultiplierProperty.value).toBe(1); + }); + + it("gives every interval completed within one frame its own independent count, instead of dumping the frame's total into the first and leaving the rest at zero", () => { + // A mean this high makes a true zero-count interval astronomically + // unlikely, so any zero exposes intervals sharing a stale, already-spent + // count rather than each drawing its own. + model.simulatedSource.activityProperty.value = 1000; + model.countingIntervalProperty.value = 0.25; + model.speedMultiplierProperty.value = 100; + model.startRecording(); + + // One real 60 fps frame's worth of simulated time (100x * 1/60 s ≈ 1.67 s) + // spans about 6-7 counting intervals, well past the first. + model.step(1 / 60); + + const counts = model.samplesProperty.value.map((sample) => sample.counts); + expect(counts.length).toBeGreaterThan(1); + for (const count of counts) { + expect(count).toBeGreaterThan(0); + } + }); +}); + describe("SimulatedCountSource statistics", () => { it("produces counts whose variance matches their mean", () => { // The defining property of a Poisson process, and the thing the sim asks