Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions src/RadioactivityAndStatisticsConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
81 changes: 69 additions & 12 deletions src/common/model/RadioactivityModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand All @@ -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<number>;

/** How many intervals a run collects before recording stops on its own. */
public readonly samplesPerRunProperty: NumberProperty;

Expand Down Expand Up @@ -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<number>(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);

Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions src/common/model/RadioactivityScreenModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<ChartViewTypeValue>(options.initialChartView);
}
Expand Down
12 changes: 4 additions & 8 deletions src/common/view/AcquisitionPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
47 changes: 43 additions & 4 deletions src/common/view/SourcePanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof StringManager.prototype.getSourceStrings>,
): TReadOnlyProperty<string> {
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<typeof StringManager.prototype.getSourceStrings>,
Expand All @@ -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],
});
}

Expand Down
1 change: 1 addition & 0 deletions src/i18n/StringManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type ScreenControlA11yStrings = {
readonly clearButtonStringProperty: ReadOnlyProperty<string>;
readonly exportButtonStringProperty: ReadOnlyProperty<string>;
readonly chartViewRadioGroupStringProperty: ReadOnlyProperty<string>;
readonly speedRadioGroupStringProperty: ReadOnlyProperty<string>;
readonly poissonCheckboxStringProperty: ReadOnlyProperty<string>;
readonly gaussianPredictionCheckboxStringProperty: ReadOnlyProperty<string>;
readonly gaussianFitCheckboxStringProperty: ReadOnlyProperty<string>;
Expand Down
10 changes: 8 additions & 2 deletions src/i18n/strings_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading