@@ -219,6 +219,107 @@ def get_amounts(self, component):
219219 identifier = component if isinstance (component , str ) else component .identifier
220220
221221 return self .amounts [identifier ]
222+
223+ def to_substance_n_molecules (
224+ self ,
225+ maximum_molecules : int ,
226+ tolerance = None ,
227+ count_exact_amount = True ,
228+ truncate_n_molecules = True ,
229+ ) -> "Substance" :
230+ """
231+ Returns a new substance given a maximum total number of molecules.
232+
233+ Parameters
234+ ----------
235+ maximum_molecules: int
236+ The maximum number of molecules.
237+ tolerance: float, optional
238+ The tolerance within which this amount should be represented. As
239+ an example, when converting a mole fraction into a number of molecules,
240+ the total number of molecules may not be sufficiently large enough to
241+ reproduce this amount.
242+ count_exact_amount: bool
243+ Whether components present in an exact amount (i.e. defined with an
244+ ``ExactAmount``) should be considered when apply the maximum number
245+ of molecules constraint. This may be set false, for example, when
246+ building a separate solvated protein (n = 1) and solvated protein +
247+ ligand complex (n = 2) system but wish for both systems to have the
248+ same number of solvent molecules.
249+ truncate_n_molecules: bool
250+ Whether or not to attempt to truncate the number of molecules in the
251+ substance if the total number is over the specified maximum. If False, an
252+ exception will be raised in this case.
253+
254+ The truncation works by iteratively removing one molecule of the
255+ predominant component up to a limit of removing a total number of molecules
256+ equal to the number of components in the substance (e.g. for a binary
257+ substance a maximum of two molecules can be removed). An exception is
258+ raised if the number of molecules cannot be sensibly truncated.
259+ """
260+
261+ molecules_per_component = self .get_molecules_per_component (
262+ maximum_molecules ,
263+ tolerance = tolerance ,
264+ count_exact_amount = count_exact_amount ,
265+ truncate_n_molecules = truncate_n_molecules ,
266+ )
267+
268+ new_amounts = defaultdict (list )
269+ total_number_of_molecules = sum (molecules_per_component .values ())
270+
271+ # Handle any exact amounts.
272+ for component in self .components :
273+ exact_amounts = [
274+ amount
275+ for amount in self .get_amounts (component )
276+ if isinstance (amount , ExactAmount )
277+ ]
278+
279+ if len (exact_amounts ) == 0 :
280+ continue
281+
282+ total_number_of_molecules -= exact_amounts [0 ].value
283+ new_amounts [component ].append (exact_amounts [0 ])
284+
285+ # Recompute the mole fractions.
286+ total_mole_fraction = 0.0
287+ number_of_new_mole_fractions = 0
288+
289+ for component in self .components :
290+ mole_fractions = [
291+ amount
292+ for amount in self .get_amounts (component )
293+ if isinstance (amount , MoleFraction )
294+ ]
295+
296+ if len (mole_fractions ) == 0 :
297+ continue
298+
299+ molecule_count = molecules_per_component [component .identifier ]
300+
301+ if component in new_amounts :
302+ molecule_count -= new_amounts [component ][0 ].value
303+
304+ new_mole_fraction = molecule_count / total_number_of_molecules
305+ new_amounts [component ].append (MoleFraction (new_mole_fraction ))
306+
307+ total_mole_fraction += new_mole_fraction
308+ number_of_new_mole_fractions += 1
309+
310+ if (
311+ not np .isclose (total_mole_fraction , 1.0 )
312+ and number_of_new_mole_fractions > 0
313+ ):
314+ raise ValueError ("The new mole fraction does not equal 1.0" )
315+
316+ output_substance = Substance ()
317+
318+ for component , amounts in new_amounts .items ():
319+ for amount in amounts :
320+ output_substance .add_component (component , amount )
321+
322+ return output_substance
222323
223324 def get_molecules_per_component (
224325 self ,
0 commit comments