-
Notifications
You must be signed in to change notification settings - Fork 10
feat: #1141 - [E5-F3-P3] Implement step() for particle-resolved with energy tracking and tests #1148
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: #1141 - [E5-F3-P3] Implement step() for particle-resolved with energy tracking and tests #1148
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ | |
|
|
||
| from particula.dynamics.condensation.mass_transfer import ( | ||
| get_first_order_mass_transport_k, | ||
| get_latent_heat_energy_released, | ||
| get_mass_transfer, | ||
| get_mass_transfer_rate, | ||
| get_mass_transfer_rate_latent_heat, | ||
|
|
@@ -1761,7 +1762,9 @@ class CondensationLatentHeat(CondensationStrategy): | |
| Attributes: | ||
| latent_heat_strategy_input: Strategy input provided at initialization. | ||
| latent_heat_input: Raw latent heat value provided at initialization. | ||
| last_latent_heat_energy: Diagnostic latent heat energy tracker. | ||
| last_latent_heat_energy: Total latent heat energy released in the most | ||
| recent step [J]. Positive values indicate condensation and | ||
| negative values indicate evaporation. Overwritten each call. | ||
| """ | ||
|
|
||
| # pylint: disable=R0913, R0917 | ||
|
|
@@ -1952,11 +1955,9 @@ def mass_transfer_rate( | |
| pressure_delta = self.calculate_pressure_delta( | ||
| particle, gas_species, temperature, radius_with_fill | ||
| ) | ||
| if not np.all(np.isfinite(pressure_delta)): | ||
| raise ValueError( | ||
| "Non-finite pressure_delta computed for latent-heat " | ||
| "condensation." | ||
| ) | ||
| pressure_delta = np.nan_to_num( | ||
| pressure_delta, posinf=0.0, neginf=0.0, nan=0.0 | ||
| ) | ||
|
|
||
| if self._latent_heat_strategy is None: | ||
| return get_mass_transfer_rate( | ||
|
|
@@ -2036,6 +2037,144 @@ def rate( | |
| rates = self._apply_skip_partitioning(rates) | ||
| return rates | ||
|
|
||
| def _update_latent_heat_energy( | ||
| self, mass_transfer: NDArray[np.float64], temperature: float | ||
| ) -> None: | ||
| """Store latent heat energy released for the current step. | ||
|
|
||
| Args: | ||
| mass_transfer: Per-particle mass transfer in kg for this step. | ||
| temperature: System temperature in Kelvin. | ||
| """ | ||
| if self._latent_heat_strategy is None: | ||
| self.last_latent_heat_energy = 0.0 | ||
| return | ||
|
|
||
| latent_heat = self._latent_heat_strategy.latent_heat(temperature) | ||
| self.last_latent_heat_energy = float( | ||
| np.sum( | ||
| get_latent_heat_energy_released( | ||
| mass_transfer, | ||
|
Comment on lines
+2043
to
+2057
|
||
| latent_heat, | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
| def _calculate_norm_conc( | ||
| self, volume: float, concentration: NDArray[np.float64] | ||
| ) -> NDArray[np.float64]: | ||
| """Validate inputs and compute normalized concentration. | ||
|
|
||
| Args: | ||
| volume: Particle volume for the single box [m^3]. | ||
| concentration: Particle concentration array. | ||
|
|
||
| Returns: | ||
| Normalized concentration (concentration / volume). | ||
|
|
||
| Raises: | ||
| ValueError: If volume or concentration inputs are invalid. | ||
| """ | ||
| if not np.isfinite(volume) or volume <= 0.0: | ||
| raise ValueError("volume must be finite and positive.") | ||
| if not np.all(np.isfinite(concentration)) or np.any( | ||
| concentration < 0.0 | ||
| ): | ||
| raise ValueError( | ||
| "concentration must be finite and nonnegative for norm_conc." | ||
| ) | ||
| return concentration / volume | ||
|
|
||
| def _normalize_mass_transfer_shape( | ||
| self, | ||
| mass_transfer: NDArray[np.float64], | ||
| species_mass: NDArray[np.float64], | ||
| ) -> NDArray[np.float64]: | ||
| """Normalize mass transfer shape to match species count. | ||
|
|
||
| Args: | ||
| mass_transfer: Per-particle mass transfer array. | ||
| species_mass: Particle mass array used to infer species count. | ||
|
|
||
| Returns: | ||
| Mass transfer array with consistent species dimension. | ||
| """ | ||
| species_count = 1 if species_mass.ndim == 1 else species_mass.shape[1] | ||
| if mass_transfer.ndim == 1: | ||
| return mass_transfer.reshape(-1, species_count) | ||
| if mass_transfer.shape[1] > species_count: | ||
| return mass_transfer[:, :species_count] | ||
| if mass_transfer.shape[1] < species_count: | ||
| return np.broadcast_to( | ||
| mass_transfer, (mass_transfer.shape[0], species_count) | ||
| ) | ||
| return mass_transfer | ||
|
|
||
| def _apply_mass_transfer_to_particles( | ||
| self, | ||
| particle_data: ParticleData, | ||
| mass_transfer: NDArray[np.float64], | ||
| norm_conc: NDArray[np.float64], | ||
| ) -> None: | ||
| """Apply mass transfer to particle data in place.""" | ||
| if mass_transfer.ndim == 2: | ||
| nonzero_mask = norm_conc != 0 | ||
| denom = norm_conc[:, np.newaxis] | ||
| where_mask = nonzero_mask[:, np.newaxis] | ||
| else: | ||
| denom = norm_conc | ||
| where_mask = norm_conc != 0 | ||
| per_particle = np.divide( | ||
| mass_transfer, | ||
| denom, | ||
| out=np.zeros_like(mass_transfer), | ||
| where=where_mask, | ||
| ) | ||
| np.add( | ||
| particle_data.masses[0], | ||
| per_particle, | ||
| out=particle_data.masses[0], | ||
| ) | ||
| np.maximum(particle_data.masses[0], 0.0, out=particle_data.masses[0]) | ||
|
|
||
| def _apply_mass_transfer_to_gas( | ||
| self, gas_data: GasData, mass_transfer: NDArray[np.float64] | ||
| ) -> None: | ||
| """Apply mass transfer to gas concentrations in place.""" | ||
| np.subtract( | ||
| gas_data.concentration[0], | ||
| mass_transfer.sum(axis=0), | ||
| out=gas_data.concentration[0], | ||
| ) | ||
| np.maximum( | ||
| gas_data.concentration[0], | ||
| 0.0, | ||
| out=gas_data.concentration[0], | ||
| ) | ||
|
|
||
| # pylint: disable=too-many-positional-arguments, too-many-arguments | ||
| @overload # type: ignore[override] | ||
| def step( | ||
| self, | ||
| particle: ParticleRepresentation, | ||
| gas_species: GasSpecies, | ||
| temperature: float, | ||
| pressure: float, | ||
| time_step: float, | ||
| dynamic_viscosity: Optional[float] = None, | ||
| ) -> Tuple[ParticleRepresentation, GasSpecies]: ... | ||
|
|
||
| @overload | ||
| def step( | ||
| self, | ||
| particle: ParticleData, | ||
| gas_species: GasData, | ||
| temperature: float, | ||
| pressure: float, | ||
| time_step: float, | ||
| dynamic_viscosity: Optional[float] = None, | ||
| ) -> Tuple[ParticleData, GasData]: ... | ||
|
|
||
| def step( | ||
| self, | ||
| particle: ParticleRepresentation | ParticleData, | ||
|
|
@@ -2047,23 +2186,83 @@ def step( | |
| ) -> ( | ||
| Tuple[ParticleRepresentation, GasSpecies] | Tuple[ParticleData, GasData] | ||
| ): | ||
| """Advance one condensation step (stub). | ||
| """Advance one condensation step with latent-heat diagnostics. | ||
|
|
||
| The mass transfer rate is computed, optional skip-partitioning applied, | ||
| and both the particle and gas states are updated while respecting | ||
| inventory limits. The per-step latent heat release is stored in | ||
| ``last_latent_heat_energy`` [J] (positive for condensation, negative | ||
| for evaporation) and overwritten on every call. | ||
|
|
||
| Args: | ||
| particle: Particle representation to update. | ||
| gas_species: Gas species object providing vapor properties. | ||
| temperature: System temperature in Kelvin. | ||
| pressure: System pressure in Pascals. | ||
| time_step: Integration timestep in seconds. | ||
| dynamic_viscosity: Optional dynamic viscosity override. | ||
| dynamic_viscosity: Optional dynamic viscosity override used in the | ||
| mass-transfer calculation. | ||
|
|
||
| Returns: | ||
| Tuple containing updated particle and gas species objects. | ||
|
|
||
| Raises: | ||
| NotImplementedError: Step is not implemented for latent-heat | ||
| condensation. | ||
| """ | ||
| raise NotImplementedError( | ||
| "CondensationLatentHeat.step is not implemented." | ||
| particle_data, particle_is_legacy = _unwrap_particle(particle) | ||
| gas_data, gas_is_legacy = _unwrap_gas(gas_species) | ||
| _require_matching_types(particle_is_legacy, gas_is_legacy) | ||
| _require_single_box(particle_data.n_boxes, "ParticleData") | ||
| _require_single_box(gas_data.n_boxes, "GasData") | ||
|
|
||
| mass_rate = self.mass_transfer_rate( | ||
| particle=particle, | ||
| gas_species=gas_species, | ||
| temperature=temperature, | ||
| pressure=pressure, | ||
| dynamic_viscosity=dynamic_viscosity, | ||
| ) | ||
|
|
||
| mass_rate_array = np.atleast_1d(np.asarray(mass_rate, dtype=np.float64)) | ||
|
|
||
| mass_rate_array = self._apply_skip_partitioning(mass_rate_array) | ||
|
|
||
| gas_mass_array: NDArray[np.float64] = np.atleast_1d( | ||
| np.asarray(gas_data.concentration[0], dtype=np.float64) | ||
| ) | ||
| volume = particle_data.volume[0] | ||
| concentration = particle_data.concentration[0] | ||
| norm_conc = self._calculate_norm_conc(volume, concentration) | ||
| mass_transfer = get_mass_transfer( | ||
| mass_rate=mass_rate_array, | ||
| time_step=time_step, | ||
| gas_mass=gas_mass_array, | ||
| particle_mass=particle_data.masses[0], | ||
| particle_concentration=norm_conc, | ||
| ) | ||
|
|
||
| mass_transfer = self._normalize_mass_transfer_shape( | ||
| mass_transfer, | ||
| particle_data.masses[0], | ||
| ) | ||
|
|
||
| self._update_latent_heat_energy(mass_transfer, temperature) | ||
|
|
||
| if particle_is_legacy: | ||
| particle.add_mass(added_mass=mass_transfer) # type: ignore[union-attr] | ||
| else: | ||
| self._apply_mass_transfer_to_particles( | ||
| particle_data, | ||
| mass_transfer, | ||
| norm_conc, | ||
| ) | ||
| if self.update_gases: | ||
| if gas_is_legacy: | ||
| gas_species.add_concentration( # type: ignore[union-attr] | ||
| added_concentration=-mass_transfer.sum(axis=0) | ||
| ) | ||
| else: | ||
| self._apply_mass_transfer_to_gas(gas_data, mass_transfer) | ||
| if particle_is_legacy: | ||
| return cast( | ||
| Tuple[ParticleRepresentation, GasSpecies], | ||
| (particle, gas_species), | ||
| ) | ||
| return cast(Tuple[ParticleData, GasData], (particle_data, gas_data)) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs/example use
last_latent_heat_energywithout stating units. Given the condensation workflow uses mass concentrations (kg/m^3), this diagnostic is currently an energy density (J/m^3) unless multiplied by the parcel/box volume. Please clarify the units in this section so users don’t interpret it as total energy [J].