From 342d23d2c89f2ad9c51a200984a6452246d2f9b2 Mon Sep 17 00:00:00 2001 From: pixelzoom Date: Tue, 7 May 2024 10:42:28 -0600 Subject: [PATCH] factor out DiffusionParticleSystem with IOType, https://github.com/phetsims/gas-properties/issues/231 --- js/common/model/BaseModel.ts | 9 +- .../model/DiffusionCollisionDetector.ts | 9 +- js/diffusion/model/DiffusionData.ts | 17 +- js/diffusion/model/DiffusionModel.ts | 388 ++---------------- js/diffusion/model/DiffusionParticleSystem.ts | 378 +++++++++++++++++ .../view/DiffusionParticleSystemNode.ts | 6 +- js/diffusion/view/DiffusionScreenView.ts | 19 +- js/diffusion/view/DiffusionSettingsNode.ts | 8 +- 8 files changed, 436 insertions(+), 398 deletions(-) create mode 100644 js/diffusion/model/DiffusionParticleSystem.ts diff --git a/js/common/model/BaseModel.ts b/js/common/model/BaseModel.ts index cc48ea2a..401c3fe2 100644 --- a/js/common/model/BaseModel.ts +++ b/js/common/model/BaseModel.ts @@ -26,7 +26,7 @@ import gasProperties from '../../gasProperties.js'; import GasPropertiesConstants from '../GasPropertiesConstants.js'; import TimeTransform from './TimeTransform.js'; import PickRequired from '../../../../phet-core/js/types/PickRequired.js'; -import PhetioObject, { PhetioObjectOptions } from '../../../../tandem/js/PhetioObject.js'; +import { PhetioObjectOptions } from '../../../../tandem/js/PhetioObject.js'; import Tandem from '../../../../tandem/js/Tandem.js'; import PickOptional from '../../../../phet-core/js/types/PickOptional.js'; @@ -50,7 +50,7 @@ export type BaseModelOptions = SelfOptions & PickOptional & // because subclass DiffusionModel has state PickRequired; -export default class BaseModel extends PhetioObject implements TModel { +export default class BaseModel implements TModel { // transform between model and view coordinate frames public readonly modelViewTransform: ModelViewTransform2; @@ -77,12 +77,9 @@ export default class BaseModel extends PhetioObject implements TModel { hasTimeSpeedFeature: false, // PhetioObjectOptions - isDisposable: false, - phetioState: false + isDisposable: false }, providedOptions ); - super( options ); - this.modelViewTransform = ModelViewTransform2.createOffsetXYScaleMapping( options.modelOriginOffset, MODEL_VIEW_SCALE, diff --git a/js/diffusion/model/DiffusionCollisionDetector.ts b/js/diffusion/model/DiffusionCollisionDetector.ts index 73c24255..aa473786 100644 --- a/js/diffusion/model/DiffusionCollisionDetector.ts +++ b/js/diffusion/model/DiffusionCollisionDetector.ts @@ -13,17 +13,14 @@ import Vector2 from '../../../../dot/js/Vector2.js'; import CollisionDetector from '../../common/model/CollisionDetector.js'; import gasProperties from '../../gasProperties.js'; import DiffusionContainer from './DiffusionContainer.js'; -import DiffusionParticle1 from './DiffusionParticle1.js'; -import DiffusionParticle2 from './DiffusionParticle2.js'; +import DiffusionParticleSystem from './DiffusionParticleSystem.js'; export default class DiffusionCollisionDetector extends CollisionDetector { private readonly diffusionContainer: DiffusionContainer; - public constructor( diffusionContainer: DiffusionContainer, - particles1: DiffusionParticle1[], - particles2: DiffusionParticle2[] ) { - super( diffusionContainer, [ particles1, particles2 ], new BooleanProperty( true ) ); + public constructor( diffusionContainer: DiffusionContainer, particleSystem: DiffusionParticleSystem ) { + super( diffusionContainer, [ particleSystem.particles1, particleSystem.particles2 ], new BooleanProperty( true ) ); this.diffusionContainer = diffusionContainer; } diff --git a/js/diffusion/model/DiffusionData.ts b/js/diffusion/model/DiffusionData.ts index fad96ec5..87d2a452 100644 --- a/js/diffusion/model/DiffusionData.ts +++ b/js/diffusion/model/DiffusionData.ts @@ -17,8 +17,7 @@ import NullableIO from '../../../../tandem/js/types/NullableIO.js'; import NumberIO from '../../../../tandem/js/types/NumberIO.js'; import GasPropertiesConstants from '../../common/GasPropertiesConstants.js'; import gasProperties from '../../gasProperties.js'; -import DiffusionParticle1 from './DiffusionParticle1.js'; -import DiffusionParticle2 from './DiffusionParticle2.js'; +import DiffusionParticleSystem from './DiffusionParticleSystem.js'; // constants const NUMBER_OF_PARTICLES_PROPERTY_OPTIONS: NumberPropertyOptions = { @@ -42,7 +41,7 @@ export default class DiffusionData { // null when there are no particles in this half of the container. public readonly averageTemperatureProperty: Property; - public constructor( bounds: Bounds2, particles1: DiffusionParticle1[], particles2: DiffusionParticle2[], + public constructor( bounds: Bounds2, particleSystem: DiffusionParticleSystem, leftOrRightString: 'left' | 'right', tandem: Tandem ) { this.bounds = bounds; @@ -73,7 +72,7 @@ export default class DiffusionData { phetioDocumentation: `Average temperature in the ${leftOrRightString} half of the container.` } ); - this.update( particles1, particles2 ); + this.update( particleSystem ); } public dispose(): void { @@ -83,15 +82,15 @@ export default class DiffusionData { /** * Updates Properties based on the contents of the particle arrays. */ - public update( particles1: DiffusionParticle1[], particles2: DiffusionParticle2[] ): void { + public update( particleSystem: DiffusionParticleSystem ): void { let numberOfParticles1 = 0; let numberOfParticles2 = 0; let totalKE = 0; // Contribution by DiffusionParticle1 species - for ( let i = particles1.length - 1; i >= 0; i-- ) { - const particle = particles1[ i ]; + for ( let i = particleSystem.particles1.length - 1; i >= 0; i-- ) { + const particle = particleSystem.particles1[ i ]; if ( this.bounds.containsCoordinates( particle.x, particle.y ) ) { numberOfParticles1++; totalKE += particle.getKineticEnergy(); @@ -100,8 +99,8 @@ export default class DiffusionData { // Contribution by DiffusionParticle2 species. // Note that there's a wee bit of code duplication here, but it gains us some iteration efficiency. - for ( let i = particles2.length - 1; i >= 0; i-- ) { - const particle = particles2[ i ]; + for ( let i = particleSystem.particles2.length - 1; i >= 0; i-- ) { + const particle = particleSystem.particles2[ i ]; if ( this.bounds.containsCoordinates( particle.x, particle.y ) ) { numberOfParticles2++; totalKE += particle.getKineticEnergy(); diff --git a/js/diffusion/model/DiffusionModel.ts b/js/diffusion/model/DiffusionModel.ts index 59d7873c..0aa2aa9c 100644 --- a/js/diffusion/model/DiffusionModel.ts +++ b/js/diffusion/model/DiffusionModel.ts @@ -6,242 +6,73 @@ * @author Chris Malley (PixelZoom, Inc.) */ -import DerivedProperty from '../../../../axon/js/DerivedProperty.js'; import Multilink from '../../../../axon/js/Multilink.js'; -import Property, { PropertyOptions } from '../../../../axon/js/Property.js'; -import TReadOnlyProperty from '../../../../axon/js/TReadOnlyProperty.js'; -import Bounds2 from '../../../../dot/js/Bounds2.js'; -import dotRandom from '../../../../dot/js/dotRandom.js'; import Vector2 from '../../../../dot/js/Vector2.js'; import Tandem from '../../../../tandem/js/Tandem.js'; -import NullableIO from '../../../../tandem/js/types/NullableIO.js'; -import NumberIO from '../../../../tandem/js/types/NumberIO.js'; -import GasPropertiesConstants from '../../common/GasPropertiesConstants.js'; import BaseModel from '../../common/model/BaseModel.js'; -import ParticleUtils from '../../common/model/ParticleUtils.js'; import gasProperties from '../../gasProperties.js'; import DiffusionCollisionDetector from './DiffusionCollisionDetector.js'; import DiffusionContainer from './DiffusionContainer.js'; import DiffusionData from './DiffusionData.js'; -import DiffusionParticle1, { DiffusionParticle1StateObject } from './DiffusionParticle1.js'; -import DiffusionParticle2, { DiffusionParticle2StateObject } from './DiffusionParticle2.js'; -import DiffusionSettings from './DiffusionSettings.js'; -import ParticleFlowRateModel from './ParticleFlowRateModel.js'; -import Particle, { ParticleOptions } from '../../common/model/Particle.js'; -import PickRequired from '../../../../phet-core/js/types/PickRequired.js'; -import { combineOptions } from '../../../../phet-core/js/optionize.js'; -import isSettingPhetioStateProperty from '../../../../tandem/js/isSettingPhetioStateProperty.js'; -import DiffusionParticle from './DiffusionParticle.js'; -import ArrayIO from '../../../../tandem/js/types/ArrayIO.js'; -import IOType from '../../../../tandem/js/types/IOType.js'; - -// constants -const CENTER_OF_MASS_PROPERTY_OPTIONS = { - units: 'pm', - valueType: [ 'number', null ], - phetioValueType: NullableIO( NumberIO ), - phetioReadOnly: true // derived from the state of the particle system -}; - -// Options to createParticle functions -type CreateParticleOptions = PickRequired; - -// This should match DIFFUSION_MODEL_STATE_SCHEMA, but with JavaScript types. -type DiffusionModelStateObject = { - particles1: DiffusionParticle1StateObject[]; - particles2: DiffusionParticle2StateObject[]; -}; - -// This should match DiffusionModelStateObject, but with IOTypes. -const DIFFUSION_MODEL_STATE_SCHEMA = { - particles1: ArrayIO( DiffusionParticle1.DiffusionParticle1IO ), - particles2: ArrayIO( DiffusionParticle2.DiffusionParticle2IO ) -}; +import DiffusionParticleSystem from './DiffusionParticleSystem.js'; export default class DiffusionModel extends BaseModel { public readonly container: DiffusionContainer; - // particles of each species, together these make up the 'particle system' - public readonly particles1: DiffusionParticle1[]; - public readonly particles2: DiffusionParticle2[]; + public readonly particleSystem: DiffusionParticleSystem; - // settings for the particle types 1 and 2, before the divider is removed - public readonly particle1Settings: DiffusionSettings; - public readonly particle2Settings: DiffusionSettings; - - // N, the total number of particles in the container - public readonly numberOfParticlesProperty: TReadOnlyProperty; + // Handles collisions between particles and a vertical divider. + public readonly collisionDetector: DiffusionCollisionDetector; // data for the left and right sides of the container, appears in Data accordion box public readonly leftData: DiffusionData; public readonly rightData: DiffusionData; - // Center of mass for each particle species, in pm, relative to the center (divider) of the container. - // null when there are no particles in the container. This is actually an x offset, not a 'center' Vector2. - // But we decided to keep these Property names so that they match the associated checkbox labels. - // See https://github.com/phetsims/gas-properties/issues/228 - public readonly centerOfMass1Property: Property; - public readonly centerOfMass2Property: Property; - - // flow rate model for each particle species - public readonly particle1FlowRateModel: ParticleFlowRateModel; - public readonly particle2FlowRateModel: ParticleFlowRateModel; - - // Handles collisions between particles and a vertical divider. - public readonly collisionDetector: DiffusionCollisionDetector; - public constructor( tandem: Tandem ) { super( { modelOriginOffset: new Vector2( 670, 520 ), stopwatchPosition: new Vector2( 60, 50 ), hasTimeSpeedFeature: true, - tandem: tandem, - phetioType: DiffusionModel.DiffusionModelIO, - phetioState: true // Override phetioState: false in superclass BaseModel. + tandem: tandem } ); this.container = new DiffusionContainer( tandem.createTandem( 'container' ) ); - this.particles1 = []; - this.particles2 = []; - - // Factoring out class DiffusionParticleSystem is too complicated. So use an intermediate 'particleSystem' tandem - // to provide that structure for the Studio tree. - const particleSystemTandem = tandem.createTandem( 'particleSystem' ); - const particle1Tandem = particleSystemTandem.createTandem( 'particle1' ); - const particle2Tandem = particleSystemTandem.createTandem( 'particle2' ); + this.particleSystem = new DiffusionParticleSystem( this.container, this.isPlayingProperty, tandem.createTandem( 'particleSystem' ) ); - this.particle1Settings = new DiffusionSettings( particle1Tandem.createTandem( 'settings' ) ); - this.particle2Settings = new DiffusionSettings( particle2Tandem.createTandem( 'settings' ) ); - - // Synchronize particle counts and arrays. - const createDiffusionParticle1 = ( options: CreateParticleOptions ) => new DiffusionParticle1( options ); - this.particle1Settings.numberOfParticlesProperty.link( numberOfParticles => { - this.updateNumberOfParticles( numberOfParticles, - this.container.leftBounds, - this.particle1Settings, - this.particles1, - createDiffusionParticle1 ); - } ); - const createDiffusionParticle2 = ( options: CreateParticleOptions ) => new DiffusionParticle2( options ); - this.particle2Settings.numberOfParticlesProperty.link( numberOfParticles => { - this.updateNumberOfParticles( numberOfParticles, - this.container.rightBounds, - this.particle2Settings, - this.particles2, - createDiffusionParticle2 ); - } ); - - this.numberOfParticlesProperty = new DerivedProperty( - [ this.particle1Settings.numberOfParticlesProperty, this.particle2Settings.numberOfParticlesProperty ], - ( numberOfParticles1, numberOfParticles2 ) => { - - // Skip these assertions when PhET-iO state is being restored, because at least one of the arrays will - // definitely not be populated. See https://github.com/phetsims/gas-properties/issues/178 - if ( !isSettingPhetioStateProperty.value ) { - - // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. - // If you hit these assertions, then you need to add this listener later. This is a trade-off - // for using plain old Arrays instead of ObservableArrayDef. - assert && assert( this.particles1.length === numberOfParticles1, 'particles1 has not been populated yet' ); - assert && assert( this.particles2.length === numberOfParticles2, 'particles2 has not been populated yet' ); - } - return numberOfParticles1 + numberOfParticles2; - }, { - isValidValue: value => ( Number.isInteger( value ) && value >= 0 ), - phetioValueType: NumberIO, - tandem: particleSystemTandem.createTandem( 'numberOfParticlesProperty' ), - phetioFeatured: true, - phetioDocumentation: 'Total number of particles in the container.' - } ); + this.collisionDetector = new DiffusionCollisionDetector( this.container, this.particleSystem ); const dataTandem = tandem.createTandem( 'data' ); - this.leftData = new DiffusionData( this.container.leftBounds, this.particles1, this.particles2, - 'left', dataTandem.createTandem( 'leftData' ) ); - this.rightData = new DiffusionData( this.container.rightBounds, this.particles1, this.particles2, - 'right', dataTandem.createTandem( 'rightData' ) ); - - this.centerOfMass1Property = new Property( null, - combineOptions>( {}, CENTER_OF_MASS_PROPERTY_OPTIONS, { - tandem: particle1Tandem.createTandem( 'centerOfMassProperty' ), - phetioReadOnly: true, - phetioFeatured: true, - phetioDocumentation: 'Center of mass for particles of type 1. This is the x offset from the center of the container.' - } ) ); - - this.centerOfMass2Property = new Property( null, - combineOptions>( {}, CENTER_OF_MASS_PROPERTY_OPTIONS, { - tandem: particle2Tandem.createTandem( 'centerOfMassProperty' ), - phetioReadOnly: true, - phetioFeatured: true, - phetioDocumentation: 'Center of mass for particles of type 2. This is the x offset from the center of the container.' - } ) ); - - this.particle1FlowRateModel = new ParticleFlowRateModel( this.container.dividerX, this.particles1, particle1Tandem ); - this.particle2FlowRateModel = new ParticleFlowRateModel( this.container.dividerX, this.particles2, particle2Tandem ); - - this.collisionDetector = new DiffusionCollisionDetector( this.container, this.particles1, this.particles2 ); - - // Update mass and temperature of existing particles. This adjusts speed of the particles. - Multilink.multilink( - [ this.particle1Settings.massProperty, this.particle1Settings.initialTemperatureProperty ], - ( mass, initialTemperature ) => { - updateMassAndTemperature( mass, initialTemperature, this.particles1 ); - } ); - Multilink.multilink( - [ this.particle2Settings.massProperty, this.particle2Settings.initialTemperatureProperty ], - ( mass, initialTemperature ) => { - updateMassAndTemperature( mass, initialTemperature, this.particles2 ); - } ); - - // Update data if initial temperature settings are changed while the sim is paused. - Multilink.multilink( - [ this.particle1Settings.initialTemperatureProperty, this.particle2Settings.initialTemperatureProperty ], + this.leftData = new DiffusionData( this.container.leftBounds, this.particleSystem, 'left', dataTandem.createTandem( 'leftData' ) ); + this.rightData = new DiffusionData( this.container.rightBounds, this.particleSystem, 'right', dataTandem.createTandem( 'rightData' ) ); + + // Update data if these settings are changed while the sim is paused. + Multilink.multilink( [ + this.particleSystem.particle1Settings.numberOfParticlesProperty, + this.particleSystem.particle2Settings.numberOfParticlesProperty, + this.particleSystem.particle1Settings.initialTemperatureProperty, + this.particleSystem.particle2Settings.initialTemperatureProperty + ], () => { if ( !this.isPlayingProperty.value ) { this.updateData(); } } ); - // Update radii of existing particles. - this.particle1Settings.radiusProperty.link( radius => { - updateRadius( radius, this.particles1, this.container.leftBounds, this.isPlayingProperty.value ); - } ); - this.particle2Settings.radiusProperty.link( radius => { - updateRadius( radius, this.particles2, this.container.rightBounds, this.isPlayingProperty.value ); - } ); - - // When the divider is restored, create a new initial state with same numbers of particles. + // When the divider is restored, create a new initial state (and new sets of particles) with same settings. this.container.isDividedProperty.link( isDivided => { if ( isDivided ) { - - // Restarts the experiment with the same settings. - // This causes the current sets of particles to be deleted, and new sets of particles to be created. - this.particle1Settings.restart(); - this.particle2Settings.restart(); - - // Reset flow-rate models - this.particle1FlowRateModel.reset(); - this.particle2FlowRateModel.reset(); + this.particleSystem.restart(); } } ); } public override reset(): void { super.reset(); - this.container.reset(); - this.particle1Settings.reset(); - this.particle2Settings.reset(); - this.centerOfMass1Property.reset(); - this.centerOfMass2Property.reset(); - this.particle1FlowRateModel.reset(); - this.particle2FlowRateModel.reset(); - - assert && assert( this.particles1.length === 0, 'there should be no DiffusionParticle1 particles' ); - assert && assert( this.particles2.length === 0, 'there should be no DiffusionParticle2 particles' ); + this.particleSystem.reset(); } /** @@ -253,192 +84,23 @@ export default class DiffusionModel extends BaseModel { super.stepModelTime( dt ); - // Step particles - ParticleUtils.stepParticles( this.particles1, dt ); - ParticleUtils.stepParticles( this.particles2, dt ); - - // Particle Flow Rate model - if ( !this.container.isDividedProperty.value ) { - this.particle1FlowRateModel.step( dt ); - this.particle2FlowRateModel.step( dt ); - } + // Particle system + this.particleSystem.step( dt ); // Collision detection and response this.collisionDetector.update(); // Update other things that are based on the current state of the particle system. - this.updateCenterOfMass(); + this.particleSystem.updateCenterOfMass(); this.updateData(); } - /** - * Adjusts an array of particles to have the desired number of elements. - * @param numberOfParticles - desired number of particles - * @param positionBounds - initial position will be inside this bounds - * @param settings - * @param particles - array of particles that corresponds to newValue and oldValue - * @param createParticle - creates a Particle instance - */ - private updateNumberOfParticles( numberOfParticles: number, positionBounds: Bounds2, settings: DiffusionSettings, - particles: Particle[], createParticle: ( options: CreateParticleOptions ) => Particle ): void { - - const delta = numberOfParticles - particles.length; - if ( delta !== 0 ) { - if ( delta > 0 ) { - addParticles( delta, positionBounds, settings, particles, createParticle ); - } - else { - ParticleUtils.removeLastParticles( -delta, particles ); - } - - // If paused, update things that would normally be handled by step. - if ( !this.isPlayingProperty.value ) { - this.updateCenterOfMass(); - this.updateData(); - } - } - } - - /** - * Updates the center of mass, as shown by the center-of-mass indicators. - */ - private updateCenterOfMass(): void { - this.centerOfMass1Property.value = ParticleUtils.getCenterXOfMass( this.particles1, this.container.width / 2 ); - this.centerOfMass2Property.value = ParticleUtils.getCenterXOfMass( this.particles2, this.container.width / 2 ); - } - /** * Updates the Data displayed for the left and right sides of the container. */ private updateData(): void { - this.leftData.update( this.particles1, this.particles2 ); - this.rightData.update( this.particles1, this.particles2 ); - } - - /** - * Serializes this instance of DiffusionModel. - */ - private toStateObject(): DiffusionModelStateObject { - return { - particles1: this.particles1.map( particle => DiffusionParticle1.DiffusionParticle1IO.toStateObject( particle ) ), - particles2: this.particles2.map( particle => DiffusionParticle2.DiffusionParticle2IO.toStateObject( particle ) ) - }; - } - - /** - * Deserializes an instance of DiffusionModel. - */ - private static applyState( diffusionModel: DiffusionModel, stateObject: DiffusionModelStateObject ): void { - - diffusionModel.particles1.length = 0; - stateObject.particles1.forEach( ( stateObject: DiffusionParticle1StateObject ) => { - diffusionModel.particles1.push( DiffusionParticle1.DiffusionParticle1IO.fromStateObject( stateObject ) ); - } ); - - diffusionModel.particles2.length = 0; - stateObject.particles2.forEach( ( stateObject: DiffusionParticle2StateObject ) => { - diffusionModel.particles2.push( DiffusionParticle2.DiffusionParticle2IO.fromStateObject( stateObject ) ); - } ); - } - - /** - * DiffusionModelIO handles serialization of the particle arrays. - * TODO https://github.com/phetsims/gas-properties/issues/231 What type of serialization is this? - */ - private static readonly DiffusionModelIO = new IOType( 'DiffusionModelIO', { - valueType: DiffusionModel, - defaultDeserializationMethod: 'applyState', - stateSchema: DIFFUSION_MODEL_STATE_SCHEMA, - toStateObject: diffusionModel => diffusionModel.toStateObject(), - applyState: DiffusionModel.applyState - } ); -} - -/** - * Adds n particles to the end of the specified array. Particles must be inside positionBounds. - */ -function addParticles( n: number, positionBounds: Bounds2, settings: DiffusionSettings, particles: Particle[], - createParticle: ( options: CreateParticleOptions ) => Particle ): void { - assert && assert( n > 0, `invalid n: ${n}` ); - - // Create n particles - for ( let i = 0; i < n; i++ ) { - - const particle = createParticle( { - mass: settings.massProperty.value, - radius: settings.radiusProperty.value - } ); - - // Position the particle at a random position within positionBounds, accounting for particle radius. - const x = dotRandom.nextDoubleBetween( positionBounds.minX + particle.radius, positionBounds.maxX - particle.radius ); - const y = dotRandom.nextDoubleBetween( positionBounds.minY + particle.radius, positionBounds.maxY - particle.radius ); - particle.setXY( x, y ); - assert && assert( positionBounds.containsCoordinates( particle.x, particle.y ), 'particle is outside of positionBounds' ); - - // Set the initial velocity, based on initial temperature and mass. - particle.setVelocityPolar( - // |v| = sqrt( 3kT / m ) - Math.sqrt( 3 * GasPropertiesConstants.BOLTZMANN * settings.initialTemperatureProperty.value / particle.mass ), - - // Random angle - dotRandom.nextDouble() * 2 * Math.PI - ); - - particles.push( particle ); - } -} - -/** - * When mass or initial temperature changes, update particles and adjust their speed accordingly. - */ -function updateMassAndTemperature( mass: number, temperature: number, particles: DiffusionParticle[] ): void { - assert && assert( mass > 0, `invalid mass: ${mass}` ); - assert && assert( temperature >= 0, `invalid temperature: ${temperature}` ); - assert && assert( Array.isArray( particles ), `invalid particles: ${particles}` ); - - for ( let i = particles.length - 1; i >= 0; i-- ) { - particles[ i ].setMass( mass ); - - // |v| = sqrt( 3kT / m ) - particles[ i ].setSpeed( Math.sqrt( 3 * GasPropertiesConstants.BOLTZMANN * temperature / mass ) ); - } -} - -/** - * Updates the radius for a set of particles. - * @param radius - * @param particles - * @param bounds - particles should be inside these bounds - * @param isPlaying - */ -function updateRadius( radius: number, particles: DiffusionParticle[], bounds: Bounds2, isPlaying: boolean ): void { - assert && assert( radius > 0, `invalid radius: ${radius}` ); - - for ( let i = particles.length - 1; i >= 0; i-- ) { - - const particle = particles[ i ]; - particle.setRadius( radius ); - - // If the sim is paused, then adjust the position of any particles are not fully inside the bounds. - // While the sim is playing, this adjustment will be handled by collision detection. - if ( !isPlaying ) { - - // constrain horizontally - if ( particle.left < bounds.minX ) { - particle.left = bounds.minX; - } - else if ( particle.right > bounds.maxX ) { - particle.right = bounds.maxX; - } - - // constrain vertically - if ( particle.bottom < bounds.minY ) { - particle.bottom = bounds.minY; - } - else if ( particle.top > bounds.maxY ) { - particle.top = bounds.maxY; - } - } + this.leftData.update( this.particleSystem ); + this.rightData.update( this.particleSystem ); } } diff --git a/js/diffusion/model/DiffusionParticleSystem.ts b/js/diffusion/model/DiffusionParticleSystem.ts new file mode 100644 index 00000000..93843642 --- /dev/null +++ b/js/diffusion/model/DiffusionParticleSystem.ts @@ -0,0 +1,378 @@ +// Copyright 2024, University of Colorado Boulder + +/** + * DiffusionParticleSystem is the particle system for the 'Diffusion' screen. + * + * @author Chris Malley (PixelZoom, Inc.) + */ + +import gasProperties from '../../gasProperties.js'; +import PhetioObject from '../../../../tandem/js/PhetioObject.js'; +import DiffusionParticle1, { DiffusionParticle1StateObject } from './DiffusionParticle1.js'; +import DiffusionParticle2, { DiffusionParticle2StateObject } from './DiffusionParticle2.js'; +import DiffusionSettings from './DiffusionSettings.js'; +import TReadOnlyProperty from '../../../../axon/js/TReadOnlyProperty.js'; +import Tandem from '../../../../tandem/js/Tandem.js'; +import DerivedProperty from '../../../../axon/js/DerivedProperty.js'; +import isSettingPhetioStateProperty from '../../../../tandem/js/isSettingPhetioStateProperty.js'; +import NumberIO from '../../../../tandem/js/types/NumberIO.js'; +import ParticleUtils from '../../common/model/ParticleUtils.js'; +import Multilink from '../../../../axon/js/Multilink.js'; +import GasPropertiesConstants from '../../common/GasPropertiesConstants.js'; +import DiffusionParticle from './DiffusionParticle.js'; +import Bounds2 from '../../../../dot/js/Bounds2.js'; +import DiffusionContainer from './DiffusionContainer.js'; +import PickRequired from '../../../../phet-core/js/types/PickRequired.js'; +import Particle, { ParticleOptions } from '../../common/model/Particle.js'; +import dotRandom from '../../../../dot/js/dotRandom.js'; +import Property, { PropertyOptions } from '../../../../axon/js/Property.js'; +import { combineOptions } from '../../../../phet-core/js/optionize.js'; +import NullableIO from '../../../../tandem/js/types/NullableIO.js'; +import IOType from '../../../../tandem/js/types/IOType.js'; +import ArrayIO from '../../../../tandem/js/types/ArrayIO.js'; +import ParticleFlowRateModel from './ParticleFlowRateModel.js'; + +const CENTER_OF_MASS_PROPERTY_OPTIONS = { + units: 'pm', + valueType: [ 'number', null ], + phetioValueType: NullableIO( NumberIO ), + phetioReadOnly: true // derived from the state of the particle system +}; + +// This should match DIFFUSION_PARTICLE_SYSTEM_SCHEMA, but with JavaScript types. +type DiffusionParticleSystemStateObject = { + particles1: DiffusionParticle1StateObject[]; + particles2: DiffusionParticle2StateObject[]; +}; + +// This should match DiffusionParticleSystemStateObject, but with IOTypes. +const DIFFUSION_PARTICLE_SYSTEM_SCHEMA = { + particles1: ArrayIO( DiffusionParticle1.DiffusionParticle1IO ), + particles2: ArrayIO( DiffusionParticle2.DiffusionParticle2IO ) +}; + +// Options to createParticle functions +type CreateParticleOptions = PickRequired; + +export default class DiffusionParticleSystem extends PhetioObject { + + private readonly container: DiffusionContainer; + private readonly isPlayingProperty: TReadOnlyProperty; + + // Particles of each species, together these make up the 'particle system'. + public readonly particles1: DiffusionParticle1[]; + public readonly particles2: DiffusionParticle2[]; + + // Settings for the particle types 1 and 2, before the divider is removed. + public readonly particle1Settings: DiffusionSettings; + public readonly particle2Settings: DiffusionSettings; + + // N, the total number of particles in the container + public readonly numberOfParticlesProperty: TReadOnlyProperty; + + // Center of mass for each particle species, in pm, relative to the center (divider) of the container. + // null when there are no particles in the container. This is actually an x offset, not a 'center' Vector2. + // But we decided to keep these Property names so that they match the associated checkbox labels. + // See https://github.com/phetsims/gas-properties/issues/228 + public readonly centerOfMass1Property: Property; + public readonly centerOfMass2Property: Property; + + // Flow rate model for each particle species. + public readonly particle1FlowRateModel: ParticleFlowRateModel; + public readonly particle2FlowRateModel: ParticleFlowRateModel; + + public constructor( container: DiffusionContainer, isPlayingProperty: TReadOnlyProperty, tandem: Tandem ) { + + super( { + isDisposable: true, + tandem: tandem, + phetioType: DiffusionParticleSystem.DiffusionParticleSystemIO + } ); + + this.container = container; + this.isPlayingProperty = isPlayingProperty; + + this.particles1 = []; + this.particles2 = []; + + // Intermediate tandems to provide the desired structure for the Studio tree. + const particle1Tandem = tandem.createTandem( 'particle1' ); + const particle2Tandem = tandem.createTandem( 'particle2' ); + + this.particle1Settings = new DiffusionSettings( particle1Tandem.createTandem( 'settings' ) ); + this.particle2Settings = new DiffusionSettings( particle2Tandem.createTandem( 'settings' ) ); + + // Synchronize particle counts and arrays. + const createDiffusionParticle1 = ( options: CreateParticleOptions ) => new DiffusionParticle1( options ); + this.particle1Settings.numberOfParticlesProperty.link( numberOfParticles => { + this.updateNumberOfParticles( numberOfParticles, + container.leftBounds, + this.particle1Settings, + this.particles1, + createDiffusionParticle1 ); + } ); + const createDiffusionParticle2 = ( options: CreateParticleOptions ) => new DiffusionParticle2( options ); + this.particle2Settings.numberOfParticlesProperty.link( numberOfParticles => { + this.updateNumberOfParticles( numberOfParticles, + container.rightBounds, + this.particle2Settings, + this.particles2, + createDiffusionParticle2 ); + } ); + + this.numberOfParticlesProperty = new DerivedProperty( + [ this.particle1Settings.numberOfParticlesProperty, this.particle2Settings.numberOfParticlesProperty ], + ( numberOfParticles1, numberOfParticles2 ) => { + + // Skip these assertions when PhET-iO state is being restored, because at least one of the arrays will + // definitely not be populated. See https://github.com/phetsims/gas-properties/issues/178 + if ( !isSettingPhetioStateProperty.value ) { + + // Verify that particle arrays have been populated before numberOfParticlesProperty is updated. + // If you hit these assertions, then you need to add this listener later. This is a trade-off + // for using plain old Arrays instead of ObservableArrayDef. + assert && assert( this.particles1.length === numberOfParticles1, 'particles1 has not been populated yet' ); + assert && assert( this.particles2.length === numberOfParticles2, 'particles2 has not been populated yet' ); + } + return numberOfParticles1 + numberOfParticles2; + }, { + isValidValue: value => ( Number.isInteger( value ) && value >= 0 ), + phetioValueType: NumberIO, + tandem: tandem.createTandem( 'numberOfParticlesProperty' ), + phetioFeatured: true, + phetioDocumentation: 'Total number of particles in the container.' + } ); + + this.centerOfMass1Property = new Property( null, + combineOptions>( {}, CENTER_OF_MASS_PROPERTY_OPTIONS, { + tandem: particle1Tandem.createTandem( 'centerOfMassProperty' ), + phetioReadOnly: true, + phetioFeatured: true, + phetioDocumentation: 'Center of mass for particles of type 1. This is the x offset from the center of the container.' + } ) ); + + this.centerOfMass2Property = new Property( null, + combineOptions>( {}, CENTER_OF_MASS_PROPERTY_OPTIONS, { + tandem: particle2Tandem.createTandem( 'centerOfMassProperty' ), + phetioReadOnly: true, + phetioFeatured: true, + phetioDocumentation: 'Center of mass for particles of type 2. This is the x offset from the center of the container.' + } ) ); + + this.particle1FlowRateModel = new ParticleFlowRateModel( this.container.dividerX, this.particles1, particle1Tandem ); + this.particle2FlowRateModel = new ParticleFlowRateModel( this.container.dividerX, this.particles2, particle2Tandem ); + + // Update mass and temperature of existing particles. This adjusts speed of the particles. + Multilink.multilink( + [ this.particle1Settings.massProperty, this.particle1Settings.initialTemperatureProperty ], + ( mass, initialTemperature ) => { + updateMassAndSpeed( mass, initialTemperature, this.particles1 ); + } ); + Multilink.multilink( + [ this.particle2Settings.massProperty, this.particle2Settings.initialTemperatureProperty ], + ( mass, initialTemperature ) => { + updateMassAndSpeed( mass, initialTemperature, this.particles2 ); + } ); + + // Update radii of existing particles. + this.particle1Settings.radiusProperty.link( radius => { + updateRadius( radius, this.particles1, container.leftBounds, isPlayingProperty.value ); + } ); + this.particle2Settings.radiusProperty.link( radius => { + updateRadius( radius, this.particles2, container.rightBounds, isPlayingProperty.value ); + } ); + } + + public reset(): void { + this.particle1Settings.reset(); + this.particle2Settings.reset(); + assert && assert( this.particles1.length === 0, 'there should be no DiffusionParticle1 particles' ); + assert && assert( this.particles2.length === 0, 'there should be no DiffusionParticle2 particles' ); + + this.centerOfMass1Property.reset(); + this.centerOfMass2Property.reset(); + this.particle1FlowRateModel.reset(); + this.particle2FlowRateModel.reset(); + } + + public restart(): void { + + this.particle1Settings.restart(); + this.particle2Settings.restart(); + + this.particle1FlowRateModel.reset(); + this.particle2FlowRateModel.reset(); + } + + public step( dt: number ): void { + + // Step particles + ParticleUtils.stepParticles( this.particles1, dt ); + ParticleUtils.stepParticles( this.particles2, dt ); + + // Particle Flow Rate model + if ( !this.container.isDividedProperty.value ) { + this.particle1FlowRateModel.step( dt ); + this.particle2FlowRateModel.step( dt ); + } + } + + /** + * Adjusts an array of particles to have the desired number of elements. + * @param numberOfParticles - desired number of particles + * @param positionBounds - initial position will be inside this bounds + * @param settings + * @param particles - array of particles that corresponds to newValue and oldValue + * @param createParticle - creates a Particle instance + */ + private updateNumberOfParticles( numberOfParticles: number, positionBounds: Bounds2, settings: DiffusionSettings, + particles: Particle[], createParticle: ( options: CreateParticleOptions ) => Particle ): void { + + const delta = numberOfParticles - particles.length; + if ( delta !== 0 ) { + if ( delta > 0 ) { + addParticles( delta, positionBounds, settings, particles, createParticle ); + } + else { + ParticleUtils.removeLastParticles( -delta, particles ); + } + + // If paused, update things that would normally be handled by step. + if ( !this.isPlayingProperty.value ) { + this.updateCenterOfMass(); + } + } + } + + /** + * Updates the center of mass, as shown by the center-of-mass indicators. + */ + public updateCenterOfMass(): void { + this.centerOfMass1Property.value = ParticleUtils.getCenterXOfMass( this.particles1, this.container.width / 2 ); + this.centerOfMass2Property.value = ParticleUtils.getCenterXOfMass( this.particles2, this.container.width / 2 ); + } + + /** + * Serializes this instance of DiffusionParticleSystem. + */ + private toStateObject(): DiffusionParticleSystemStateObject { + return { + particles1: this.particles1.map( particle => DiffusionParticle1.DiffusionParticle1IO.toStateObject( particle ) ), + particles2: this.particles2.map( particle => DiffusionParticle2.DiffusionParticle2IO.toStateObject( particle ) ) + }; + } + + /** + * Deserializes an instance of DiffusionParticleSystem. + */ + private static applyState( particleSystem: DiffusionParticleSystem, stateObject: DiffusionParticleSystemStateObject ): void { + + particleSystem.particles1.length = 0; + stateObject.particles1.forEach( ( stateObject: DiffusionParticle1StateObject ) => { + particleSystem.particles1.push( DiffusionParticle1.DiffusionParticle1IO.fromStateObject( stateObject ) ); + } ); + + particleSystem.particles2.length = 0; + stateObject.particles2.forEach( ( stateObject: DiffusionParticle2StateObject ) => { + particleSystem.particles2.push( DiffusionParticle2.DiffusionParticle2IO.fromStateObject( stateObject ) ); + } ); + } + + /** + * DiffusionParticleSystemIO handles serialization of the particle arrays. + * TODO https://github.com/phetsims/gas-properties/issues/231 What type of serialization is this? + */ + private static readonly DiffusionParticleSystemIO = new IOType( 'DiffusionParticleSystemIO', { + valueType: DiffusionParticleSystem, + defaultDeserializationMethod: 'applyState', + stateSchema: DIFFUSION_PARTICLE_SYSTEM_SCHEMA, + toStateObject: particleSystem => particleSystem.toStateObject(), + applyState: DiffusionParticleSystem.applyState + } ); +} + +/** + * Adds n particles to the end of the specified array. Particles must be inside positionBounds. + */ +function addParticles( n: number, positionBounds: Bounds2, settings: DiffusionSettings, particles: Particle[], + createParticle: ( options: CreateParticleOptions ) => Particle ): void { + assert && assert( n > 0 && Number.isInteger( n ), `invalid n: ${n}` ); + + // Create n particles + for ( let i = 0; i < n; i++ ) { + + const particle = createParticle( { + mass: settings.massProperty.value, + radius: settings.radiusProperty.value + } ); + + // Position the particle at a random position within positionBounds, accounting for particle radius. + const x = dotRandom.nextDoubleBetween( positionBounds.minX + particle.radius, positionBounds.maxX - particle.radius ); + const y = dotRandom.nextDoubleBetween( positionBounds.minY + particle.radius, positionBounds.maxY - particle.radius ); + particle.setXY( x, y ); + assert && assert( positionBounds.containsCoordinates( particle.x, particle.y ), 'particle is outside of positionBounds' ); + + // Set the initial velocity, based on initial temperature and mass. + particle.setVelocityPolar( + // |v| = sqrt( 3kT / m ) + Math.sqrt( 3 * GasPropertiesConstants.BOLTZMANN * settings.initialTemperatureProperty.value / particle.mass ), + + // Random angle + dotRandom.nextDouble() * 2 * Math.PI + ); + + particles.push( particle ); + } +} + +/** + * Update the mass and speed for a set of particles. Speed is based on temperature and mass. + */ +function updateMassAndSpeed( mass: number, temperature: number, particles: DiffusionParticle[] ): void { + assert && assert( mass > 0, `invalid mass: ${mass}` ); + assert && assert( temperature >= 0, `invalid temperature: ${temperature}` ); + assert && assert( Array.isArray( particles ), `invalid particles: ${particles}` ); + + for ( let i = particles.length - 1; i >= 0; i-- ) { + particles[ i ].setMass( mass ); + + // |v| = sqrt( 3kT / m ) + particles[ i ].setSpeed( Math.sqrt( 3 * GasPropertiesConstants.BOLTZMANN * temperature / mass ) ); + } +} + +/** + * Updates the radius for a set of particles. Particles will be inside the specified bounds. + */ +function updateRadius( radius: number, particles: DiffusionParticle[], bounds: Bounds2, isPlaying: boolean ): void { + assert && assert( radius > 0, `invalid radius: ${radius}` ); + + for ( let i = particles.length - 1; i >= 0; i-- ) { + + const particle = particles[ i ]; + particle.setRadius( radius ); + + // If the sim is paused, then adjust the position of any particles are not fully inside the bounds. + // While the sim is playing, this adjustment will be handled by collision detection. + if ( !isPlaying ) { + + // constrain horizontally + if ( particle.left < bounds.minX ) { + particle.left = bounds.minX; + } + else if ( particle.right > bounds.maxX ) { + particle.right = bounds.maxX; + } + + // constrain vertically + if ( particle.bottom < bounds.minY ) { + particle.bottom = bounds.minY; + } + else if ( particle.top > bounds.maxY ) { + particle.top = bounds.maxY; + } + } + } +} + +gasProperties.register( 'DiffusionParticleSystem', DiffusionParticleSystem ); \ No newline at end of file diff --git a/js/diffusion/view/DiffusionParticleSystemNode.ts b/js/diffusion/view/DiffusionParticleSystemNode.ts index 292179d4..81993aa6 100644 --- a/js/diffusion/view/DiffusionParticleSystemNode.ts +++ b/js/diffusion/view/DiffusionParticleSystemNode.ts @@ -28,18 +28,18 @@ export default class DiffusionParticleSystemNode extends ParticlesNode { const particle1CanvasProperty = new DiffusionParticleCanvasProperty( new DiffusionParticle1(), model.modelViewTransform, - model.particle1Settings.radiusProperty + model.particleSystem.particle1Settings.radiusProperty ); // generated canvas for DiffusionParticle2 species const particle2CanvasProperty = new DiffusionParticleCanvasProperty( new DiffusionParticle2(), model.modelViewTransform, - model.particle2Settings.radiusProperty + model.particleSystem.particle2Settings.radiusProperty ); // Arrays for each particle species - const particleArrays = [ model.particles1, model.particles2 ]; + const particleArrays = [ model.particleSystem.particles1, model.particleSystem.particles2 ]; // Images for each particle species in particleArrays const imageProperties = [ particle1CanvasProperty, particle2CanvasProperty ]; diff --git a/js/diffusion/view/DiffusionScreenView.ts b/js/diffusion/view/DiffusionScreenView.ts index f8409f6c..1d63e094 100644 --- a/js/diffusion/view/DiffusionScreenView.ts +++ b/js/diffusion/view/DiffusionScreenView.ts @@ -61,17 +61,17 @@ export default class DiffusionScreenView extends BaseScreenView { } // Center of Mass indicators - const centerOfMassNode1 = new CenterOfMassNode( model.centerOfMass1Property, model.container.bottom, + const centerOfMassNode1 = new CenterOfMassNode( model.particleSystem.centerOfMass1Property, model.container.bottom, model.container.widthProperty, model.modelViewTransform, GasPropertiesColors.diffusionParticle1ColorProperty, { visibleProperty: viewProperties.centerOfMassVisibleProperty } ); - const centerOfMassNode2 = new CenterOfMassNode( model.centerOfMass2Property, model.container.bottom, + const centerOfMassNode2 = new CenterOfMassNode( model.particleSystem.centerOfMass2Property, model.container.bottom, model.container.widthProperty, model.modelViewTransform, GasPropertiesColors.diffusionParticle2ColorProperty, { visibleProperty: viewProperties.centerOfMassVisibleProperty } ); // Particle Flow Rate vectors - const particleFlowRateNode1 = new ParticleFlowRateNode( model.particle1FlowRateModel, { + const particleFlowRateNode1 = new ParticleFlowRateNode( model.particleSystem.particle1FlowRateModel, { visibleProperty: viewProperties.particleFlowRateVisibleProperty, arrowNodeOptions: { fill: GasPropertiesColors.diffusionParticle1ColorProperty @@ -79,7 +79,7 @@ export default class DiffusionScreenView extends BaseScreenView { centerX: containerNode.centerX, top: containerNode.bottom + 38 } ); - const particleFlowRateNode2 = new ParticleFlowRateNode( model.particle2FlowRateModel, { + const particleFlowRateNode2 = new ParticleFlowRateNode( model.particleSystem.particle2FlowRateModel, { visibleProperty: viewProperties.particleFlowRateVisibleProperty, arrowNodeOptions: { fill: GasPropertiesColors.diffusionParticle2ColorProperty @@ -102,8 +102,13 @@ export default class DiffusionScreenView extends BaseScreenView { } ); // Panel for setting initial conditions - const settingsPanel = new DiffusionSettingsNode( model.particle1Settings, model.particle2Settings, model.modelViewTransform, - model.container.isDividedProperty, model.numberOfParticlesProperty, panelsTandem.createTandem( 'settingsPanel' ) ); + const settingsPanel = new DiffusionSettingsNode( + model.particleSystem.particle1Settings, + model.particleSystem.particle2Settings, + model.particleSystem.numberOfParticlesProperty, + model.container.isDividedProperty, + model.modelViewTransform, + panelsTandem.createTandem( 'settingsPanel' ) ); // Panel for controlling visibility of 'tools' const toolsPanel = new DiffusionToolsPanel( viewProperties, model.stopwatch.isVisibleProperty, @@ -126,7 +131,7 @@ export default class DiffusionScreenView extends BaseScreenView { const particleSystemNode = new DiffusionParticleSystemNode( model ); // If the number of particles changes while the sim is paused, redraw the particle system. - model.numberOfParticlesProperty.link( () => { + model.particleSystem.numberOfParticlesProperty.link( () => { if ( !model.isPlayingProperty.value ) { particleSystemNode.update(); } diff --git a/js/diffusion/view/DiffusionSettingsNode.ts b/js/diffusion/view/DiffusionSettingsNode.ts index ee26e46f..2b236330 100644 --- a/js/diffusion/view/DiffusionSettingsNode.ts +++ b/js/diffusion/view/DiffusionSettingsNode.ts @@ -50,16 +50,16 @@ export default class DiffusionSettingsNode extends Panel { /** * @param particle1Settings - setting for particle type 1 * @param particle2Settings - setting for particle type 2 - * @param modelViewTransform - * @param isDividedProperty * @param numberOfParticlesProperty + * @param isDividedProperty + * @param modelViewTransform * @param tandem */ public constructor( particle1Settings: DiffusionSettings, particle2Settings: DiffusionSettings, - modelViewTransform: ModelViewTransform2, - isDividedProperty: Property, numberOfParticlesProperty: TReadOnlyProperty, + isDividedProperty: Property, + modelViewTransform: ModelViewTransform2, tandem: Tandem ) { const options = combineOptions( {}, GasPropertiesConstants.PANEL_OPTIONS, {