From 744121e2a9dcf043f5c97530bf3b032e94b3b84f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 10 Jan 2023 03:26:50 +0000 Subject: [PATCH 01/74] first pass at some draft code --- openfe/protocols/openmm_abfe/__init__.py | 9 + .../openmm_abfe/equil_abfe_methods.py | 1085 +++++++++++++++++ 2 files changed, 1094 insertions(+) create mode 100644 openfe/protocols/openmm_abfe/__init__.py create mode 100644 openfe/protocols/openmm_abfe/equil_abfe_methods.py diff --git a/openfe/protocols/openmm_abfe/__init__.py b/openfe/protocols/openmm_abfe/__init__.py new file mode 100644 index 000000000..68e9a42df --- /dev/null +++ b/openfe/protocols/openmm_abfe/__init__.py @@ -0,0 +1,9 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +from .equil_abfe_methods import ( + AbsoluteTransform, + AbsoluteTransformSettings, + AbsoluteTransformResult, + AbsoluteTransformUnit, +) diff --git a/openfe/protocols/openmm_abfe/equil_abfe_methods.py b/openfe/protocols/openmm_abfe/equil_abfe_methods.py new file mode 100644 index 000000000..1ce8ab86f --- /dev/null +++ b/openfe/protocols/openmm_abfe/equil_abfe_methods.py @@ -0,0 +1,1085 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +"""Equilibrium ABFE methods using OpenMM + OpenMMTools + +This module implements the necessary methodology toolking to run calculate an +absolute free energy transformation using OpenMM tools and one of the +following methods: + - Hamiltonian Replica Exchange + - Self-adjusted mixture sampling + - Independent window sampling + +Acknowledgements +---------------- +* Originally based on a script from hydration.py in + [espaloma](https://github.com/choderalab/espaloma_charge) + +TODO +---- +* Add support for restraints +* Improve this docstring by adding an example use case. + +""" +from __future__ import annotations + +import os +import logging + +from collections import defaultdict +import gufe +import json +import numpy as np +import openmm +from openff.units import unit +from openff.units.openmm import to_openmm, ensure_quantity +from openmmtools import multistate +from openmmtools.states import (ThermodynamicState, + create_thermodyanmic_state_protocol,) +from openmmtools.alchemy import (AlchemicalRegion, AbsoluteAlchemicalFactory, + AlchemicalState,) +from pydantic import BaseModel, validator +from typing import Dict, List, Union, Optional +from openmm import app +from openmm import unit as omm_unit +from openmmforcefields.generators import SMIRNOFFTemplateGenerator +import pathlib +from typing import Any, Iterable +import openmmtools +import uuid +import mdtraj as mdt + +from openfe.setup import ( + ChemicalSystem, LigandAtomMapping, +) +from openfe.protocols.openmm_rbfe._rbfe_utils import compute + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class SystemSettings(BaseModel): + """Settings describing the simulation system settings. + + Attributes + ---------- + nonbonded_method : str + Which nonbonded electrostatic method to use, currently only PME + is supported. + nonbonded_cutoff : float * unit.nanometer + Cutoff value for short range interactions. + Default 1.0 * unit.nanometer. + constraints : str + Which bonds and angles should be constrained. Default None. + rigid_water : bool + Whether to apply rigid constraints to water molecules. Default True. + hydrogen_mass : float + How much mass to repartition to hydrogen. Default None, no + repartitioning will occur. + """ + class Config: + arbitrary_types_allowed = True + + nonbonded_method = 'PME' + nonbonded_cutoff = 1.0 * unit.nanometer + constraints: Union[str, None] = 'HBonds' # Usually use HBonds + rigid_water = True + remove_com = True # Probably want False here + hydrogen_mass: Union[float, None] = None + + +class TopologySettings(BaseModel): + """Settings for creating Topologies for each component + + Attributes + ---------- + forcefield : dictionary of list of strings + A mapping of each components name to the xml forcefield to apply + solvent_model : str + The water model to use. Note, the relevant force field file should + also be included in ``forcefield``. Default 'tip3p'. + + TODO + ---- + * We can probably just detect the solvent model from the force field + defn. In that case we wouldn't have to have ``solvent_model`` here. + """ + # mapping of component name to forcefield path(s) + forcefield: Dict[str, Union[List[str], str]] + solvent_model = 'tip3p' + + +class AlchemicalSettings(BaseModel): + """Settings for the alchemical protocol + + This describes the lambda schedule and the creation of the + hybrid system. + + Attributes + ---------- + lambda_functions : str + Key of which switching functions to use for alchemical mutation. + Currently only default is supported. Default 'default'. + lambda_windows : int + Number of lambda windows to calculate. Default 24. + """ + # Lambda settings + lambda_functions = 'default' + lambda_windows = 24 + + +class OpenMMEngineSettings(BaseModel): + """OpenMM MD engine settings + + Attributes + ---------- + compute_platform : str, optional + Which compute platform to perform the simulation on. If None, the + fastest compute platform available will be chosen. Default None. + + TODO + ---- + * In the future make precision and deterministic forces user defined too. + """ + compute_platform: Optional[str] = None + + +class SamplerSettings(BaseModel): + """Settings for the Equilibrium sampler, currently supporting either + HybridSAMSSampler or HybridRepexSampler. + + Attributes + ---------- + sampler_method : str + Sampler method to use, currently supports: + - repex (hamiltonian replica exchange) + - sams (self-adjusted mixture sampling) + - independent (independent lambda sampling) + Default repex. + online_analysis_interval : int + The interval at which to perform online analysis of the free energy. + At each interval the free energy is estimate and the simulation is + considered complete if the free energy estimate is below + ``online_analysis_target_error``. Default `None`. + online_analysis_target_error : float * unit.boltzmann_constant * unit.kelvin + Target error for the online analysis measured in kT. + Once the free energy is at or below this value, the simulation will be + considered complete. + online_analysis_minimum_iterations : float + Set number of iterations which must pass before online analysis is + carried out. Default 50. + n_repeats : int + number of independent repeats to run. Default 3 + flatness_criteria : str + SAMS only. Method for assessing when to switch to asymptomatically + optimal scheme. + One of ['logZ-flatness', 'minimum-visits', 'histogram-flatness']. + Default 'logZ-flatness'. + gamma0 : float + SAMS only. Initial weight adaptation rate. Default 1.0. + n_replicas : int + Number of replicas to use. Default 24. + + TODO + ---- + * Work out how this fits within the context of independent window FEPs. + * It'd be great if we could pass in the sampler object rather than using + strings to define which one we want. + * Make n_replicas optional such that: If `None` or greater than the number + of lambda windows set in :class:`AlchemicalSettings`, this will default + to the number of lambda windows. If less than the number of lambda + windows, the replica lambda states will be picked at equidistant + intervals along the lambda schedule. + """ + class Config: + arbitrary_types_allowed = True + + sampler_method = "repex" + online_analysis_interval: Optional[int] = None + online_analysis_target_error = 0.2 * unit.boltzmann_constant * unit.kelvin + online_analysis_minimum_iterations = 50 + n_repeats: int = 3 + flatness_criteria = 'logZ-flatness' + gamma0 = 1.0 + n_replicas = 24 + + @validator('online_analysis_target_error', + 'online_analysis_minimum_iterations', 'gamma0') + def must_be_positive(cls, v): + if v < 0: + errmsg = ("Online analysis target error, minimum iteration " + "and SAMS gamm0 must be 0 or positive values") + raise ValueError(errmsg) + return v + + +class BarostatSettings(BaseModel): + """Settings for the OpenMM Monte Carlo barostat series + + Attributes + ---------- + pressure : float * unit.bar + Target pressure acting on the system. Default 1 * unit.bar. + frequency : int * unit.timestep + Frequency at which volume scaling changes should be attempted. + Default 25 * unit.timestep. + + Notes + ----- + * The temperature is defined under IntegratorSettings + + TODO + ---- + * Add support for anisotropic and membrane barostats. + """ + class Config: + arbitrary_types_allowed = True + + pressure = 1 * unit.bar + frequency = 25 * unit.timestep + + @validator('pressure') + def must_be_positive(cls, v): + if v <= 0: + raise ValueError("Pressure must be positive") + return v + + @validator('pressure') + def is_pressure(cls, v): + if not v.is_compatible_with(unit.bar): + raise ValueError("Must be pressure value, e.g. use unit.bar") + return v + + +class IntegratorSettings(BaseModel): + """Settings for the LangevinSplittingDynamicsMove integrator + + Attributes + ---------- + timestep : float * unit.femtosecond + Size of the simulation timestep. Default 2 * unit.femtosecond. + temperature : float * unit.kelvin + Target simulation temperature. Default 298.15 * unit.kelvin. + collision_rate : float / unit.picosecond + Collision frequency. Default 1 / unit.pisecond. + n_steps : int * unit.timestep + Number of integration timesteps each time the MCMC move is applied. + Default 1000. + reassign_velocities : bool + If True, velocities are reassigned from the Maxwell-Boltzmann + distribution at the beginning of move. Default False. + splitting : str + Sequence of "R", "V", "O" substeps to be carried out at each + timestep. Default "V R O R V". + n_restart_attempts : int + Number of attempts to restart from Context if there are NaNs in the + energies after integration. Default 20. + constraint_tolerance : float + Tolerance for the constraint solver. Default 1e-6. + """ + class Config: + arbitrary_types_allowed = True + + timestep = 2 * unit.femtosecond + temperature = 298.15 * unit.kelvin + collision_rate = 1 / unit.picosecond + n_steps = 1000 * unit.timestep + reassign_velocities = True + splitting = "V R O R V" + n_restart_attempts = 20 + constraint_tolerance = 1e-06 + + @validator('timestep', 'temperature', 'collision_rate', 'n_steps', + 'n_restart_attempts', 'constraint_tolerance') + def must_be_positive(cls, v): + if v <= 0: + errmsg = ("timestep, temperature, collision_rate, n_steps, " + "n_restart_atttempts, constraint_tolerance must be " + "positive") + raise ValueError(errmsg) + return v + + @validator('temperature') + def is_temperature(cls, v): + if not v.is_compatible_with(unit.kelvin): + raise ValueError("Must be temperature value, e.g. use unit.kelvin") + return v + + @validator('timestep') + def is_time(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.picosecond): + raise ValueError("timestep must be in time units " + "(i.e. picoseconds)") + return v + + @validator('collision_rate') + def must_be_inverse_time(cls, v): + if not v.is_compatible_with(1 / unit.picosecond): + raise ValueError("collision_rate must be in inverse time " + "(i.e. 1/picoseconds)") + return v + + +class SimulationSettings(BaseModel): + """Settings for simulation control, including lengths, writing to disk, + etc... + + Attributes + ---------- + minimization_steps : int + Number of minimization steps to perform. Default 10000. + equilibration_length : float * unit.picosecond + Length of the equilibration phase in units of time. The total number of + steps from this equilibration length (i.e. + ``equilibration_length`` / :class:`IntegratorSettings.timestep`) must be + a multiple of the value defined for :class:`IntegratorSettings.n_steps`. + production_length : float * unit.picosecond + Length of the production phase in units of time. The total number of + steps from this production length (i.e. + ``production_length`` / :class:`IntegratorSettings.timestep`) must be + a multiple of the value defined for :class:`IntegratorSettings.nsteps`. + output_filename : str + Path to the storage file for analysis. Default 'rbfe.nc'. + output_indices : str + Selection string for which part of the system to write coordinates for. + Default 'all'. + checkpoint_interval : int * unit.timestep + Frequency to write the checkpoint file. Default 50 * unit.timestep + checkpoint_storage : str + Separate filename for the checkpoint file. Note, this should + not be a full path, just a filename. Default 'rbfe_checkpoint.nc' + """ + class Config: + arbitrary_types_allowed = True + + minimization_steps = 10000 + equilibration_length: unit.Quantity + production_length: unit.Quantity + + # reporter settings + output_filename = 'abfe.nc' + output_indices = 'all' + checkpoint_interval = 50 * unit.timestep + checkpoint_storage = 'abfe_checkpoint.nc' + + @validator('equilibration_length', 'production_length') + def is_time(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.picosecond): + raise ValueError("Durations must be in time units") + return v + + @validator('minimization_steps', 'equilibration_length', + 'production_length', 'checkpoint_interval') + def must_be_positive(cls, v): + if v <= 0: + errmsg = ("Minimization steps, MD lengths, and checkpoint " + "intervals must be positive") + raise ValueError(errmsg) + return v + + +class AbsoluteTransformSettings(BaseModel): + class Config: + arbitrary_types_allowed = True + + # Things for creating the systems + system_settings: SystemSettings + topology_settings: TopologySettings + + # Alchemical settings + alchemical_settings: AlchemicalSettings + + # MD Engine things + engine_settings = OpenMMEngineSettings() + + # Sampling State defining things + integrator_settings: IntegratorSettings + barostat_settings: BarostatSettings + sampler_settings: SamplerSettings + + # Simulation run settings + simulation_settings: SimulationSettings + + # solvent model? + solvent_padding = 1.2 * unit.nanometer + + def _gufe_tokenize(self): + return serialise_pydantic(self) + + +def serialise_pydantic(settings: AbsoluteTransformSettings): + def serialise_unit(thing): + # this gets called when a thing can't get jsonified by pydantic + # for now only unit.Quantity fall foul of this requirement + if not isinstance(thing, unit.Quantity): + raise TypeError + return '__Quantity__' + str(thing) + return settings.json(encoder=serialise_unit) + + +def deserialise_pydantic(raw: str) -> AbsoluteTransformSettings: + def undo_mash(d): + for k, v in d.items(): + if isinstance(v, str) and v.startswith('__Quantity__'): + d[k] = unit.Quantity(v[12:]) # 12==strlen ^^ + elif isinstance(v, dict): + d[k] = undo_mash(v) + return d + + dct = json.loads(raw) + dct = undo_mash(dct) + + return AbsoluteTransformSettings(**dct) + + +def _get_resname(off_mol) -> str: + # behaviour changed between 0.10 and 0.11 + omm_top = off_mol.to_topology().to_openmm() + names = [r.name for r in omm_top.residues()] + if len(names) > 1: + raise ValueError("We assume single residue") + return names[0] + + +class AbsoluteTransformResult(gufe.ProtocolResult): + """Dict-like container for the output of a AbsoluteTransform""" + def __init__(self, **data): + super().__init__(**data) + # TODO: Detect when we have extensions and stitch these together? + if any(len(files['nc_paths']) > 2 for files in self.data['nc_files']): + raise NotImplementedError("Can't stitch together results yet") + + self._analyzers = [] + for f in self.data['nc_files']: + nc = f['nc_paths'][0] + chk = f['checkpoint_paths'][0] + reporter = multistate.MultiStateReporter( + storage=nc, + checkpoint_storage=chk) + analyzer = multistate.MultiStateSamplerAnalyzer(reporter) + + self._analyzers.append(analyzer) + + def get_estimate(self): + """Free energy difference of this transformation + + Returns + ------- + dG : unit.Quantity + The free energy difference between the first and last states. This is + a Quantity defined with units. + + TODO + ---- + * Check this holds up completely for SAMS. + """ + dGs = [] + #weights = [] + + for analyzer in self._analyzers: + # this returns: + # (matrix of) estimated free energy difference + # (matrix of) estimated statistical uncertainty (one S.D.) + dG, _ = analyzer.get_free_energy() + dG = (dG[0, -1] * analyzer.kT).in_units_of( + omm_unit.kilocalories_per_mole) + + # hack to get simulation length in uncorrelated samples + #weight = analyzer._get_equilibration_data()[2] + + dGs.append(dG) + #weights.append(weight) + + avg_val = np.average([i.value_in_unit(dGs[0].unit) for i in dGs]) + + return avg_val * dGs[0].unit + + def get_uncertainty(self): + """The uncertainty/error in the dG value""" + dGs = [] + + for analyzer in self._analyzers: + # this returns: + # (matrix of) estimated free energy difference + # (matrix of) estimated statistical uncertainty (one S.D.) + dG, _ = analyzer.get_free_energy() + dG = (dG[0, -1] * analyzer.kT).in_units_of( + omm_unit.kilocalories_per_mole) + + dGs.append(dG) + + std_val = np.std([i.value_in_unit(dGs[0].unit) for i in dGs]) + + return std_val * dGs[0].unit + + def get_rate_of_convergence(self): + raise NotImplementedError + + +class AbsoluteTransform(gufe.Protocol): + result_cls = AbsoluteTransformResult + + def __init__(self, settings: AbsoluteTransformSettings): + super().__init__(settings) + + def _to_dict(self): + return {'settings': serialise_pydantic(self.settings)} + + @classmethod + def _from_dict(cls, dct: Dict): + return cls(settings=deserialise_pydantic(dct['settings'])) + + @classmethod + def _default_settings(cls) -> AbsoluteTransformSettings: + """A dictionary of initial settings for this creating this Protocol + + These settings are intended as a suitable starting point for creating + an instance of this protocol. It is recommended, however that care is + taken to inspect and customize these before performing a Protocol. + + Returns + ------- + AbsoluteTransformSettings + a set of default settings + """ + return AbsoluteTransformSettings( + system_settings=SystemSettings( + constraints='HBonds', + hydrogen_mass = 3.0, + ), + topology_settings=TopologySettings( + forcefield = { + 'protein': 'amber/ff14SB.xml', + 'solvent': 'amber/tip3p_standard.xml', # TIP3P and recommended monovalent ion parameters + 'ions': 'amber/tip3p_HFE_multivalent.xml', # for divalent ions + 'tpo': 'amber/phosaa10.xml', # HANDLES THE TPO + 'ligand': 'openff-2.0.0.offxml', + } + ), + alchemical_settings=AlchemicalSettings(), + sampler_settings=SamplerSettings(), + barostat_settings=BarostatSettings(), + integrator_settings=IntegratorSettings( + timestep = 4.0 * unit.femtosecond, + n_steps = 250 * unit.timestep, + ), + simulation_settings=SimulationSettings( + equilibration_length=2.0 * unit.nanosecond, + production_length=5.0 * unit.nanosecond, + ) + ) + + def _create( + self, + chem_system: ChemicalSystem, + extend_from: Optional[gufe.ProtocolDAGResult] = None, + ) -> list[gufe.ProtocolUnit]: + # TODO: Extensions? + if extend_from: + raise NotImplementedError("Can't extend simulations yet") + + # Checks on the inputs! + # 1) check that both states have solvent and ligand + if 'solvent' not in chem_system.components: + nonbond = self.settings.system_settings.nonbonded_method + if nonbond != 'nocutoff': + errmsg = f"{nonbond} cannot be used for vacuum transform" + raise ValueError(errmsg) + if 'ligand' not in chem_system.components: + raise ValueError(f"Missing ligand in system") + + # actually create and return Units + ligand_name = chem_system['ligand'].name + # our DAG has no dependencies, so just list units + units = [AbsoluteTransformUnit( + chem_system=chem_system, + settings=self.settings, + generation=0, repeat_id=i, + name=f'{ligand_name} repeat {i} generation 0') + for i in range(self.settings.sampler_settings.n_repeats)] + + return units + + def _gather( + self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] + ) -> Dict[str, Any]: + # result units will have a repeat_id and generation + # first group according to repeat_id + repeats = defaultdict(list) + for d in protocol_dag_results: + pu: gufe.ProtocolUnitResult + for pu in d.protocol_unit_results: + if not pu.ok(): + continue + rep = pu.outputs['repeat_id'] + gen = pu.outputs['generation'] + + repeats[rep].append(( + gen, pu.outputs['nc'], + pu.outputs['last_checkpoint'])) + + data = [] + for replicate_id, replicate_data in sorted(repeats.items()): + # then sort within a repeat according to generation + nc_paths = [ncpath for gen, ncpath, nc_check in sorted(replicate_data)] + chk_files = [nc_check for gen, ncpath, nc_check in sorted(replicate_data)] + data.append({'nc_paths': nc_paths, + 'checkpoint_paths': chk_files}) + + return { + 'nc_files': data, + } + + +class AbsoluteTransformUnit(gufe.ProtocolUnit): + """Calculates the absolute free energy of an alchemical ligand transformation. + + """ + _chem_system: ChemicalSystem + _settings: AbsoluteTransformSettings + generation: int + repeat_id: int + name: str + + def __init__(self, *, + chem_system: ChemicalSystem, + settings: AbsoluteTransformSettings, + name: Optional[str] = None, + generation: int = 0, + repeat_id: int = 0, + ): + """ + Parameters + ---------- + chem_system : ChemicalSystem + the ChemicalSystem containing a SmallMoleculeComponent being + alchemically removed from it. + settings : AbsoluteTransformSettings + the settings for the Method. This can be constructed using the + get_default_settings classmethod to give a starting point that + can be updated to suit. + name : str, optional + human-readable identifier for this Unit + repeat_id : int, optional + identifier for which repeat (aka replica/clone) this Unit is, + default 0 + generation : int, optional + counter for how many times this repeat has been extended, default 0 + + """ + super().__init__( + name=name, + chem_system=chem_system, + settings=settings, + ) + self.repeat_id = repeat_id + self.generation = generation + + def _to_dict(self): + return { + 'inputs': self.inputs, + 'generation': self.generation, + 'repeat_id': self.repeat_id, + 'name': self.name, + } + + @classmethod + def _from_dict(cls, dct: Dict): + dct['_settings'] = deserialise_pydantic(dct['_settings']) + + inps = dct.pop('inputs') + + return cls( + **inps, + **dct + ) + + def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: + """Run the absolute free energy calculation. + + Parameters + ---------- + dry : bool + Do a dry run of the calculation, creating all necessary hybrid + system components (topology, system, sampler, etc...) but without + running the simulation. + verbose : bool + Verbose output of the simulation progress. Output is provided via + INFO level logging. + basepath : Pathlike, optional + Where to run the calculation, defaults to current working directory + + Returns + ------- + dict + Outputs created in the basepath directory or the debug objects + (i.e. sampler) if ``dry==True``. + + Raises + ------ + error + Exception if anything failed + """ + if verbose: + logger.info("creating hybrid system") + if basepath is None: + # use cwd + basepath = pathlib.Path('.') + + # 0. General setup and settings dependency resolution step + + # a. check equilibration and production are divisible by n_steps + settings = self._inputs['settings'] + chem_system = self._inputs['system'] + + sim_settings = settings.simulation_settings + timestep = settings.integrator_settings.timestep + mc_steps = settings.integrator_settings.n_steps.m + + equil_time = sim_settings.equilibration_length.to('femtosecond') + equil_steps = round(equil_time / timestep) + + # mypy gets the return type of round wrong, it's a Quantity + if (equil_steps.m % mc_steps) != 0: # type: ignore + errmsg = (f"Equilibration time {equil_time} should contain a " + "number of steps divisible by the number of integrator " + f"timesteps between MC moves {mc_steps}") + raise ValueError(errmsg) + + prod_time = sim_settings.production_length.to('femtosecond') + prod_steps = round(prod_time / timestep) + + if (prod_steps.m % mc_steps) != 0: # type: ignore + errmsg = (f"Production time {prod_time} should contain a " + "number of steps divisible by the number of integrator " + f"timesteps between MC moves {mc_steps}") + raise ValueError(errmsg) + + # b. get the openff object for the ligand + openff_ligand = chem_system['ligand'].to_openff() + + # 1. Get smirnoff template generators + smirnoff_system = SMIRNOFFTemplateGenerator( + forcefield=settings.topology_settings.forcefield['ligand'], + molecules=[openff_ligand], + ) + + # 2. Create forece fields and register them + omm_forcefield = app.ForceField( + *[ff for (comp, ff) in settings.topology_settings.forcefield.items() + if not comp == 'ligand'] + ) + + omm_forcefield.registerTemplateGenerator( + smirnoff_system.generator) + + # 3. Model state A + system_topology = openff_ligand.to_topology().to_openmm() + if 'protein' in chem_system.components: + pdbfile: gufe.ProteinComponent = chem_system['protein'] + system_modeller = app.Modeller(pdbfile.to_openmm_topology(), + pdbfile.to_openmm_positions()) + system_modeller.add( + system_topology, + ensure_quantity(openff_ligand.conformers[0], 'openmm'), + ) + else: + system_modeller = app.Modeller( + system_topology, + ensure_quantity(sopenff_ligand.conformers[0], 'openmm'), + ) + # make note of which chain id(s) the ligand is, + # we'll need this to chemically modify it later + ligand_nchains = system_topology.getNumChains() + ligand_chain_id = system_modeller.topology.getNumChains() + + # 4. Solvate the complex in a `concentration` mM cubic water box with + # `solvent_padding` from the solute to the edges of the box + if 'solvent' in chem_system.components: + conc = chem_system['solvent'].ion_concentration + pos = chem_system['solvent'].positive_ion + neg = chem_system['solvent'].negative_ion + + system_modeller.addSolvent( + omm_forcefield, + model=settings.topology_settings.solvent_model, + padding=to_openmm(settings.solvent_padding), + positiveIon=pos, negativeIon=neg, + ionicStrength=to_openmm(conc), + ) + + # 5. Create OpenMM system + topology + initial positions + # a. Get nonbond method + nonbonded_method = { + 'pme': app.PME, + 'nocutoff': app.NoCutoff, + 'cutoffnonperiodic': app.CutoffNonPeriodic, + 'cutoffperiodic': app.CutoffPeriodic, + 'ewald': app.Ewald + }[settings.system_settings.nonbonded_method.lower()] + + # b. Get the constraint method + constraints = { + 'hbonds': app.HBonds, + 'none': None, + 'allbonds': app.AllBonds, + 'hangles': app.HAngles + # vvv can be None so string it + }[str(settings.system_settings.constraints).lower()] + + # c. create the System + omm_system = omm_forcefield.createSystem( + system_modeller.topology, + nonbondedMethod=nonbonded_method, + nonbondedCutoff=to_openmm(settings.system_settings.nonbonded_cutoff), + constraints=constraints, + rigidWater=settings.system_settings.rigid_water, + hydrogenMass=settings.system_settings.hydrogen_mass, + removeCMMotion=settings.system_settings.remove_com, + ) + + # d. create stateA topology + system_topology = system_modeller.getTopology() + + # e. get stateA positions + system_positions = system_modeller.getPositions() + ## canonicalize positions (tuples to np.array) + system_positions = omm_unit.Quantity( + value=np.array([list(pos) for pos in system_positions.value_in_unit_system(openmm.unit.md_unit_system)]), + unit = openmm.unit.nanometers + ) + + # 6. Create the alchemical system + # a. Get alchemical settings + alchem_settings = settings.alchemical_settings + + # b. add a barostat if necessary + if 'solvent' in chem_system.components: + omm_system.addForce( + openmm.MonteCarloBarostat( + settings.barostat_settings.pressure.to(unit.bar).m, + settings.integrator_settings.temperature.m, + settings.barostat_settings.frequency.m, + ) + ) + + # c. Define the thermodynamic state + ## Note: we should be able to remove the if around the barostat here.. + ## TODO: check that the barostat settings are preseved + if 'solvent' in chem_system.components: + thermostate = ThermodynamicState( + system=omm_system, + temperature=to_openmm(settings.integrator_settings.temperature), + pressure=to_openmm(settings.barostat_settings.pressure) + ) + else: + thermostate = ThermodynamicState( + system=omm_system, + temperature=to_openmm(settings.integrator_settings.temperature), + ) + + # pre-minimize system for a few steps to avoid GPU overflow + integrator = openmm.VerletIntegrator(0.001) + context = openmm.Context( + omm_system, integrator, + openmm.Platform.getPlatformByName('CPU'), + ) + context.setPositions(system_positions) + openmm.LocalEnergyMinimizer.minimize( + context, maxIterations=100 + ) + positions = context.getState(getPositions=True).getPositions(asNumpy=True) + del context, integrator + + # 8. Create alchemical system + ## TODO add support for all the variants here + alchemical_region = AlchemicalRegion( + alchemical_atoms=openff_ligand.n_atoms + ) + alchemical_factory = AbsoluteAlchemicalFactory() + alchemical_system = alchemical_factory.create_alchemical_system( + omm_system, alchemical_region + ) + + # 9. Create lambda schedule + ## TODO: do this properly using LambdaProtocol + ## TODO: double check we definitely don't need to define + ## temperature & pressure (pressure sure that's the case) + lambdas = dict() + n_elec = int(alchem_settings.lambda_windows / 2) + n_vdw = alchem_settings.lambda_windows - n_elec + lambdas['lambda_electrostatic'] = np.concatenate( + [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] + ) + lambdas['lambda_steric'] = np.concatenate( + [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] + ) + + ## Check that the lambda schedule matches n_replicas + #### TODO: undo for SAMS + n_replicas = settings.sampler_settings.n_replicas + + if n_replicas != (len(lambdas['lambda_steric'])): + errmsg = (f"Number of replicas {n_replicas} " + f"does not equal the number of lambda windows " + f"{len(lambdas.lambda_schedule)}") + raise ValueError(errmsg) + + # 10. Create compound states + alchemical_state = AlchemicalState.from_system(alchemical_system) + cmp_states = create_thermodynamic_state_protocol( + alchemical_system, + protocol=lambdas, + composable_states=[alchemical_state], + ) + + # 11. Create the sampler states + # Fill up a list of sampler states all with the same starting state + sampler_state = SamplerState(positions=positions) + if omm_system.usesPeriodicBoundaryConditions(): + box = omm_system.getDefaultPeriodicBoxVectors() + sampler_state.box_vectors = box + + sampler_states = [sampler_state for _ in cmp_states] + + + # 9. Create the multistate reporter + # Get the sub selection of the system to print coords for + ## TODO: check this actually works + mdt_top = mdt.Topology.from_openmm(system_topology) + selection_indices = mdt_top.select( + settings.simulation_settings.output_indices + ) + + # a. Create the multistate reporter + reporter = multistate.MultiStateReporter( + storage=basepath / settings.simulation_settings.output_filename, + analysis_particle_indices=selection_indices, + checkpoint_interval=settings.simulation_settings.checkpoint_interval.m, + checkpoint_storage=basepath / settings.simulation_settings.checkpoint_storage, + ) + + # 10. Get platform and context caches + platform = compute.get_openmm_platform( + settings.engine_settings.compute_platform + ) + + # a. Create context caches (energy + sampler) + # Note: these needs to exist on the compute node + energy_context_cache = openmmtools.cache.ContextCache( + capacity=None, time_to_live=None, platform=platform, + ) + + sampler_context_cache = openmmtools.cache.ContextCache( + capacity=None, time_to_live=None, platform=platform, + ) + + # 11. Set the integrator + # a. get integrator settings + integrator_settings = settings.integrator_settings + + # b. create langevin integrator + integrator = openmmtools.mcmc.LangevinSplittingDynamicsMove( + timestep=to_openmm(integrator_settings.timestep), + collision_rate=to_openmm(integrator_settings.collision_rate), + n_steps=integrator_settings.n_steps.m, + reassign_velocities=integrator_settings.reassign_velocities, + n_restart_attempts=integrator_settings.n_restart_attempts, + constraint_tolerance=integrator_settings.constraint_tolerance, + splitting=integrator_settings.splitting + ) + + # 12. Create sampler + sampler_settings = settings.sampler_settings + + if sampler_settings.sampler_method.lower() == "repex": + sampler = multistate.RepexSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_target_error=sampler_settings.online_analysis_target_error.m, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations + ) + elif sampler_settings.sampler_method.lower() == "sams": + sampler = multistate.SAMSSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations, + flatness_criteria=sampler_settings.flatness_criteria, + gamma0=sampler_settings.gamma0, + ) + elif sampler_settings.sampler_method.lower() == 'independent': + sampler = multistate.MultiStateSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_target_error=sampler_settings.online_analysis_target_error.m, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations + ) + else: + raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") + + sampler.create( + thermodynamic_states=cmp_states, + sampler_states=sampler_states, + storage=reporter + ) + + sampler.energy_context_cache = energy_context_cache + sampler.sampler_context_cache = sampler_context_cache + + if not dry: + # minimize + if verbose: + logger.info("minimizing systems") + + sampler.minimize(max_iterations=settings.simulation_settings.minimization_steps) + + # equilibrate + if verbose: + logger.info("equilibrating systems") + + sampler.equilibrate(int(equil_steps.m / mc_steps)) # type: ignore + + # production + if verbose: + logger.info("running production phase") + + sampler.extend(int(prod_steps.m / mc_steps)) # type: ignore + + # close reporter when you're done + reporter.close() + + nc = basepath / settings.simulation_settings.output_filename + chk = basepath / settings.simulation_settings.checkpoint_storage + return { + 'nc': nc, + 'last_checkpoint': chk, + } + else: + # close reporter when you're done, prevent file handle clashes + reporter.close() + + # clean up the reporter file + fns = [basepath / settings.simulation_settings.output_filename, + basepath / settings.simulation_settings.checkpoint_storage] + for fn in fns: + os.remove(fn) + return {'debug': {'sampler': sampler}} + + def _execute( + self, ctx: gufe.Context, **kwargs, + ) -> dict[str, Any]: + # create directory for *this* unit within the context of the *DAG* + # stops output files mashing into each other within a DAG + myid = uuid.uuid4() + mypath = pathlib.Path(os.path.join(ctx.shared, str(myid))) + mypath.mkdir(parents=True, exist_ok=False) + + outputs = self.run(basepath=mypath) + + return { + 'repeat_id': self.repeat_id, + 'generation': self.generation, + **outputs + } From 4889a843a93f1b3034a059fa420d9784bfc88cd5 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 10 Jan 2023 03:34:14 +0000 Subject: [PATCH 02/74] Avoid numpy 1.24 issues for now --- environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index b90af4eb3..ef8b166bd 100644 --- a/environment.yml +++ b/environment.yml @@ -4,7 +4,7 @@ channels: - conda-forge - openeye dependencies: - - numpy + - numpy<1.24 - networkx - rdkit - pip From b4cd06c50f3d1225fe09a43dac1cd794a4f33692 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 10 Jan 2023 05:10:39 +0000 Subject: [PATCH 03/74] various fixes --- .../openmm_abfe/equil_abfe_methods.py | 84 ++++-- .../setup/test_openmm_abfe_equil_protocols.py | 274 ++++++++++++++++++ ...py => test_openmm_rbfe_equil_protocols.py} | 0 3 files changed, 340 insertions(+), 18 deletions(-) create mode 100644 openfe/tests/setup/test_openmm_abfe_equil_protocols.py rename openfe/tests/setup/{test_openmm_equil_protocols.py => test_openmm_rbfe_equil_protocols.py} (100%) diff --git a/openfe/protocols/openmm_abfe/equil_abfe_methods.py b/openfe/protocols/openmm_abfe/equil_abfe_methods.py index 1ce8ab86f..418c61c2c 100644 --- a/openfe/protocols/openmm_abfe/equil_abfe_methods.py +++ b/openfe/protocols/openmm_abfe/equil_abfe_methods.py @@ -27,14 +27,15 @@ from collections import defaultdict import gufe +from gufe.protocols import ProtocolDAG, ProtocolDAGResult import json import numpy as np import openmm from openff.units import unit from openff.units.openmm import to_openmm, ensure_quantity from openmmtools import multistate -from openmmtools.states import (ThermodynamicState, - create_thermodyanmic_state_protocol,) +from openmmtools.states import (ThermodynamicState, SamplerState, + create_thermodynamic_state_protocol,) from openmmtools.alchemy import (AlchemicalRegion, AbsoluteAlchemicalFactory, AlchemicalState,) from pydantic import BaseModel, validator @@ -146,7 +147,7 @@ class OpenMMEngineSettings(BaseModel): class SamplerSettings(BaseModel): """Settings for the Equilibrium sampler, currently supporting either - HybridSAMSSampler or HybridRepexSampler. + SAMSSampler or ReplicaExchangeSampler. Attributes ---------- @@ -602,6 +603,43 @@ def _create( return units + def create( + self, + chem_system: ChemicalSystem, + extend_from: Optional[ProtocolDAGResult] = None, + name: Optional[str] = None, + ) -> ProtocolDAG: + """Prepare a `ProtocolDAG` with all information required for execution. + A `ProtocolDAG` is composed of `ProtocolUnit`s, with dependencies + established between them. These form a directed, acyclic graph, + and each `ProtocolUnit` can be executed once its dependencies have + completed. + A `ProtocolDAG` can be passed to a `Scheduler` for execution on its + resources. A `ProtocolDAGResult` can be retrieved from the `Scheduler` + upon completion of all `ProtocolUnit`s in the `ProtocolDAG`. + Parameters + ---------- + chem_system : ChemicalSystem + The starting `ChemicalSystem` for the transformation. + extend_from : Optional[ProtocolDAGResult] + If provided, then the `ProtocolDAG` produced will start from the + end state of the given `ProtocolDAGResult`. This allows for + extension from a previously-run `ProtocolDAG`. + name : Optional[str] + A user supplied identifier for the resulting DAG + Returns + ------- + ProtocolDAG + A directed, acyclic graph that can be executed by a `Scheduler`. + """ + return ProtocolDAG( + name=name, + protocol_units=self._create( + chem_system=chem_system, + extend_from=extend_from, + ), + ) + def _gather( self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] ) -> Dict[str, Any]: @@ -732,7 +770,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: # a. check equilibration and production are divisible by n_steps settings = self._inputs['settings'] - chem_system = self._inputs['system'] + chem_system = self._inputs['chem_system'] sim_settings = settings.simulation_settings timestep = settings.integrator_settings.timestep @@ -776,23 +814,23 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: smirnoff_system.generator) # 3. Model state A - system_topology = openff_ligand.to_topology().to_openmm() + ligand_topology = openff_ligand.to_topology().to_openmm() if 'protein' in chem_system.components: pdbfile: gufe.ProteinComponent = chem_system['protein'] system_modeller = app.Modeller(pdbfile.to_openmm_topology(), pdbfile.to_openmm_positions()) system_modeller.add( - system_topology, + ligand_topology, ensure_quantity(openff_ligand.conformers[0], 'openmm'), ) else: system_modeller = app.Modeller( - system_topology, - ensure_quantity(sopenff_ligand.conformers[0], 'openmm'), + ligand_topology, + ensure_quantity(openff_ligand.conformers[0], 'openmm'), ) # make note of which chain id(s) the ligand is, # we'll need this to chemically modify it later - ligand_nchains = system_topology.getNumChains() + ligand_nchains = ligand_topology.getNumChains() ligand_chain_id = system_modeller.topology.getNumChains() # 4. Solvate the complex in a `concentration` mM cubic water box with @@ -843,7 +881,13 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: # d. create stateA topology system_topology = system_modeller.getTopology() - # e. get stateA positions + # e. get ligand indices + lig_chain = list(system_topology.chains())[ligand_chain_id - ligand_nchains:ligand_chain_id] + assert len(lig_chain) == 1 + lig_atoms = list(lig_chain[0].atoms()) + lig_indices = [at.index for at in lig_atoms] + + # f. get stateA positions system_positions = system_modeller.getPositions() ## canonicalize positions (tuples to np.array) system_positions = omm_unit.Quantity( @@ -896,7 +940,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: # 8. Create alchemical system ## TODO add support for all the variants here alchemical_region = AlchemicalRegion( - alchemical_atoms=openff_ligand.n_atoms + alchemical_atoms=lig_indices ) alchemical_factory = AbsoluteAlchemicalFactory() alchemical_system = alchemical_factory.create_alchemical_system( @@ -909,11 +953,11 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: ## temperature & pressure (pressure sure that's the case) lambdas = dict() n_elec = int(alchem_settings.lambda_windows / 2) - n_vdw = alchem_settings.lambda_windows - n_elec - lambdas['lambda_electrostatic'] = np.concatenate( + n_vdw = alchem_settings.lambda_windows - n_elec + 1 + lambdas['lambda_electrostatics'] = np.concatenate( [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] ) - lambdas['lambda_steric'] = np.concatenate( + lambdas['lambda_sterics'] = np.concatenate( [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] ) @@ -921,17 +965,21 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: #### TODO: undo for SAMS n_replicas = settings.sampler_settings.n_replicas - if n_replicas != (len(lambdas['lambda_steric'])): + if n_replicas != (len(lambdas['lambda_sterics'])): errmsg = (f"Number of replicas {n_replicas} " - f"does not equal the number of lambda windows " - f"{len(lambdas.lambda_schedule)}") + "does not equal the number of lambda windows ") raise ValueError(errmsg) # 10. Create compound states alchemical_state = AlchemicalState.from_system(alchemical_system) + constants = dict() + constants['temperature'] = to_openmm(settings.integrator_settings.temperature) + if 'solvent' in chem_system.components: + constants['pressure'] = to_openmm(settings.barostat_settings.pressure) cmp_states = create_thermodynamic_state_protocol( alchemical_system, protocol=lambdas, + constants=constants, composable_states=[alchemical_state], ) @@ -995,7 +1043,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: sampler_settings = settings.sampler_settings if sampler_settings.sampler_method.lower() == "repex": - sampler = multistate.RepexSampler( + sampler = multistate.ReplicaExchangeSampler( mcmc_moves=integrator, online_analysis_interval=sampler_settings.online_analysis_interval, online_analysis_target_error=sampler_settings.online_analysis_target_error.m, diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py new file mode 100644 index 000000000..2d781930f --- /dev/null +++ b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py @@ -0,0 +1,274 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import gufe +import pytest +from unittest import mock +from openff.units import unit +from openff.units.openmm import ensure_quantity +from importlib import resources +import xml.etree.ElementTree as ET + +from openmm import app, XmlSerializer +from openmmtools.multistate.multistatesampler import MultiStateSampler + +from openfe import setup +from openfe.protocols import openmm_abfe +from openmmforcefields.generators import SMIRNOFFTemplateGenerator +from openff.units.openmm import ensure_quantity + + +@pytest.fixture +def benzene_vacuum_system(benzene_modifications): + return setup.ChemicalSystem( + {'ligand': benzene_modifications['benzene']}, + ) + + +@pytest.fixture +def benzene_system(benzene_modifications): + return setup.ChemicalSystem( + {'ligand': benzene_modifications['benzene'], + 'solvent': setup.SolventComponent( + positive_ion='Na', negative_ion='Cl', + ion_concentration=0.15 * unit.molar) + }, + ) + + +@pytest.fixture +def benzene_complex_system(benzene_modifications, T4_protein_component): + return setup.ChemicalSystem( + {'ligand': benzene_modifications['benzene'], + 'solvent': setup.SolventComponent( + positive_ion='Na', negative_ion='Cl', + ion_concentration=0.15 * unit.molar), + 'protein': T4_protein_component,} + ) + + +@pytest.fixture +def toluene_vacuum_system(benzene_modifications): + return setup.ChemicalSystem( + {'ligand': benzene_modifications['toluene']}, + ) + + +@pytest.fixture +def toluene_system(benzene_modifications): + return setup.ChemicalSystem( + {'ligand': benzene_modifications['toluene'], + 'solvent': setup.SolventComponent( + positive_ion='Na', negative_ion='Cl', + ion_concentration=0.15 * unit.molar), + }, + ) + + +@pytest.fixture +def toluene_complex_system(benzene_modifications, T4_protein_component): + return setup.ChemicalSystem( + {'ligand': benzene_modifications['toluene'], + 'solvent': setup.SolventComponent( + positive_ion='Na', negative_ion='Cl', + ion_concentration=0.15 * unit.molar), + 'protein': T4_protein_component,} + ) + + +def test_create_default_settings(): + settings = openmm_abfe.AbsoluteTransform.default_settings() + + assert settings + + +def test_create_default_protocol(): + # this is roughly how it should be created + protocol = openmm_abfe.AbsoluteTransform( + settings=openmm_abfe.AbsoluteTransform.default_settings(), + ) + + assert protocol + + +def test_serialize_protocol(): + protocol = openmm_abfe.AbsoluteTransform( + settings=openmm_abfe.AbsoluteTransform.default_settings(), + ) + + ser = protocol.to_dict() + + ret = openmm_abfe.AbsoluteTransform.from_dict(ser) + + assert protocol == ret + + +@pytest.mark.parametrize('method', [ + 'repex', 'sams', 'independent', 'InDePeNdENT' +]) +def test_dry_run_default_vacuum(benzene_vacuum_system, method, tmpdir): + vac_settings = openmm_abfe.AbsoluteTransform.default_settings() + vac_settings.system_settings.nonbonded_method = 'nocutoff' + vac_settings.sampler_settings.sampler_method = method + vac_settings.sampler_settings.n_repeats = 1 + + protocol = openmm_abfe.AbsoluteTransform( + settings=vac_settings, + ) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + chem_system=benzene_vacuum_system, + ) + unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + assert isinstance(unit.run(dry=True)['debug']['sampler'], + MultiStateSampler) + + +#@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) +#def test_dry_run_ligand(benzene_system, toluene_system, +# benzene_to_toluene_mapping, method, tmpdir): +# # this might be a bit time consuming +# settings = openmm_rbfe.RelativeLigandTransform.default_settings() +# settings.sampler_settings.sampler_method = method +# settings.sampler_settings.n_repeats = 1 +# +# protocol = openmm_rbfe.RelativeLigandTransform( +# settings=settings, +# ) +# dag = protocol.create( +# stateA=benzene_system, +# stateB=toluene_system, +# mapping={'ligand': benzene_to_toluene_mapping}, +# ) +# unit = list(dag.protocol_units)[0] +# +# with tmpdir.as_cwd(): +# # Returns debug objects if everything is OK +# assert isinstance(unit.run(dry=True)['debug']['sampler'], +# MultiStateSampler) +# +# +#@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) +#def test_dry_run_complex(benzene_complex_system, toluene_complex_system, +# benzene_to_toluene_mapping, method, tmpdir): +# # this will be very time consuming +# settings = openmm_rbfe.RelativeLigandTransform.default_settings() +# settings.sampler_settings.sampler_method = method +# settings.sampler_settings.n_repeats = 1 +# +# protocol = openmm_rbfe.RelativeLigandTransform( +# settings=settings, +# ) +# dag = protocol.create( +# stateA=benzene_complex_system, +# stateB=toluene_complex_system, +# mapping={'ligand': benzene_to_toluene_mapping}, +# ) +# unit = list(dag.protocol_units)[0] +# +# with tmpdir.as_cwd(): +# # Returns debug contents if everything is OK +# assert isinstance(unit.run(dry=True)['debug']['sampler'], +# MultiStateSampler) +# +# +#def test_n_replicas_not_n_windows(benzene_vacuum_system, +# toluene_vacuum_system, +# benzene_to_toluene_mapping, tmpdir): +# # For PR #125 we pin such that the number of lambda windows +# # equals the numbers of replicas used - TODO: remove limitation +# settings = openmm_rbfe.RelativeLigandTransform.default_settings() +# # default lambda windows is 11 +# settings.sampler_settings.n_replicas = 13 +# settings.system_settings.nonbonded_method = 'nocutoff' +# +# errmsg = ("Number of replicas 13 does not equal the number of " +# "lambda windows 11") +# +# with tmpdir.as_cwd(): +# with pytest.raises(ValueError, match=errmsg): +# p = openmm_rbfe.RelativeLigandTransform( +# settings=settings, +# ) +# dag = p.create( +# stateA=benzene_vacuum_system, +# stateB=toluene_vacuum_system, +# mapping={'ligand': benzene_to_toluene_mapping}, +# ) +# unit = list(dag.protocol_units)[0] +# unit.run(dry=True) +# +# +#def test_vaccuum_PME_error(benzene_system, benzene_modifications, +# benzene_to_toluene_mapping): +# # state B doesn't have a solvent component (i.e. its vacuum) +# stateB = setup.ChemicalSystem({'ligand': benzene_modifications['toluene']}) +# +# p = openmm_rbfe.RelativeLigandTransform( +# settings=openmm_rbfe.RelativeLigandTransform.default_settings(), +# ) +# errmsg = "PME cannot be used for vacuum transform" +# with pytest.raises(ValueError, match=errmsg): +# _ = p.create( +# stateA=benzene_system, +# stateB=stateB, +# mapping={'ligand': benzene_to_toluene_mapping}, +# ) +# +# +#@pytest.fixture +#def solvent_protocol_dag(benzene_system, toluene_system, benzene_to_toluene_mapping): +# settings = openmm_rbfe.RelativeLigandTransform.default_settings() +# +# protocol = openmm_rbfe.RelativeLigandTransform( +# settings=settings, +# ) +# +# return protocol.create( +# stateA=benzene_system, stateB=toluene_system, +# mapping={'ligand': benzene_to_toluene_mapping}, +# ) +# +# +#def test_unit_tagging(solvent_protocol_dag, tmpdir): +# # test that executing the Units includes correct generation and repeat info +# units = solvent_protocol_dag.protocol_units +# with mock.patch('openfe.protocols.openmm_rbfe.equil_rbfe_methods.RelativeLigandTransformUnit.run', +# return_value={'nc': 'file.nc', 'last_checkpoint': 'chk.nc'}): +# results = [] +# for u in units: +# ret = u.execute(shared=tmpdir) +# results.append(ret) +# +# repeats = set() +# for ret in results: +# assert isinstance(ret, gufe.ProtocolUnitResult) +# assert ret.outputs['generation'] == 0 +# repeats.add(ret.outputs['repeat_id']) +# assert repeats == {0, 1, 2} +# +# +#def test_gather(solvent_protocol_dag, tmpdir): +# # check .gather behaves as expected +# with mock.patch('openfe.protocols.openmm_rbfe.equil_rbfe_methods.RelativeLigandTransformUnit.run', +# return_value={'nc': 'file.nc', 'last_checkpoint': 'chk.nc'}): +# dagres = gufe.protocols.execute_DAG(solvent_protocol_dag, +# shared=tmpdir) +# +# prot = openmm_rbfe.RelativeLigandTransform( +# settings=openmm_rbfe.RelativeLigandTransform.default_settings() +# ) +# +# with mock.patch('openfe.protocols.openmm_rbfe.equil_rbfe_methods.multistate') as m: +# res = prot.gather([dagres]) +# +# # check we created the expected number of Reporters and Analyzers +# assert m.MultiStateReporter.call_count == 3 +# m.MultiStateReporter.assert_called_with( +# storage='file.nc', checkpoint_storage='chk.nc', +# ) +# assert m.MultiStateSamplerAnalyzer.call_count == 3 +# +# assert isinstance(res, openmm_rbfe.RelativeLigandTransformResult) diff --git a/openfe/tests/setup/test_openmm_equil_protocols.py b/openfe/tests/setup/test_openmm_rbfe_equil_protocols.py similarity index 100% rename from openfe/tests/setup/test_openmm_equil_protocols.py rename to openfe/tests/setup/test_openmm_rbfe_equil_protocols.py From f59d011be5e750465ff5f61cc7ae90064ec092fe Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 10 Jan 2023 05:17:48 +0000 Subject: [PATCH 04/74] more tests --- .../openmm_abfe/equil_abfe_methods.py | 1 + .../setup/test_openmm_abfe_equil_protocols.py | 139 ++++++++---------- 2 files changed, 65 insertions(+), 75 deletions(-) diff --git a/openfe/protocols/openmm_abfe/equil_abfe_methods.py b/openfe/protocols/openmm_abfe/equil_abfe_methods.py index 418c61c2c..96ceed373 100644 --- a/openfe/protocols/openmm_abfe/equil_abfe_methods.py +++ b/openfe/protocols/openmm_abfe/equil_abfe_methods.py @@ -939,6 +939,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: # 8. Create alchemical system ## TODO add support for all the variants here + ## TODO: check that adding indices this way works alchemical_region = AlchemicalRegion( alchemical_atoms=lig_indices ) diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py index 2d781930f..ee5e36c82 100644 --- a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py @@ -126,81 +126,70 @@ def test_dry_run_default_vacuum(benzene_vacuum_system, method, tmpdir): MultiStateSampler) -#@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) -#def test_dry_run_ligand(benzene_system, toluene_system, -# benzene_to_toluene_mapping, method, tmpdir): -# # this might be a bit time consuming -# settings = openmm_rbfe.RelativeLigandTransform.default_settings() -# settings.sampler_settings.sampler_method = method -# settings.sampler_settings.n_repeats = 1 -# -# protocol = openmm_rbfe.RelativeLigandTransform( -# settings=settings, -# ) -# dag = protocol.create( -# stateA=benzene_system, -# stateB=toluene_system, -# mapping={'ligand': benzene_to_toluene_mapping}, -# ) -# unit = list(dag.protocol_units)[0] -# -# with tmpdir.as_cwd(): -# # Returns debug objects if everything is OK -# assert isinstance(unit.run(dry=True)['debug']['sampler'], -# MultiStateSampler) -# -# -#@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) -#def test_dry_run_complex(benzene_complex_system, toluene_complex_system, -# benzene_to_toluene_mapping, method, tmpdir): -# # this will be very time consuming -# settings = openmm_rbfe.RelativeLigandTransform.default_settings() -# settings.sampler_settings.sampler_method = method -# settings.sampler_settings.n_repeats = 1 -# -# protocol = openmm_rbfe.RelativeLigandTransform( -# settings=settings, -# ) -# dag = protocol.create( -# stateA=benzene_complex_system, -# stateB=toluene_complex_system, -# mapping={'ligand': benzene_to_toluene_mapping}, -# ) -# unit = list(dag.protocol_units)[0] -# -# with tmpdir.as_cwd(): -# # Returns debug contents if everything is OK -# assert isinstance(unit.run(dry=True)['debug']['sampler'], -# MultiStateSampler) -# -# -#def test_n_replicas_not_n_windows(benzene_vacuum_system, -# toluene_vacuum_system, -# benzene_to_toluene_mapping, tmpdir): -# # For PR #125 we pin such that the number of lambda windows -# # equals the numbers of replicas used - TODO: remove limitation -# settings = openmm_rbfe.RelativeLigandTransform.default_settings() -# # default lambda windows is 11 -# settings.sampler_settings.n_replicas = 13 -# settings.system_settings.nonbonded_method = 'nocutoff' -# -# errmsg = ("Number of replicas 13 does not equal the number of " -# "lambda windows 11") -# -# with tmpdir.as_cwd(): -# with pytest.raises(ValueError, match=errmsg): -# p = openmm_rbfe.RelativeLigandTransform( -# settings=settings, -# ) -# dag = p.create( -# stateA=benzene_vacuum_system, -# stateB=toluene_vacuum_system, -# mapping={'ligand': benzene_to_toluene_mapping}, -# ) -# unit = list(dag.protocol_units)[0] -# unit.run(dry=True) -# -# +@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) +def test_dry_run_ligand(benzene_system, method, tmpdir): + # this might be a bit time consuming + settings = openmm_abfe.AbsoluteTransform.default_settings() + settings.sampler_settings.sampler_method = method + settings.sampler_settings.n_repeats = 1 + + protocol = openmm_abfe.AbsoluteTransform( + settings=settings, + ) + dag = protocol.create( + chem_system=benzene_system, + ) + unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + # Returns debug objects if everything is OK + assert isinstance(unit.run(dry=True)['debug']['sampler'], + MultiStateSampler) + + +@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) +def test_dry_run_complex(benzene_complex_system, method, tmpdir): + # this will be very time consuming + settings = openmm_abfe.AbsoluteTransform.default_settings() + settings.sampler_settings.sampler_method = method + settings.sampler_settings.n_repeats = 1 + + protocol = openmm_abfe.AbsoluteTransform( + settings=settings, + ) + dag = protocol.create( + chem_system=benzene_complex_system, + ) + unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + # Returns debug contents if everything is OK + assert isinstance(unit.run(dry=True)['debug']['sampler'], + MultiStateSampler) + + +def test_n_replicas_not_n_windows(benzene_vacuum_system, tmpdir): + # For PR #125 we pin such that the number of lambda windows + # equals the numbers of replicas used - TODO: remove limitation + settings = openmm_abfe.AbsoluteTransform.default_settings() + # default lambda windows is 24 + settings.sampler_settings.n_replicas = 13 + settings.system_settings.nonbonded_method = 'nocutoff' + + errmsg = "Number of replicas 13 does not equal" + + with tmpdir.as_cwd(): + with pytest.raises(ValueError, match=errmsg): + p = openmm_abfe.AbsoluteTransform( + settings=settings, + ) + dag = p.create( + chem_system=benzene_vacuum_system, + ) + unit = list(dag.protocol_units)[0] + unit.run(dry=True) + + #def test_vaccuum_PME_error(benzene_system, benzene_modifications, # benzene_to_toluene_mapping): # # state B doesn't have a solvent component (i.e. its vacuum) From af915632e2eab9999bd5dbc098f2760e434c5f52 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 17 Mar 2023 11:45:03 +0000 Subject: [PATCH 05/74] somewhere along the way --- .../openmm_abfe/equil_abfe_methods.py | 81 +++++++++---------- 1 file changed, 36 insertions(+), 45 deletions(-) diff --git a/openfe/protocols/openmm_abfe/equil_abfe_methods.py b/openfe/protocols/openmm_abfe/equil_abfe_methods.py index 96ceed373..17956f1d6 100644 --- a/openfe/protocols/openmm_abfe/equil_abfe_methods.py +++ b/openfe/protocols/openmm_abfe/equil_abfe_methods.py @@ -27,6 +27,7 @@ from collections import defaultdict import gufe +from gufe.components import Component from gufe.protocols import ProtocolDAG, ProtocolDAGResult import json import numpy as np @@ -477,7 +478,6 @@ def get_estimate(self): * Check this holds up completely for SAMS. """ dGs = [] - #weights = [] for analyzer in self._analyzers: # this returns: @@ -487,11 +487,7 @@ def get_estimate(self): dG = (dG[0, -1] * analyzer.kT).in_units_of( omm_unit.kilocalories_per_mole) - # hack to get simulation length in uncorrelated samples - #weight = analyzer._get_equilibration_data()[2] - dGs.append(dG) - #weights.append(weight) avg_val = np.average([i.value_in_unit(dGs[0].unit) for i in dGs]) @@ -572,18 +568,50 @@ def _default_settings(cls) -> AbsoluteTransformSettings: ) ) + def _get_alchemical_components( + stateA, stateB) -> dict[str, List(Component)]: + """ + Checks equality of ChemicalSystem components across both states and + identify which components do not match. + + Parameters + ---------- + stateA : ChemicalSystem + The chemical system of end state A. + stateB : ChemicalSystem + The chemical system of end staate B. + + Returns + ------- + alchemical_components : Dictionary + Dictionary containing a list of alchemical components for each + state. + """ + matched_keys = {} + alchemical_components = {'stateA': [], 'stateB': {}} + + for keyA, valA in stateA.components.items(): + for keyB, valB in stateB.component.items(): + if valA.to_dict() == valB.to_dict(): + matched_keys[keyA] = keyB + break + + for state in ['A', 'B']: + for + def _create( self, - chem_system: ChemicalSystem, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: Optional[dict[str, gufe.ComponentMapping]] = None extend_from: Optional[gufe.ProtocolDAGResult] = None, ) -> list[gufe.ProtocolUnit]: - # TODO: Extensions? if extend_from: raise NotImplementedError("Can't extend simulations yet") # Checks on the inputs! # 1) check that both states have solvent and ligand - if 'solvent' not in chem_system.components: + if 'solvent' not in stateA.components: nonbond = self.settings.system_settings.nonbonded_method if nonbond != 'nocutoff': errmsg = f"{nonbond} cannot be used for vacuum transform" @@ -603,43 +631,6 @@ def _create( return units - def create( - self, - chem_system: ChemicalSystem, - extend_from: Optional[ProtocolDAGResult] = None, - name: Optional[str] = None, - ) -> ProtocolDAG: - """Prepare a `ProtocolDAG` with all information required for execution. - A `ProtocolDAG` is composed of `ProtocolUnit`s, with dependencies - established between them. These form a directed, acyclic graph, - and each `ProtocolUnit` can be executed once its dependencies have - completed. - A `ProtocolDAG` can be passed to a `Scheduler` for execution on its - resources. A `ProtocolDAGResult` can be retrieved from the `Scheduler` - upon completion of all `ProtocolUnit`s in the `ProtocolDAG`. - Parameters - ---------- - chem_system : ChemicalSystem - The starting `ChemicalSystem` for the transformation. - extend_from : Optional[ProtocolDAGResult] - If provided, then the `ProtocolDAG` produced will start from the - end state of the given `ProtocolDAGResult`. This allows for - extension from a previously-run `ProtocolDAG`. - name : Optional[str] - A user supplied identifier for the resulting DAG - Returns - ------- - ProtocolDAG - A directed, acyclic graph that can be executed by a `Scheduler`. - """ - return ProtocolDAG( - name=name, - protocol_units=self._create( - chem_system=chem_system, - extend_from=extend_from, - ), - ) - def _gather( self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] ) -> Dict[str, Any]: From 71fe8459a3aa75ff75aef715af2c787272f6bfef Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 1 Apr 2023 14:08:42 +0100 Subject: [PATCH 06/74] Working AFE protocol --- .gitignore | 5 +- .../openmm_abfe/equil_abfe_methods.py | 1125 ----------------- .../{openmm_abfe => openmm_afe}/__init__.py | 8 +- openfe/protocols/openmm_afe/afe_settings.py | 373 ++++++ .../protocols/openmm_afe/equil_afe_methods.py | 1035 +++++++++++++++ .../setup/test_openmm_abfe_equil_protocols.py | 195 ++- 6 files changed, 1504 insertions(+), 1237 deletions(-) delete mode 100644 openfe/protocols/openmm_abfe/equil_abfe_methods.py rename openfe/protocols/{openmm_abfe => openmm_afe}/__init__.py (64%) create mode 100644 openfe/protocols/openmm_afe/afe_settings.py create mode 100644 openfe/protocols/openmm_afe/equil_afe_methods.py diff --git a/.gitignore b/.gitignore index 11a304508..c9e26fb05 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,7 @@ cython_debug/ #.idea/ # vim -*.swp \ No newline at end of file +*.swp + +# vscode +.vscode/ diff --git a/openfe/protocols/openmm_abfe/equil_abfe_methods.py b/openfe/protocols/openmm_abfe/equil_abfe_methods.py deleted file mode 100644 index 17956f1d6..000000000 --- a/openfe/protocols/openmm_abfe/equil_abfe_methods.py +++ /dev/null @@ -1,1125 +0,0 @@ -# This code is part of OpenFE and is licensed under the MIT license. -# For details, see https://github.com/OpenFreeEnergy/openfe -"""Equilibrium ABFE methods using OpenMM + OpenMMTools - -This module implements the necessary methodology toolking to run calculate an -absolute free energy transformation using OpenMM tools and one of the -following methods: - - Hamiltonian Replica Exchange - - Self-adjusted mixture sampling - - Independent window sampling - -Acknowledgements ----------------- -* Originally based on a script from hydration.py in - [espaloma](https://github.com/choderalab/espaloma_charge) - -TODO ----- -* Add support for restraints -* Improve this docstring by adding an example use case. - -""" -from __future__ import annotations - -import os -import logging - -from collections import defaultdict -import gufe -from gufe.components import Component -from gufe.protocols import ProtocolDAG, ProtocolDAGResult -import json -import numpy as np -import openmm -from openff.units import unit -from openff.units.openmm import to_openmm, ensure_quantity -from openmmtools import multistate -from openmmtools.states import (ThermodynamicState, SamplerState, - create_thermodynamic_state_protocol,) -from openmmtools.alchemy import (AlchemicalRegion, AbsoluteAlchemicalFactory, - AlchemicalState,) -from pydantic import BaseModel, validator -from typing import Dict, List, Union, Optional -from openmm import app -from openmm import unit as omm_unit -from openmmforcefields.generators import SMIRNOFFTemplateGenerator -import pathlib -from typing import Any, Iterable -import openmmtools -import uuid -import mdtraj as mdt - -from openfe.setup import ( - ChemicalSystem, LigandAtomMapping, -) -from openfe.protocols.openmm_rbfe._rbfe_utils import compute - - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - - -class SystemSettings(BaseModel): - """Settings describing the simulation system settings. - - Attributes - ---------- - nonbonded_method : str - Which nonbonded electrostatic method to use, currently only PME - is supported. - nonbonded_cutoff : float * unit.nanometer - Cutoff value for short range interactions. - Default 1.0 * unit.nanometer. - constraints : str - Which bonds and angles should be constrained. Default None. - rigid_water : bool - Whether to apply rigid constraints to water molecules. Default True. - hydrogen_mass : float - How much mass to repartition to hydrogen. Default None, no - repartitioning will occur. - """ - class Config: - arbitrary_types_allowed = True - - nonbonded_method = 'PME' - nonbonded_cutoff = 1.0 * unit.nanometer - constraints: Union[str, None] = 'HBonds' # Usually use HBonds - rigid_water = True - remove_com = True # Probably want False here - hydrogen_mass: Union[float, None] = None - - -class TopologySettings(BaseModel): - """Settings for creating Topologies for each component - - Attributes - ---------- - forcefield : dictionary of list of strings - A mapping of each components name to the xml forcefield to apply - solvent_model : str - The water model to use. Note, the relevant force field file should - also be included in ``forcefield``. Default 'tip3p'. - - TODO - ---- - * We can probably just detect the solvent model from the force field - defn. In that case we wouldn't have to have ``solvent_model`` here. - """ - # mapping of component name to forcefield path(s) - forcefield: Dict[str, Union[List[str], str]] - solvent_model = 'tip3p' - - -class AlchemicalSettings(BaseModel): - """Settings for the alchemical protocol - - This describes the lambda schedule and the creation of the - hybrid system. - - Attributes - ---------- - lambda_functions : str - Key of which switching functions to use for alchemical mutation. - Currently only default is supported. Default 'default'. - lambda_windows : int - Number of lambda windows to calculate. Default 24. - """ - # Lambda settings - lambda_functions = 'default' - lambda_windows = 24 - - -class OpenMMEngineSettings(BaseModel): - """OpenMM MD engine settings - - Attributes - ---------- - compute_platform : str, optional - Which compute platform to perform the simulation on. If None, the - fastest compute platform available will be chosen. Default None. - - TODO - ---- - * In the future make precision and deterministic forces user defined too. - """ - compute_platform: Optional[str] = None - - -class SamplerSettings(BaseModel): - """Settings for the Equilibrium sampler, currently supporting either - SAMSSampler or ReplicaExchangeSampler. - - Attributes - ---------- - sampler_method : str - Sampler method to use, currently supports: - - repex (hamiltonian replica exchange) - - sams (self-adjusted mixture sampling) - - independent (independent lambda sampling) - Default repex. - online_analysis_interval : int - The interval at which to perform online analysis of the free energy. - At each interval the free energy is estimate and the simulation is - considered complete if the free energy estimate is below - ``online_analysis_target_error``. Default `None`. - online_analysis_target_error : float * unit.boltzmann_constant * unit.kelvin - Target error for the online analysis measured in kT. - Once the free energy is at or below this value, the simulation will be - considered complete. - online_analysis_minimum_iterations : float - Set number of iterations which must pass before online analysis is - carried out. Default 50. - n_repeats : int - number of independent repeats to run. Default 3 - flatness_criteria : str - SAMS only. Method for assessing when to switch to asymptomatically - optimal scheme. - One of ['logZ-flatness', 'minimum-visits', 'histogram-flatness']. - Default 'logZ-flatness'. - gamma0 : float - SAMS only. Initial weight adaptation rate. Default 1.0. - n_replicas : int - Number of replicas to use. Default 24. - - TODO - ---- - * Work out how this fits within the context of independent window FEPs. - * It'd be great if we could pass in the sampler object rather than using - strings to define which one we want. - * Make n_replicas optional such that: If `None` or greater than the number - of lambda windows set in :class:`AlchemicalSettings`, this will default - to the number of lambda windows. If less than the number of lambda - windows, the replica lambda states will be picked at equidistant - intervals along the lambda schedule. - """ - class Config: - arbitrary_types_allowed = True - - sampler_method = "repex" - online_analysis_interval: Optional[int] = None - online_analysis_target_error = 0.2 * unit.boltzmann_constant * unit.kelvin - online_analysis_minimum_iterations = 50 - n_repeats: int = 3 - flatness_criteria = 'logZ-flatness' - gamma0 = 1.0 - n_replicas = 24 - - @validator('online_analysis_target_error', - 'online_analysis_minimum_iterations', 'gamma0') - def must_be_positive(cls, v): - if v < 0: - errmsg = ("Online analysis target error, minimum iteration " - "and SAMS gamm0 must be 0 or positive values") - raise ValueError(errmsg) - return v - - -class BarostatSettings(BaseModel): - """Settings for the OpenMM Monte Carlo barostat series - - Attributes - ---------- - pressure : float * unit.bar - Target pressure acting on the system. Default 1 * unit.bar. - frequency : int * unit.timestep - Frequency at which volume scaling changes should be attempted. - Default 25 * unit.timestep. - - Notes - ----- - * The temperature is defined under IntegratorSettings - - TODO - ---- - * Add support for anisotropic and membrane barostats. - """ - class Config: - arbitrary_types_allowed = True - - pressure = 1 * unit.bar - frequency = 25 * unit.timestep - - @validator('pressure') - def must_be_positive(cls, v): - if v <= 0: - raise ValueError("Pressure must be positive") - return v - - @validator('pressure') - def is_pressure(cls, v): - if not v.is_compatible_with(unit.bar): - raise ValueError("Must be pressure value, e.g. use unit.bar") - return v - - -class IntegratorSettings(BaseModel): - """Settings for the LangevinSplittingDynamicsMove integrator - - Attributes - ---------- - timestep : float * unit.femtosecond - Size of the simulation timestep. Default 2 * unit.femtosecond. - temperature : float * unit.kelvin - Target simulation temperature. Default 298.15 * unit.kelvin. - collision_rate : float / unit.picosecond - Collision frequency. Default 1 / unit.pisecond. - n_steps : int * unit.timestep - Number of integration timesteps each time the MCMC move is applied. - Default 1000. - reassign_velocities : bool - If True, velocities are reassigned from the Maxwell-Boltzmann - distribution at the beginning of move. Default False. - splitting : str - Sequence of "R", "V", "O" substeps to be carried out at each - timestep. Default "V R O R V". - n_restart_attempts : int - Number of attempts to restart from Context if there are NaNs in the - energies after integration. Default 20. - constraint_tolerance : float - Tolerance for the constraint solver. Default 1e-6. - """ - class Config: - arbitrary_types_allowed = True - - timestep = 2 * unit.femtosecond - temperature = 298.15 * unit.kelvin - collision_rate = 1 / unit.picosecond - n_steps = 1000 * unit.timestep - reassign_velocities = True - splitting = "V R O R V" - n_restart_attempts = 20 - constraint_tolerance = 1e-06 - - @validator('timestep', 'temperature', 'collision_rate', 'n_steps', - 'n_restart_attempts', 'constraint_tolerance') - def must_be_positive(cls, v): - if v <= 0: - errmsg = ("timestep, temperature, collision_rate, n_steps, " - "n_restart_atttempts, constraint_tolerance must be " - "positive") - raise ValueError(errmsg) - return v - - @validator('temperature') - def is_temperature(cls, v): - if not v.is_compatible_with(unit.kelvin): - raise ValueError("Must be temperature value, e.g. use unit.kelvin") - return v - - @validator('timestep') - def is_time(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.picosecond): - raise ValueError("timestep must be in time units " - "(i.e. picoseconds)") - return v - - @validator('collision_rate') - def must_be_inverse_time(cls, v): - if not v.is_compatible_with(1 / unit.picosecond): - raise ValueError("collision_rate must be in inverse time " - "(i.e. 1/picoseconds)") - return v - - -class SimulationSettings(BaseModel): - """Settings for simulation control, including lengths, writing to disk, - etc... - - Attributes - ---------- - minimization_steps : int - Number of minimization steps to perform. Default 10000. - equilibration_length : float * unit.picosecond - Length of the equilibration phase in units of time. The total number of - steps from this equilibration length (i.e. - ``equilibration_length`` / :class:`IntegratorSettings.timestep`) must be - a multiple of the value defined for :class:`IntegratorSettings.n_steps`. - production_length : float * unit.picosecond - Length of the production phase in units of time. The total number of - steps from this production length (i.e. - ``production_length`` / :class:`IntegratorSettings.timestep`) must be - a multiple of the value defined for :class:`IntegratorSettings.nsteps`. - output_filename : str - Path to the storage file for analysis. Default 'rbfe.nc'. - output_indices : str - Selection string for which part of the system to write coordinates for. - Default 'all'. - checkpoint_interval : int * unit.timestep - Frequency to write the checkpoint file. Default 50 * unit.timestep - checkpoint_storage : str - Separate filename for the checkpoint file. Note, this should - not be a full path, just a filename. Default 'rbfe_checkpoint.nc' - """ - class Config: - arbitrary_types_allowed = True - - minimization_steps = 10000 - equilibration_length: unit.Quantity - production_length: unit.Quantity - - # reporter settings - output_filename = 'abfe.nc' - output_indices = 'all' - checkpoint_interval = 50 * unit.timestep - checkpoint_storage = 'abfe_checkpoint.nc' - - @validator('equilibration_length', 'production_length') - def is_time(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.picosecond): - raise ValueError("Durations must be in time units") - return v - - @validator('minimization_steps', 'equilibration_length', - 'production_length', 'checkpoint_interval') - def must_be_positive(cls, v): - if v <= 0: - errmsg = ("Minimization steps, MD lengths, and checkpoint " - "intervals must be positive") - raise ValueError(errmsg) - return v - - -class AbsoluteTransformSettings(BaseModel): - class Config: - arbitrary_types_allowed = True - - # Things for creating the systems - system_settings: SystemSettings - topology_settings: TopologySettings - - # Alchemical settings - alchemical_settings: AlchemicalSettings - - # MD Engine things - engine_settings = OpenMMEngineSettings() - - # Sampling State defining things - integrator_settings: IntegratorSettings - barostat_settings: BarostatSettings - sampler_settings: SamplerSettings - - # Simulation run settings - simulation_settings: SimulationSettings - - # solvent model? - solvent_padding = 1.2 * unit.nanometer - - def _gufe_tokenize(self): - return serialise_pydantic(self) - - -def serialise_pydantic(settings: AbsoluteTransformSettings): - def serialise_unit(thing): - # this gets called when a thing can't get jsonified by pydantic - # for now only unit.Quantity fall foul of this requirement - if not isinstance(thing, unit.Quantity): - raise TypeError - return '__Quantity__' + str(thing) - return settings.json(encoder=serialise_unit) - - -def deserialise_pydantic(raw: str) -> AbsoluteTransformSettings: - def undo_mash(d): - for k, v in d.items(): - if isinstance(v, str) and v.startswith('__Quantity__'): - d[k] = unit.Quantity(v[12:]) # 12==strlen ^^ - elif isinstance(v, dict): - d[k] = undo_mash(v) - return d - - dct = json.loads(raw) - dct = undo_mash(dct) - - return AbsoluteTransformSettings(**dct) - - -def _get_resname(off_mol) -> str: - # behaviour changed between 0.10 and 0.11 - omm_top = off_mol.to_topology().to_openmm() - names = [r.name for r in omm_top.residues()] - if len(names) > 1: - raise ValueError("We assume single residue") - return names[0] - - -class AbsoluteTransformResult(gufe.ProtocolResult): - """Dict-like container for the output of a AbsoluteTransform""" - def __init__(self, **data): - super().__init__(**data) - # TODO: Detect when we have extensions and stitch these together? - if any(len(files['nc_paths']) > 2 for files in self.data['nc_files']): - raise NotImplementedError("Can't stitch together results yet") - - self._analyzers = [] - for f in self.data['nc_files']: - nc = f['nc_paths'][0] - chk = f['checkpoint_paths'][0] - reporter = multistate.MultiStateReporter( - storage=nc, - checkpoint_storage=chk) - analyzer = multistate.MultiStateSamplerAnalyzer(reporter) - - self._analyzers.append(analyzer) - - def get_estimate(self): - """Free energy difference of this transformation - - Returns - ------- - dG : unit.Quantity - The free energy difference between the first and last states. This is - a Quantity defined with units. - - TODO - ---- - * Check this holds up completely for SAMS. - """ - dGs = [] - - for analyzer in self._analyzers: - # this returns: - # (matrix of) estimated free energy difference - # (matrix of) estimated statistical uncertainty (one S.D.) - dG, _ = analyzer.get_free_energy() - dG = (dG[0, -1] * analyzer.kT).in_units_of( - omm_unit.kilocalories_per_mole) - - dGs.append(dG) - - avg_val = np.average([i.value_in_unit(dGs[0].unit) for i in dGs]) - - return avg_val * dGs[0].unit - - def get_uncertainty(self): - """The uncertainty/error in the dG value""" - dGs = [] - - for analyzer in self._analyzers: - # this returns: - # (matrix of) estimated free energy difference - # (matrix of) estimated statistical uncertainty (one S.D.) - dG, _ = analyzer.get_free_energy() - dG = (dG[0, -1] * analyzer.kT).in_units_of( - omm_unit.kilocalories_per_mole) - - dGs.append(dG) - - std_val = np.std([i.value_in_unit(dGs[0].unit) for i in dGs]) - - return std_val * dGs[0].unit - - def get_rate_of_convergence(self): - raise NotImplementedError - - -class AbsoluteTransform(gufe.Protocol): - result_cls = AbsoluteTransformResult - - def __init__(self, settings: AbsoluteTransformSettings): - super().__init__(settings) - - def _to_dict(self): - return {'settings': serialise_pydantic(self.settings)} - - @classmethod - def _from_dict(cls, dct: Dict): - return cls(settings=deserialise_pydantic(dct['settings'])) - - @classmethod - def _default_settings(cls) -> AbsoluteTransformSettings: - """A dictionary of initial settings for this creating this Protocol - - These settings are intended as a suitable starting point for creating - an instance of this protocol. It is recommended, however that care is - taken to inspect and customize these before performing a Protocol. - - Returns - ------- - AbsoluteTransformSettings - a set of default settings - """ - return AbsoluteTransformSettings( - system_settings=SystemSettings( - constraints='HBonds', - hydrogen_mass = 3.0, - ), - topology_settings=TopologySettings( - forcefield = { - 'protein': 'amber/ff14SB.xml', - 'solvent': 'amber/tip3p_standard.xml', # TIP3P and recommended monovalent ion parameters - 'ions': 'amber/tip3p_HFE_multivalent.xml', # for divalent ions - 'tpo': 'amber/phosaa10.xml', # HANDLES THE TPO - 'ligand': 'openff-2.0.0.offxml', - } - ), - alchemical_settings=AlchemicalSettings(), - sampler_settings=SamplerSettings(), - barostat_settings=BarostatSettings(), - integrator_settings=IntegratorSettings( - timestep = 4.0 * unit.femtosecond, - n_steps = 250 * unit.timestep, - ), - simulation_settings=SimulationSettings( - equilibration_length=2.0 * unit.nanosecond, - production_length=5.0 * unit.nanosecond, - ) - ) - - def _get_alchemical_components( - stateA, stateB) -> dict[str, List(Component)]: - """ - Checks equality of ChemicalSystem components across both states and - identify which components do not match. - - Parameters - ---------- - stateA : ChemicalSystem - The chemical system of end state A. - stateB : ChemicalSystem - The chemical system of end staate B. - - Returns - ------- - alchemical_components : Dictionary - Dictionary containing a list of alchemical components for each - state. - """ - matched_keys = {} - alchemical_components = {'stateA': [], 'stateB': {}} - - for keyA, valA in stateA.components.items(): - for keyB, valB in stateB.component.items(): - if valA.to_dict() == valB.to_dict(): - matched_keys[keyA] = keyB - break - - for state in ['A', 'B']: - for - - def _create( - self, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - mapping: Optional[dict[str, gufe.ComponentMapping]] = None - extend_from: Optional[gufe.ProtocolDAGResult] = None, - ) -> list[gufe.ProtocolUnit]: - if extend_from: - raise NotImplementedError("Can't extend simulations yet") - - # Checks on the inputs! - # 1) check that both states have solvent and ligand - if 'solvent' not in stateA.components: - nonbond = self.settings.system_settings.nonbonded_method - if nonbond != 'nocutoff': - errmsg = f"{nonbond} cannot be used for vacuum transform" - raise ValueError(errmsg) - if 'ligand' not in chem_system.components: - raise ValueError(f"Missing ligand in system") - - # actually create and return Units - ligand_name = chem_system['ligand'].name - # our DAG has no dependencies, so just list units - units = [AbsoluteTransformUnit( - chem_system=chem_system, - settings=self.settings, - generation=0, repeat_id=i, - name=f'{ligand_name} repeat {i} generation 0') - for i in range(self.settings.sampler_settings.n_repeats)] - - return units - - def _gather( - self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] - ) -> Dict[str, Any]: - # result units will have a repeat_id and generation - # first group according to repeat_id - repeats = defaultdict(list) - for d in protocol_dag_results: - pu: gufe.ProtocolUnitResult - for pu in d.protocol_unit_results: - if not pu.ok(): - continue - rep = pu.outputs['repeat_id'] - gen = pu.outputs['generation'] - - repeats[rep].append(( - gen, pu.outputs['nc'], - pu.outputs['last_checkpoint'])) - - data = [] - for replicate_id, replicate_data in sorted(repeats.items()): - # then sort within a repeat according to generation - nc_paths = [ncpath for gen, ncpath, nc_check in sorted(replicate_data)] - chk_files = [nc_check for gen, ncpath, nc_check in sorted(replicate_data)] - data.append({'nc_paths': nc_paths, - 'checkpoint_paths': chk_files}) - - return { - 'nc_files': data, - } - - -class AbsoluteTransformUnit(gufe.ProtocolUnit): - """Calculates the absolute free energy of an alchemical ligand transformation. - - """ - _chem_system: ChemicalSystem - _settings: AbsoluteTransformSettings - generation: int - repeat_id: int - name: str - - def __init__(self, *, - chem_system: ChemicalSystem, - settings: AbsoluteTransformSettings, - name: Optional[str] = None, - generation: int = 0, - repeat_id: int = 0, - ): - """ - Parameters - ---------- - chem_system : ChemicalSystem - the ChemicalSystem containing a SmallMoleculeComponent being - alchemically removed from it. - settings : AbsoluteTransformSettings - the settings for the Method. This can be constructed using the - get_default_settings classmethod to give a starting point that - can be updated to suit. - name : str, optional - human-readable identifier for this Unit - repeat_id : int, optional - identifier for which repeat (aka replica/clone) this Unit is, - default 0 - generation : int, optional - counter for how many times this repeat has been extended, default 0 - - """ - super().__init__( - name=name, - chem_system=chem_system, - settings=settings, - ) - self.repeat_id = repeat_id - self.generation = generation - - def _to_dict(self): - return { - 'inputs': self.inputs, - 'generation': self.generation, - 'repeat_id': self.repeat_id, - 'name': self.name, - } - - @classmethod - def _from_dict(cls, dct: Dict): - dct['_settings'] = deserialise_pydantic(dct['_settings']) - - inps = dct.pop('inputs') - - return cls( - **inps, - **dct - ) - - def run(self, dry=False, verbose=True, basepath=None) -> dict[str, Any]: - """Run the absolute free energy calculation. - - Parameters - ---------- - dry : bool - Do a dry run of the calculation, creating all necessary hybrid - system components (topology, system, sampler, etc...) but without - running the simulation. - verbose : bool - Verbose output of the simulation progress. Output is provided via - INFO level logging. - basepath : Pathlike, optional - Where to run the calculation, defaults to current working directory - - Returns - ------- - dict - Outputs created in the basepath directory or the debug objects - (i.e. sampler) if ``dry==True``. - - Raises - ------ - error - Exception if anything failed - """ - if verbose: - logger.info("creating hybrid system") - if basepath is None: - # use cwd - basepath = pathlib.Path('.') - - # 0. General setup and settings dependency resolution step - - # a. check equilibration and production are divisible by n_steps - settings = self._inputs['settings'] - chem_system = self._inputs['chem_system'] - - sim_settings = settings.simulation_settings - timestep = settings.integrator_settings.timestep - mc_steps = settings.integrator_settings.n_steps.m - - equil_time = sim_settings.equilibration_length.to('femtosecond') - equil_steps = round(equil_time / timestep) - - # mypy gets the return type of round wrong, it's a Quantity - if (equil_steps.m % mc_steps) != 0: # type: ignore - errmsg = (f"Equilibration time {equil_time} should contain a " - "number of steps divisible by the number of integrator " - f"timesteps between MC moves {mc_steps}") - raise ValueError(errmsg) - - prod_time = sim_settings.production_length.to('femtosecond') - prod_steps = round(prod_time / timestep) - - if (prod_steps.m % mc_steps) != 0: # type: ignore - errmsg = (f"Production time {prod_time} should contain a " - "number of steps divisible by the number of integrator " - f"timesteps between MC moves {mc_steps}") - raise ValueError(errmsg) - - # b. get the openff object for the ligand - openff_ligand = chem_system['ligand'].to_openff() - - # 1. Get smirnoff template generators - smirnoff_system = SMIRNOFFTemplateGenerator( - forcefield=settings.topology_settings.forcefield['ligand'], - molecules=[openff_ligand], - ) - - # 2. Create forece fields and register them - omm_forcefield = app.ForceField( - *[ff for (comp, ff) in settings.topology_settings.forcefield.items() - if not comp == 'ligand'] - ) - - omm_forcefield.registerTemplateGenerator( - smirnoff_system.generator) - - # 3. Model state A - ligand_topology = openff_ligand.to_topology().to_openmm() - if 'protein' in chem_system.components: - pdbfile: gufe.ProteinComponent = chem_system['protein'] - system_modeller = app.Modeller(pdbfile.to_openmm_topology(), - pdbfile.to_openmm_positions()) - system_modeller.add( - ligand_topology, - ensure_quantity(openff_ligand.conformers[0], 'openmm'), - ) - else: - system_modeller = app.Modeller( - ligand_topology, - ensure_quantity(openff_ligand.conformers[0], 'openmm'), - ) - # make note of which chain id(s) the ligand is, - # we'll need this to chemically modify it later - ligand_nchains = ligand_topology.getNumChains() - ligand_chain_id = system_modeller.topology.getNumChains() - - # 4. Solvate the complex in a `concentration` mM cubic water box with - # `solvent_padding` from the solute to the edges of the box - if 'solvent' in chem_system.components: - conc = chem_system['solvent'].ion_concentration - pos = chem_system['solvent'].positive_ion - neg = chem_system['solvent'].negative_ion - - system_modeller.addSolvent( - omm_forcefield, - model=settings.topology_settings.solvent_model, - padding=to_openmm(settings.solvent_padding), - positiveIon=pos, negativeIon=neg, - ionicStrength=to_openmm(conc), - ) - - # 5. Create OpenMM system + topology + initial positions - # a. Get nonbond method - nonbonded_method = { - 'pme': app.PME, - 'nocutoff': app.NoCutoff, - 'cutoffnonperiodic': app.CutoffNonPeriodic, - 'cutoffperiodic': app.CutoffPeriodic, - 'ewald': app.Ewald - }[settings.system_settings.nonbonded_method.lower()] - - # b. Get the constraint method - constraints = { - 'hbonds': app.HBonds, - 'none': None, - 'allbonds': app.AllBonds, - 'hangles': app.HAngles - # vvv can be None so string it - }[str(settings.system_settings.constraints).lower()] - - # c. create the System - omm_system = omm_forcefield.createSystem( - system_modeller.topology, - nonbondedMethod=nonbonded_method, - nonbondedCutoff=to_openmm(settings.system_settings.nonbonded_cutoff), - constraints=constraints, - rigidWater=settings.system_settings.rigid_water, - hydrogenMass=settings.system_settings.hydrogen_mass, - removeCMMotion=settings.system_settings.remove_com, - ) - - # d. create stateA topology - system_topology = system_modeller.getTopology() - - # e. get ligand indices - lig_chain = list(system_topology.chains())[ligand_chain_id - ligand_nchains:ligand_chain_id] - assert len(lig_chain) == 1 - lig_atoms = list(lig_chain[0].atoms()) - lig_indices = [at.index for at in lig_atoms] - - # f. get stateA positions - system_positions = system_modeller.getPositions() - ## canonicalize positions (tuples to np.array) - system_positions = omm_unit.Quantity( - value=np.array([list(pos) for pos in system_positions.value_in_unit_system(openmm.unit.md_unit_system)]), - unit = openmm.unit.nanometers - ) - - # 6. Create the alchemical system - # a. Get alchemical settings - alchem_settings = settings.alchemical_settings - - # b. add a barostat if necessary - if 'solvent' in chem_system.components: - omm_system.addForce( - openmm.MonteCarloBarostat( - settings.barostat_settings.pressure.to(unit.bar).m, - settings.integrator_settings.temperature.m, - settings.barostat_settings.frequency.m, - ) - ) - - # c. Define the thermodynamic state - ## Note: we should be able to remove the if around the barostat here.. - ## TODO: check that the barostat settings are preseved - if 'solvent' in chem_system.components: - thermostate = ThermodynamicState( - system=omm_system, - temperature=to_openmm(settings.integrator_settings.temperature), - pressure=to_openmm(settings.barostat_settings.pressure) - ) - else: - thermostate = ThermodynamicState( - system=omm_system, - temperature=to_openmm(settings.integrator_settings.temperature), - ) - - # pre-minimize system for a few steps to avoid GPU overflow - integrator = openmm.VerletIntegrator(0.001) - context = openmm.Context( - omm_system, integrator, - openmm.Platform.getPlatformByName('CPU'), - ) - context.setPositions(system_positions) - openmm.LocalEnergyMinimizer.minimize( - context, maxIterations=100 - ) - positions = context.getState(getPositions=True).getPositions(asNumpy=True) - del context, integrator - - # 8. Create alchemical system - ## TODO add support for all the variants here - ## TODO: check that adding indices this way works - alchemical_region = AlchemicalRegion( - alchemical_atoms=lig_indices - ) - alchemical_factory = AbsoluteAlchemicalFactory() - alchemical_system = alchemical_factory.create_alchemical_system( - omm_system, alchemical_region - ) - - # 9. Create lambda schedule - ## TODO: do this properly using LambdaProtocol - ## TODO: double check we definitely don't need to define - ## temperature & pressure (pressure sure that's the case) - lambdas = dict() - n_elec = int(alchem_settings.lambda_windows / 2) - n_vdw = alchem_settings.lambda_windows - n_elec + 1 - lambdas['lambda_electrostatics'] = np.concatenate( - [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] - ) - lambdas['lambda_sterics'] = np.concatenate( - [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] - ) - - ## Check that the lambda schedule matches n_replicas - #### TODO: undo for SAMS - n_replicas = settings.sampler_settings.n_replicas - - if n_replicas != (len(lambdas['lambda_sterics'])): - errmsg = (f"Number of replicas {n_replicas} " - "does not equal the number of lambda windows ") - raise ValueError(errmsg) - - # 10. Create compound states - alchemical_state = AlchemicalState.from_system(alchemical_system) - constants = dict() - constants['temperature'] = to_openmm(settings.integrator_settings.temperature) - if 'solvent' in chem_system.components: - constants['pressure'] = to_openmm(settings.barostat_settings.pressure) - cmp_states = create_thermodynamic_state_protocol( - alchemical_system, - protocol=lambdas, - constants=constants, - composable_states=[alchemical_state], - ) - - # 11. Create the sampler states - # Fill up a list of sampler states all with the same starting state - sampler_state = SamplerState(positions=positions) - if omm_system.usesPeriodicBoundaryConditions(): - box = omm_system.getDefaultPeriodicBoxVectors() - sampler_state.box_vectors = box - - sampler_states = [sampler_state for _ in cmp_states] - - - # 9. Create the multistate reporter - # Get the sub selection of the system to print coords for - ## TODO: check this actually works - mdt_top = mdt.Topology.from_openmm(system_topology) - selection_indices = mdt_top.select( - settings.simulation_settings.output_indices - ) - - # a. Create the multistate reporter - reporter = multistate.MultiStateReporter( - storage=basepath / settings.simulation_settings.output_filename, - analysis_particle_indices=selection_indices, - checkpoint_interval=settings.simulation_settings.checkpoint_interval.m, - checkpoint_storage=basepath / settings.simulation_settings.checkpoint_storage, - ) - - # 10. Get platform and context caches - platform = compute.get_openmm_platform( - settings.engine_settings.compute_platform - ) - - # a. Create context caches (energy + sampler) - # Note: these needs to exist on the compute node - energy_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_live=None, platform=platform, - ) - - sampler_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_live=None, platform=platform, - ) - - # 11. Set the integrator - # a. get integrator settings - integrator_settings = settings.integrator_settings - - # b. create langevin integrator - integrator = openmmtools.mcmc.LangevinSplittingDynamicsMove( - timestep=to_openmm(integrator_settings.timestep), - collision_rate=to_openmm(integrator_settings.collision_rate), - n_steps=integrator_settings.n_steps.m, - reassign_velocities=integrator_settings.reassign_velocities, - n_restart_attempts=integrator_settings.n_restart_attempts, - constraint_tolerance=integrator_settings.constraint_tolerance, - splitting=integrator_settings.splitting - ) - - # 12. Create sampler - sampler_settings = settings.sampler_settings - - if sampler_settings.sampler_method.lower() == "repex": - sampler = multistate.ReplicaExchangeSampler( - mcmc_moves=integrator, - online_analysis_interval=sampler_settings.online_analysis_interval, - online_analysis_target_error=sampler_settings.online_analysis_target_error.m, - online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations - ) - elif sampler_settings.sampler_method.lower() == "sams": - sampler = multistate.SAMSSampler( - mcmc_moves=integrator, - online_analysis_interval=sampler_settings.online_analysis_interval, - online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations, - flatness_criteria=sampler_settings.flatness_criteria, - gamma0=sampler_settings.gamma0, - ) - elif sampler_settings.sampler_method.lower() == 'independent': - sampler = multistate.MultiStateSampler( - mcmc_moves=integrator, - online_analysis_interval=sampler_settings.online_analysis_interval, - online_analysis_target_error=sampler_settings.online_analysis_target_error.m, - online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations - ) - else: - raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") - - sampler.create( - thermodynamic_states=cmp_states, - sampler_states=sampler_states, - storage=reporter - ) - - sampler.energy_context_cache = energy_context_cache - sampler.sampler_context_cache = sampler_context_cache - - if not dry: - # minimize - if verbose: - logger.info("minimizing systems") - - sampler.minimize(max_iterations=settings.simulation_settings.minimization_steps) - - # equilibrate - if verbose: - logger.info("equilibrating systems") - - sampler.equilibrate(int(equil_steps.m / mc_steps)) # type: ignore - - # production - if verbose: - logger.info("running production phase") - - sampler.extend(int(prod_steps.m / mc_steps)) # type: ignore - - # close reporter when you're done - reporter.close() - - nc = basepath / settings.simulation_settings.output_filename - chk = basepath / settings.simulation_settings.checkpoint_storage - return { - 'nc': nc, - 'last_checkpoint': chk, - } - else: - # close reporter when you're done, prevent file handle clashes - reporter.close() - - # clean up the reporter file - fns = [basepath / settings.simulation_settings.output_filename, - basepath / settings.simulation_settings.checkpoint_storage] - for fn in fns: - os.remove(fn) - return {'debug': {'sampler': sampler}} - - def _execute( - self, ctx: gufe.Context, **kwargs, - ) -> dict[str, Any]: - # create directory for *this* unit within the context of the *DAG* - # stops output files mashing into each other within a DAG - myid = uuid.uuid4() - mypath = pathlib.Path(os.path.join(ctx.shared, str(myid))) - mypath.mkdir(parents=True, exist_ok=False) - - outputs = self.run(basepath=mypath) - - return { - 'repeat_id': self.repeat_id, - 'generation': self.generation, - **outputs - } diff --git a/openfe/protocols/openmm_abfe/__init__.py b/openfe/protocols/openmm_afe/__init__.py similarity index 64% rename from openfe/protocols/openmm_abfe/__init__.py rename to openfe/protocols/openmm_afe/__init__.py index 68e9a42df..851859930 100644 --- a/openfe/protocols/openmm_abfe/__init__.py +++ b/openfe/protocols/openmm_afe/__init__.py @@ -1,9 +1,9 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -from .equil_abfe_methods import ( - AbsoluteTransform, +from .equil_afe_methods import ( + AbsoluteTransformProtocol, AbsoluteTransformSettings, - AbsoluteTransformResult, + AbsoluteTransformProtocolResult, AbsoluteTransformUnit, -) +) \ No newline at end of file diff --git a/openfe/protocols/openmm_afe/afe_settings.py b/openfe/protocols/openmm_afe/afe_settings.py new file mode 100644 index 000000000..135c72038 --- /dev/null +++ b/openfe/protocols/openmm_afe/afe_settings.py @@ -0,0 +1,373 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +"""Settings class for equilibrium AFE Protocols using OpenMM + OpenMMTools + +This module implements the necessary settings necessary to run absolute free +energies using :class:`openfe.protocols.openmm_abfe.equil_abfe_methods.py` + + +TODO +---- +* Add support for restraints +* Improve this docstring by adding an example use case. + +""" +from typing import Optional +from pydantic import validator +from openff.units import unit +from gufe import settings + + +class SystemSettings(settings.SettingsBaseModel): + """Settings describing the simulation system settings.""" + + class Config: + arbitrary_types_allowed = True + + nonbonded_method = 'PME' + """ + Method for treating nonbonded interactions, currently only PME and + NoCutoff are allowed. Default PME. + """ + nonbonded_cutoff = 1.0 * unit.nanometer + """ + Cutoff value for short range nonbonded interactions. + Default 1.0 * unit.nanometer. + """ + + @validator('nonbonded_method') + def allowed_nonbonded(cls, v): + if v.lower() not in ['pme', 'nocutoff']: + errmsg = ("Only PME and NoCutoff are allowed nonbonded_methods") + raise ValueError(errmsg) + return v + + @validator('nonbonded_cutoff') + def is_positive_distance(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.nanometer): + raise ValueError("nonbonded_cutoff must be in distance units " + "(i.e. nanometers)") + if v < 0: + errmsg = "nonbonded_cutoff must be a positive value" + raise ValueError(errmsg) + return v + + +class SolventSettings(settings.SettingsBaseModel): + """Settings for solvating the system + + NOTE + ---- + * No solvation will happen if a SolventComponent is not passed. + """ + solvent_model = 'tip3p' + """ + Force field water model to use. + Allowed values are; `tip3p`, `spce`, `tip4pew`, and `tip5p`. + """ + class Config: + arbitrary_types_allowed = True + + solvent_padding = 1.2 * unit.nanometer + + @validator('solvent_model') + def allowed_solvent(cls, v): + allowed_models = ['tip3p', 'spce', 'tip4pew', 'tip5p'] + if v.lower() not in allowed_models: + errmsg = ( + f"Only {allowed_models} are allowed solvent_model values" + ) + raise ValueError(errmsg) + return v + + @validator('solvent_padding') + def is_positive_distance(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.nanometer): + raise ValueError("solvent_padding must be in distance units " + "(i.e. nanometers)") + if v < 0: + errmsg = "solvent_padding must be a positive value" + raise ValueError(errmsg) + return v + + +class AlchemicalSettings(settings.SettingsBaseModel): + """Settings for the alchemical protocol + + These settings describe the lambda schedule and the creation of the + hybrid system. + """ + + lambda_elec_windows = 12 + """Number of lambda electrostatic alchemical steps, default 12""" + lambda_vdw_windows = 12 + """Number of lambda vdw alchemical steps, default 12""" + + @validator('lambda_elec_windows', 'lambda_vdw_windows') + def must_be_positive(cls, v): + if v < 0: + errmsg = ("Number of lambda steps must be positive ") + raise ValueError(errmsg) + return v + + +class OpenMMEngineSettings(settings.SettingsBaseModel): + """OpenMM MD engine settings + + TODO + ---- + * In the future make precision and deterministic forces user defined too. + """ + + compute_platform: Optional[str] = None + """ + OpenMM compute platform to perform MD integration with. If None, will + choose fastest available platform. Default None. + """ + + +class AlchemicalSamplerSettings(settings.SettingsBaseModel): + """Settings for the Equilibrium Alchemical sampler, currently supporting + either MultistateSampler, SAMSSampler or ReplicaExchangeSampler. + + + TODO + ---- + * It'd be great if we could pass in the sampler object rather than using + strings to define which one we want. + * Make n_replicas optional such that: If `None` or greater than the number + of lambda windows set in :class:`AlchemicalSettings`, this will default + to the number of lambda windows. If less than the number of lambda + windows, the replica lambda states will be picked at equidistant + intervals along the lambda schedule. + """ + class Config: + arbitrary_types_allowed = True + + sampler_method = "repex" + """ + Alchemical sampling method, must be one of; + `repex` (Hamiltonian Replica Exchange), + `sams` (Self-Adjusted Mixture Sampling), + or `independent` (independently sampled lambda windows). + Default `repex`. + """ + online_analysis_interval: Optional[int] = None + """ + Interval at which to perform an analysis of the free energies. + At each interval the free energy is estimate and the simulation is + considered complete if the free energy estimate is below + ``online_analysis_target_error``. If set, will write a yaml file with + real time analysis data. Default `None`. + """ + online_analysis_target_error = 0.1 * unit.boltzmann_constant * unit.kelvin + """ + Target error for the online analysis measured in kT. Once the free energy + is at or below this value, the simulation will be considered complete. + """ + online_analysis_minimum_iterations = 50 + """ + Number of iterations which must pass before online analysis is + carried out. Default 50. + """ + n_repeats: int = 3 + """ + Number of independent repeats to run. Default 3 + """ + flatness_criteria = 'logZ-flatness' + """ + SAMS only. Method for assessing when to switch to asymptomatically + optimal scheme. + + One of ['logZ-flatness', 'minimum-visits', 'histogram-flatness']. + + Default 'logZ-flatness'. + """ + gamma0 = 1.0 + """SAMS only. Initial weight adaptation rate. Default 1.0.""" + n_replicas = 24 + """Number of replicas to use. Default 24.""" + + @validator('flatness_criteria') + def supported_flatness(cls, v): + supported = [ + 'logz-flatness', 'minimum-visits', 'histogram-flatness' + ] + if v.lower() not in supported: + errmsg = ("Only the following flatness_criteria are " + f"supported: {supported}") + raise ValueError(errmsg) + return v + + @validator('sampler_method') + def supported_sampler(cls, v): + supported = ['repex', 'sams', 'independent'] + if v.lower() not in supported: + errmsg = ("Only the following sampler_method values are " + f"supported: {supported}") + raise ValueError(errmsg) + return v + + @validator('online_analysis_target_error', 'n_repeats', + 'online_analysis_minimum_iterations', 'gamma0', 'n_replicas') + def must_be_positive(cls, v): + if v < 0: + errmsg = ("Online analysis target error, minimum iteration " + "and SAMS gamm0 must be 0 or positive values") + raise ValueError(errmsg) + return v + + +class IntegratorSettings(settings.SettingsBaseModel): + """Settings for the LangevinSplittingDynamicsMove integrator""" + + class Config: + arbitrary_types_allowed = True + + timestep = 2 * unit.femtosecond + """Size of the simulation timestep. Default 2 * unit.femtosecond.""" + collision_rate = 1 / unit.picosecond + """Collision frequency. Default 1 / unit.pisecond.""" + n_steps = 250 * unit.timestep + """ + Number of integration timesteps between each time the MCMC move + is applied. Default 1000. + """ + reassign_velocities = True + """ + If True, velocities are reassigned from the Maxwell-Boltzmann + distribution at the beginning of move. Default False. + """ + splitting = "V R O R V" + """ + Sequence of "R", "V", "O" substeps to be carried out at each timestep. + Default "V R O R V". + """ + n_restart_attempts = 20 + """ + Number of attempts to restart from Context if there are NaNs in the + energies after integration. Default 20. + """ + constraint_tolerance = 1e-06 + """Tolerance for the constraint solver. Default 1e-6.""" + barostat_frequency = 25 * unit.timestep + """ + Frequency at which volume scaling changes should be attempted. + Default 25 * unit.timestep. + """ + + @validator('timestep', 'collision_rate', 'n_steps', + 'n_restart_attempts', 'constraint_tolerance') + def must_be_positive(cls, v): + if v <= 0: + errmsg = ("timestep, temperature, collision_rate, n_steps, " + "n_restart_atttempts, constraint_tolerance must be " + "positive") + raise ValueError(errmsg) + return v + + @validator('timestep') + def is_time(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.picosecond): + raise ValueError("timestep must be in time units " + "(i.e. picoseconds)") + return v + + @validator('collision_rate') + def must_be_inverse_time(cls, v): + if not v.is_compatible_with(1 / unit.picosecond): + raise ValueError("collision_rate must be in inverse time " + "(i.e. 1/picoseconds)") + return v + + +class SimulationSettings(settings.SettingsBaseModel): + """ + Settings for simulation control, including lengths, + writing to disk, etc... + """ + class Config: + arbitrary_types_allowed = True + + minimization_steps = 5000 + """Number of minimization steps to perform. Default 10000.""" + equilibration_length: unit.Quantity + """ + Length of the equilibration phase in units of time. The total number of + steps from this equilibration length + (i.e. ``equilibration_length`` / :class:`IntegratorSettings.timestep`) + must be a multiple of the value defined for + :class:`IntegratorSettings.n_steps`. + """ + production_length: unit.Quantity + """ + Length of the production phase in units of time. The total number of + steps from this production length (i.e. + ``production_length`` / :class:`IntegratorSettings.timestep`) must be + a multiple of the value defined for :class:`IntegratorSettings.nsteps`. + """ + + # reporter settings + output_filename = 'abfe.nc' + """Path to the storage file for analysis. Default 'rbfe.nc'.""" + output_indices = 'all' + """ + Selection string for which part of the system to write coordinates for. + Default 'all'. + """ + checkpoint_interval = 100 * unit.timestep + """ + Frequency to write the checkpoint file. Default 50 * unit.timestep. + """ + checkpoint_storage = 'abfe_checkpoint.nc' + """ + Separate filename for the checkpoint file. Note, this should + not be a full path, just a filename. Default 'rbfe_checkpoint.nc'. + """ + forcefield_cache: Optional[str] = None + """ + Filename for caching small molecule residue templates so they can be + later reused. + """ + + @validator('equilibration_length', 'production_length') + def is_time(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.picosecond): + raise ValueError("Durations must be in time units") + return v + + @validator('minimization_steps', 'equilibration_length', + 'production_length', 'checkpoint_interval') + def must_be_positive(cls, v): + if v <= 0: + errmsg = ("Minimization steps, MD lengths, and checkpoint " + "intervals must be positive") + raise ValueError(errmsg) + return v + + +class AbsoluteTransformSettings(settings.Settings): + class Config: + arbitrary_types_allowed = True + + # Things for creating the systems + system_settings: SystemSettings + solvent_settings: SolventSettings + + # Alchemical settings + alchemical_settings: AlchemicalSettings + alchemsampler_settings: AlchemicalSamplerSettings + + # MD Engine things + engine_settings: OpenMMEngineSettings + + # Sampling State defining things + integrator_settings: IntegratorSettings + + # Simulation run settings + simulation_settings: SimulationSettings diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py new file mode 100644 index 000000000..8a3437f94 --- /dev/null +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -0,0 +1,1035 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +"""Equilibrium AFE Protocol using OpenMM + OpenMMTools + +This module implements the necessary methodology toolking to run calculate an +absolute free energy transformation using OpenMM tools and one of the +following methods: + - Hamiltonian Replica Exchange + - Self-adjusted mixture sampling + - Independent window sampling + +Acknowledgements +---------------- +* Originally based on a script from hydration.py in + `espaloma `_ + +TODO +---- +* Add support for restraints +* Improve this docstring by adding an example use case. + +""" +from __future__ import annotations + +import os +import logging + +from collections import defaultdict +import gufe +from gufe.components import Component +import numpy as np +import numpy.typing as npt +import openmm +from openff.toolkit import Molecule as OFFMol +from openff.units import unit +from openff.units.openmm import from_openmm, to_openmm, ensure_quantity +from openmmtools import multistate +from openmmtools.states import (SamplerState, + create_thermodynamic_state_protocol,) +from openmmtools.alchemy import (AlchemicalRegion, AbsoluteAlchemicalFactory, + AlchemicalState,) +from typing import Dict, List, Optional, Tuple +from openmm import app +from openmm import unit as omm_unit +from openmmforcefields.generators import SystemGenerator +import pathlib +from typing import Any, Iterable +import openmmtools +import uuid +import mdtraj as mdt + +from gufe import ( + settings, ChemicalSystem, SmallMoleculeComponent, + ProteinComponent, SolventComponent +) +from openfe.protocols.openmm_afe.afe_settings import ( + AbsoluteTransformSettings, SystemSettings, + SolventSettings, AlchemicalSettings, + AlchemicalSamplerSettings, OpenMMEngineSettings, + IntegratorSettings, SimulationSettings, +) +from openfe.protocols.openmm_rbfe._rbfe_utils import compute + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class AbsoluteTransformProtocolResult(gufe.ProtocolResult): + """Dict-like container for the output of a AbsoluteTransform""" + def __init__(self, **data): + super().__init__(**data) + # TODO: Detect when we have extensions and stitch these together? + if any(len(files['nc_paths']) > 2 for files in self.data['nc_files']): + raise NotImplementedError("Can't stitch together results yet") + + self._analyzers = [] + for f in self.data['nc_files']: + nc = f['nc_paths'][0] + chk = f['checkpoint_paths'][0] + reporter = multistate.MultiStateReporter( + storage=nc, + checkpoint_storage=chk) + analyzer = multistate.MultiStateSamplerAnalyzer(reporter) + self._analyzers.append(analyzer) + + def get_estimate(self): + """Free energy difference of this transformation + + Returns + ------- + dG : unit.Quantity + The free energy difference between the first and last states. This is + a Quantity defined with units. + + TODO + ---- + * Check this holds up completely for SAMS. + """ + dGs = [] + + for analyzer in self._analyzers: + # this returns: + # (matrix of) estimated free energy difference + # (matrix of) estimated statistical uncertainty (one S.D.) + dG, _ = analyzer.get_free_energy() + dG = (dG[0, -1] * analyzer.kT).in_units_of( + omm_unit.kilocalories_per_mole) + + dGs.append(dG) + + avg_val = np.average([i.value_in_unit(dGs[0].unit) for i in dGs]) + + return avg_val * dGs[0].unit + + def get_uncertainty(self): + """The uncertainty/error in the dG value""" + dGs = [] + + for analyzer in self._analyzers: + # this returns: + # (matrix of) estimated free energy difference + # (matrix of) estimated statistical uncertainty (one S.D.) + dG, _ = analyzer.get_free_energy() + dG = (dG[0, -1] * analyzer.kT).in_units_of( + omm_unit.kilocalories_per_mole) + + dGs.append(dG) + + std_val = np.std([i.value_in_unit(dGs[0].unit) for i in dGs]) + + return std_val * dGs[0].unit + + def get_rate_of_convergence(self): + raise NotImplementedError + + +class AbsoluteTransformProtocol(gufe.Protocol): + result_cls = AbsoluteTransformProtocolResult + _settings: AbsoluteTransformSettings + + @classmethod + def _default_settings(cls): + """A dictionary of initial settings for this creating this Protocol + + These settings are intended as a suitable starting point for creating + an instance of this protocol. It is recommended, however that care is + taken to inspect and customize these before performing a Protocol. + + Returns + ------- + Settings + a set of default settings + """ + return AbsoluteTransformSettings( + forcefield_settings=settings.OpenMMSystemGeneratorFFSettings(), + thermo_settings=settings.ThermoSettings( + temperature=298.15 * unit.kelvin, + pressure=1 * unit.bar, + ), + system_settings=SystemSettings(), + alchemical_settings=AlchemicalSettings(), + alchemsampler_settings=AlchemicalSamplerSettings(), + solvent_settings=SolventSettings(), + engine_settings=OpenMMEngineSettings(), + integrator_settings=IntegratorSettings( + timestep=4.0 * unit.femtosecond, + n_steps=250 * unit.timestep, + ), + simulation_settings=SimulationSettings( + equilibration_length=2.0 * unit.nanosecond, + production_length=5.0 * unit.nanosecond, + ) + ) + + @staticmethod + def _get_alchemical_components( + stateA: ChemicalSystem, + stateB: ChemicalSystem) -> Dict[str, List(Component)]: + """ + Checks equality of ChemicalSystem components across both states and + identify which components do not match. + + Parameters + ---------- + stateA : ChemicalSystem + The chemical system of end state A. + stateB : ChemicalSystem + The chemical system of end state B. + + Returns + ------- + alchemical_components : Dictionary + Dictionary containing a list of alchemical components for each + state. + """ + matched_components = {} + alchemical_components = {'stateA': [], 'stateB': {}} + + for keyA, valA in stateA.components.items(): + for keyB, valB in stateB.components.items(): + if valA.to_dict() == valB.to_dict(): + matched_components[keyA] = keyB + break + + # populate state A alchemical components + for keyA in stateA.components.keys(): + if keyA not in matched_components.keys(): + alchemical_components['stateA'].append(keyA) + + # populate state B alchemical components + for keyB in stateB.components.keys(): + if keyB not in matched_components.values(): + alchemical_components['stateB'].append(keyB) + + return alchemical_components + + @staticmethod + def _validate_alchemical_components( + stateA: ChemicalSystem, + alchemical_components: Dict[str, List[str]]): + """ + Checks that the ChemicalSystem alchemical components are correct. + + Parameters + ---------- + stateA : ChemicalSystem + The chemical system of end state A. + alchemical_components : Dict[str, List[str]] + Dictionary containing the alchemical components for + stateA and stateB. + + Raises + ------ + ValueError + If there are alchemical components in state B. + If there are non SmallMoleculeComponent alchemical species. + + Notes + ----- + * Currently doesn't support alchemical components in state B. + * Currently doesn't support alchemical components which are not + SmallMoleculeComponents. + """ + + # Crash out if there are any alchemical components in state B for now + if len(alchemical_components['stateB']) > 0: + errmsg = ("Components appearing in state B are not " + "currently supported") + raise ValueError(errmsg) + + # Crash out if any of the alchemical components are not + # SmallMoleculeComponent + for key in alchemical_components['stateA']: + comp = stateA.components[key] + if not isinstance(comp, SmallMoleculeComponent): + errmsg = ("Non SmallMoleculeComponent alchemical species " + "are not currently supported") + raise ValueError(errmsg) + + @staticmethod + def _validate_solvent(state: ChemicalSystem, nonbonded_method: str): + """ + Checks that the ChemicalSystem component has the right solvent + composition with an input nonbonded_method. + + Parameters + ---------- + state : ChemicalSystem + The chemical system to inspect + + Raises + ------ + ValueError + If there are multiple SolventComponents in the ChemicalSystem + or if there is a SolventComponent and + `nonbonded_method` is `nocutoff` + """ + solvents = 0 + for component in state.components.values(): + if isinstance(component, SolventComponent): + if nonbonded_method.lower() == "nocutoff": + errmsg = (f"{nonbonded_method} cannot be used for vacuum " + "transformation") + raise ValueError(errmsg) + solvents += 1 + + if solvents > 1: + errmsg = "Multiple SolventComponents found, only one is supported" + raise ValueError(errmsg) + + def _create( + self, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: Optional[Dict[str, gufe.ComponentMapping]] = None, + extends: Optional[gufe.ProtocolDAGResult] = None, + ) -> list[gufe.ProtocolUnit]: + # TODO: extensions + if extends: + raise NotImplementedError("Can't extend simulations yet") + + # Checks on the inputs! + # 1) check solvent compatibility + nonbonded_method = self.settings.system_settings.nonbonded_method + self._validate_solvent(stateA, nonbonded_method) + self._validate_solvent(stateB, nonbonded_method) + + # 2) check your alchemical molecules + # Note: currently only SmallMoleculeComponents in state A are + # supported + alchemical_comps = self._get_alchemical_components(stateA, stateB) + self._validate_alchemical_components(stateA, alchemical_comps) + + # Get a list of names for all the alchemical molecules + stateA_alchnames = ','.join( + [stateA.components[c].name for c in alchemical_comps['stateA']] + ) + + # our DAG has no dependencies, so just list units + units = [AbsoluteTransformUnit( + stateA=stateA, stateB=stateB, + settings=self.settings, + alchemical_components=alchemical_comps, + generation=0, repeat_id=i, + name=f'Absolute {stateA_alchnames}: repeat {i} generation 0') + for i in range(self.settings.alchemsampler_settings.n_repeats)] + + return units + + def _gather( + self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] + ) -> Dict[str, Any]: + # result units will have a repeat_id and generation + # first group according to repeat_id + repeats = defaultdict(list) + for d in protocol_dag_results: + pu: gufe.ProtocolUnitResult + for pu in d.protocol_unit_results: + if not pu.ok(): + continue + rep = pu.outputs['repeat_id'] + gen = pu.outputs['generation'] + + repeats[rep].append(( + gen, pu.outputs['nc'], + pu.outputs['last_checkpoint'])) + + data = [] + for rep_id, rep_data in sorted(repeats.items()): + # then sort within a repeat according to generation + nc_paths = [ + ncpath for gen, ncpath, nc_check in sorted(rep_data) + ] + chk_files = [ + nc_check for gen, ncpath, nc_check in sorted(rep_data) + ] + data.append({'nc_paths': nc_paths, + 'checkpoint_paths': chk_files}) + + return { + 'nc_files': data, + } + + +class AbsoluteTransformUnit(gufe.ProtocolUnit): + """ + Calculates an alchemical absolute free energy transformation of a ligand. + """ + def __init__(self, *, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + settings: settings.Settings, + alchemical_components: Dict[str, List[str]], + generation: int = 0, + repeat_id: int = 0, + name: Optional[str] = None,): + """ + Parameters + ---------- + stateA : ChemicalSystem + ChemicalSystem containing the components defining the state at + lambda 0. + stateB : ChemicalSystem + ChemicalSystem containing the components defining the state at + lambda 1. + settings : gufe.settings.Setings + Settings for the Absolute Tranformation Protocol. This can be + constructed by calling the + :class:`AbsoluteTransformProtocol.get_default_settings` method + to get a default set of settings. + name : str, optional + Human-readable identifier for this Unit + repeat_id : int, optional + Identifier for which repeat (aka replica/clone) this Unit is, + default 0 + generation : int, optional + Generation counter which keeps track of how many times this repeat + has been extended, default 0. + """ + super().__init__( + name=name, + stateA=stateA, + stateB=stateB, + settings=settings, + alchemical_components=alchemical_components, + repeat_id=repeat_id, + generation=generation, + ) + + ParseCompRet = Tuple[ + Optional[SolventComponent], Optional[ProteinComponent], + Dict[str, OFFMol], + ] + + @staticmethod + def _parse_components(state: ChemicalSystem) -> ParseCompRet: + """ + Establish all necessary Components for the transformation. + + Parameters + ---------- + state : ChemicalSystem + Chemical system to get all necessary components from. + + Returns + ------- + solvent_comp : Optional[SolventComponent] + If it exists, the SolventComponent for the state, otherwise None. + protein_comp : Optional[ProteinComponent] + If it exists, the ProteinComponent for the state, otherwise None. + openff_mols : Dict[str, openff.toolkit.Molecule] + A dictionary of openff.toolkit Molecules for each + SmallMoleculeComponent in the input state keyed by the original + component name. + + Raises + ------ + ValueError + If there are more than one ProteinComponent + + TODO + ---- + * Fix things so that we can have multiple ProteinComponents + """ + # Is the system solvated? + solvent_comp = None + for comp in state.components.values(): + if isinstance(comp, SolventComponent): + solvent_comp = comp + + # Is it complexed? + # TODO: we intentionally crash if there's multiple proteins, fix this! + protein_comp = None + for comp in state.components.values(): + if isinstance(comp, ProteinComponent): + if protein_comp is not None: + errmsg = "Multiple proteins are not currently supported" + raise ValueError(errmsg) + protein_comp = comp + + # Get a dictionary of SmallMoleculeComponents as openff Molecules + off_small_mols = {} + for key, comp in state.components.items(): + if isinstance(comp, SmallMoleculeComponent): + off_small_mols[key] = comp.to_openff() + + return solvent_comp, protein_comp, off_small_mols + + @staticmethod + def _get_sim_steps(time: unit.Quantity, timestep: unit.Quantity, + mc_steps: int) -> unit.Quantity: + """ + Get and validate the number of simulation steps + + Parameters + ---------- + time : unit.Quantity + Simulation time in femtoseconds. + timestep : unit.Quantity + Simulation timestep in femtoseconds. + mc_steps : int + Number of integration steps between MC moves. + + Returns + ------- + steps : unit.Quantity + Total number of integration steps + + Raises + ------ + ValueError + If the number of steps is not divisible by the number of mc_steps. + """ + steps = round(time / timestep) + + if (steps.m % mc_steps) != 0: # type: ignore + errmsg = (f"Simulation time {time} should contain a number of " + "steps divisible by the number of integrator " + f"timesteps between MC moves {mc_steps}") + ValueError(errmsg) + + return steps + + ModellerReturn = Tuple[app.Modeller, Dict[str, npt.NDArray]] + + @staticmethod + def _get_omm_modeller(protein_comp: Optional[ProteinComponent], + solvent_comp: Optional[SolventComponent], + off_mols: Dict[str, OFFMol], + omm_forcefield: app.ForceField, + solvent_settings: settings.SettingsBaseModel, + ) -> ModellerReturn: + """ + Generate an OpenMM Modeller class based on a potential input + ProteinComponent, and a set of openff molecules. + + Parameters + ---------- + protein_comp : Optional[ProteinComponent] + Protein Component, if it exists. + solvent_comp : Optional[ProteinCompoinent] + Solvent COmponent, if it exists. + off_mols : List[openff.toolkit.Molecule] + List of small molecules as OpenFF Molecule. + omm_forcefield : app.ForceField + ForceField object for system. + solvent_settings : settings.SettingsBaseModel + Solventation settings + + Returns + ------- + system_modeller : app.Modeller + OpenMM Modeller object generated from ProteinComponent and + OpenFF Molecules. + component_resids : Dict[str, npt.NDArray] + List of residue indices for each component in system. + """ + component_resids = {} + + def _add_small_mol(compkey: str, mol: OFFMol, + system_modeller: app.Modeller, + comp_resids: Dict[str, npt.NDArray]): + """ + Helper method to add off molecules to an existing Modeller + object and update a dictionary tracking residue indices + for each component. + """ + omm_top = mol.to_topology().to_openmm() + system_modeller.add( + omm_top, + ensure_quantity(mol.conformers[0], 'openmm') + ) + + nres = omm_top.getNumResidues() + resids = [res.index for res in system_modeller.topology.residues()] + comp_resids[key] = np.array(resids[-nres:]) + + # If there's a protein in the system, we add it first to Modeller + if protein_comp is not None: + system_modeller = app.Modeller(protein_comp.to_openmm_topology(), + protein_comp.to_openmm_positions()) + component_resids['protein'] = np.array( + [res.index for res in system_modeller.topology.residues()] + ) + + for key, mol in off_mols.items(): + _add_small_mol(key, mol, system_modeller, component_resids) + # Otherwise, we add the first small molecule, and then the rest + else: + mol_items = list(off_mols.items()) + + system_modeller = app.Modeller( + mol_items[0][1].to_topology().to_openmm(), + ensure_quantity(mol_items[0][1].conformers[0], 'openmm') + ) + component_resids[mol_items[0][0]] = np.array( + [res.index for res in system_modeller.topology.residues()] + ) + + for key, mol in mol_items[1:]: + _add_small_mol(key, mol, system_modeller, component_resids) + + # If there's solvent, add it and then set leftover resids to solvent + if solvent_comp is not None: + conc = solvent_comp.ion_concentration + pos = solvent_comp.positive_ion + neg = solvent_comp.negative_ion + + system_modeller.addSolvent( + omm_forcefield, + model=solvent_settings.solvent_model, + padding=to_openmm(solvent_settings.solvent_padding), + positiveIon=pos, negativeIon=neg, + ionicStrength=to_openmm(conc), + ) + + all_resids = np.array( + [res.index for res in system_modeller.topology.residues()] + ) + + existing_resids = np.concatenate( + [resids for resids in component_resids.values()] + ) + + component_resids['solvent'] = np.setdiff1d( + all_resids, existing_resids + ) + + return system_modeller, component_resids + + @staticmethod + def _get_alchemical_indices(omm_top: openmm.Topology, + comp_resids: Dict[str, npt.NDArray], + alchem_comps: Dict[str, List[str]] + ) -> List[int]: + """ + Get a list of atom indices for all the alchemical species + + Parameters + ---------- + omm_top : openmm.Topology + Topology of OpenMM System. + comp_resids : Dict[str, npt.NDArray] + A dictionary of residues for each component in the System. + alchem_comps : Dict[str, List[str]] + A dictionary of alchemical components for each end state. + + Return + ------ + atom_ids : List[int] + A list of atom indices for the alchemical species + """ + + # concatenate a list of residue indexes for all alchemical components + residxs = np.concatenate( + [comp_resids[key] for key in alchem_comps['stateA']] + ) + + # get the alchemicical residues from the topology + alchres = [ + r for r in omm_top.residues() if r.index in residxs + ] + + atom_ids = [] + + for res in alchres: + atom_ids.extend([at.index for at in res.atoms()]) + + return atom_ids + + @staticmethod + def _pre_minimize(system: openmm.System, + positions: omm_unit.Quantity) -> npt.NDArray: + """ + Short CPU minization of System to avoid GPU NaNs + + Parameters + ---------- + system : openmm.System + An OpenMM System to minimize. + positionns : openmm.unit.Quantity + Initial positions for the system. + + Returns + ------- + minimized_positions : npt.NDArray + Minimized positions + """ + integrator = openmm.VerletIntegrator(0.001) + context = openmm.Context( + system, integrator, + openmm.Platform.getPlatformByName('CPU'), + ) + context.setPositions(positions) + # Do a quick 100 steps minimization, usually avoids NaNs + openmm.LocalEnergyMinimizer.minimize( + context, maxIterations=100 + ) + state = context.getState(getPositions=True) + minimized_positions = state.getPositions(asNumpy=True) + return minimized_positions + + def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: + """Run the absolute free energy calculation. + + Parameters + ---------- + dry : bool + Do a dry run of the calculation, creating all necessary alchemical + system components (topology, system, sampler, etc...) but without + running the simulation. + verbose : bool + Verbose output of the simulation progress. Output is provided via + INFO level logging. + basepath : Pathlike, optional + Where to run the calculation, defaults to current working directory + + Returns + ------- + dict + Outputs created in the basepath directory or the debug objects + (i.e. sampler) if ``dry==True``. + + Attributes + ---------- + solvent : Optional[SolventComponent] + SolventComponent to be applied to the system + protein : Optional[ProteinComponent] + ProteinComponent for the system + openff_mols : List[openff.Molecule] + List of OpenFF Molecule objects for each SmallMoleculeComponent in + the stateA ChemicalSystem + """ + if verbose: + logger.info("setting up alchemical system") + + # Get basepath + if basepath is None: + # use cwd + basepath = pathlib.Path('.') + + # 0. General setup and settings dependency resolution step + + # a. Establish chemical system and their components + stateA = self._inputs['stateA'] + alchem_comps = self._inputs['alchemical_components'] + # Get the relevant solvent & protein components & openff molecules + solvent_comp, protein_comp, off_mols = self._parse_components(stateA) + + # b. Establish integration nsettings + settings = self._inputs['settings'] + sim_settings = settings.simulation_settings + timestep = settings.integrator_settings.timestep + mc_steps = settings.integrator_settings.n_steps.m + equil_time = sim_settings.equilibration_length.to('femtosecond') + prod_time = sim_settings.production_length.to('femtosecond') + equil_steps = self._get_sim_steps(equil_time, timestep, mc_steps) + prod_steps = self._get_sim_steps(prod_time, timestep, mc_steps) + + # 1. Parameterise System + # a. Set up SystemGenerator object + ffsettings = settings.forcefield_settings + protein_ffs = ffsettings.forcefields + small_ffs = ffsettings.small_molecule_forcefield + + constraints = { + 'hbonds': app.HBonds, + 'none': None, + 'allbonds': app.AllBonds, + 'hangles': app.HAngles + # vvv can be None so string it + }[str(ffsettings.constraints).lower()] + + forcefield_kwargs = { + 'constraints': constraints, + 'rigidWater': ffsettings.rigid_water, + 'removeCMMotion': ffsettings.remove_com, + 'hydrogenMass': ffsettings.hydrogen_mass * omm_unit.amu, + } + + nonbonded_method = { + 'pme': app.PME, + 'nocutoff': app.NoCutoff, + 'cutoffnonperiodic': app.CutoffNonPeriodic, + 'cutoffperiodic': app.CutoffPeriodic, + 'ewald': app.Ewald + }[settings.system_settings.nonbonded_method.lower()] + + nonbonded_cutoff = to_openmm( + settings.system_settings.nonbonded_cutoff + ) + + periodic_kwargs = { + 'nonbondedMethod': nonbonded_method, + 'nonbondedCutoff': nonbonded_cutoff, + } + + if nonbonded_method is not app.CutoffNonPeriodic: + nonperiodic_kwargs = { + 'nonbondedMethod': app.NoCutoff, + } + else: + nonperiodic_kwargs = periodic_kwargs + + system_generator = SystemGenerator( + forcefields=protein_ffs, + small_molecule_forcefield=small_ffs, + forcefield_kwargs=forcefield_kwargs, + nonperiodic_forcefield_kwargs=nonperiodic_kwargs, + periodic_forcefield_kwargs=periodic_kwargs, + cache=settings.simulation_settings.forcefield_cache, + ) + + # Add a barostat if necessary note, was broken pre 0.11.2 of openmmff + pressure = settings.thermo_settings.pressure + temperature = settings.thermo_settings.temperature + if solvent_comp is not None: + barostat = openmm.MonteCarloBarostat( + ensure_quantity(pressure, 'openmm'), + ensure_quantity(temperature, 'openmm') + ) + system_generator.barostat = barostat + + # force the creation of parameters for the small molecules + # this is cached and shouldn't incur further cost + for mol in off_mols.values(): + system_generator.create_system(mol.to_topology().to_openmm(), + molecules=[mol]) + + # b. Get OpenMM Modller + a dictionary of resids for each component + system_modeller, comp_resids = self._get_omm_modeller( + protein_comp, solvent_comp, off_mols, system_generator.forcefield, + settings.solvent_settings + ) + + # c. Get OpenMM topology + system_topology = system_modeller.getTopology() + + # d. Get initial positions (roundtrip via off_units to canocalize) + positions = to_openmm(from_openmm(system_modeller.getPositions())) + + # d. Create System + omm_system = system_generator.create_system( + system_modeller.topology, + molecules=list(off_mols.values()) + ) + + # e. Get a list of indices for the alchemical species + alchemical_indices = self._get_alchemical_indices( + system_topology, comp_resids, alchem_comps + ) + + # 2. Pre-minimize System (Test + Avoid NaNs) + positions = self._pre_minimize(omm_system, positions) + + # 3. Create the alchemical system + # a. Get alchemical settings + alchem_settings = settings.alchemical_settings + + # b. Set the alchemical region & alchemical factory + # TODO: add support for all the variants here + # TODO: check that adding indices this way works + alchemical_region = AlchemicalRegion( + alchemical_atoms=alchemical_indices, + ) + alchemical_factory = AbsoluteAlchemicalFactory() + alchemical_system = alchemical_factory.create_alchemical_system( + omm_system, alchemical_region + ) + + # c. Create the lambda schedule + # TODO: do this properly using LambdaProtocol + # TODO: double check we definitely don't need to define + # temperature & pressure (pressure sure that's the case) + lambdas = dict() + n_elec = alchem_settings.lambda_elec_windows + n_vdw = alchem_settings.lambda_vdw_windows + 1 + lambdas['lambda_electrostatics'] = np.concatenate( + [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] + ) + lambdas['lambda_sterics'] = np.concatenate( + [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] + ) + + # d. Check that the lambda schedule matches n_replicas + # TODO: undo for SAMS + n_replicas = settings.alchemsampler_settings.n_replicas + + if n_replicas != (len(lambdas['lambda_sterics'])): + errmsg = (f"Number of replicas {n_replicas} " + "does not equal the number of lambda windows ") + raise ValueError(errmsg) + + # 4. Create compound states + alchemical_state = AlchemicalState.from_system(alchemical_system) + constants = dict() + constants['temperature'] = ensure_quantity(temperature, 'openmm') + if solvent_comp is not None: + constants['pressure'] = ensure_quantity(pressure, 'openmm') + cmp_states = create_thermodynamic_state_protocol( + alchemical_system, + protocol=lambdas, + constants=constants, + composable_states=[alchemical_state], + ) + + # 5. Create the sampler states + # Fill up a list of sampler states all with the same starting state + sampler_state = SamplerState(positions=positions) + if omm_system.usesPeriodicBoundaryConditions(): + box = omm_system.getDefaultPeriodicBoxVectors() + sampler_state.box_vectors = box + + sampler_states = [sampler_state for _ in cmp_states] + + # 6. Create the multistate reporter + # a. Get the sub selection of the system to print coords for + mdt_top = mdt.Topology.from_openmm(system_topology) + selection_indices = mdt_top.select( + settings.simulation_settings.output_indices + ) + + # b. Create the multistate reporter + reporter = multistate.MultiStateReporter( + storage=basepath / settings.simulation_settings.output_filename, + analysis_particle_indices=selection_indices, + checkpoint_interval=settings.simulation_settings.checkpoint_interval.m, + checkpoint_storage=basepath / settings.simulation_settings.checkpoint_storage, + ) + + # 7. Get platform and context caches + platform = compute.get_openmm_platform( + settings.engine_settings.compute_platform + ) + + # a. Create context caches (energy + sampler) + # Note: these needs to exist on the compute node + energy_context_cache = openmmtools.cache.ContextCache( + capacity=None, time_to_live=None, platform=platform, + ) + + sampler_context_cache = openmmtools.cache.ContextCache( + capacity=None, time_to_live=None, platform=platform, + ) + + # 8. Set the integrator + # a. get integrator settings + integrator_settings = settings.integrator_settings + + # b. create langevin integrator + integrator = openmmtools.mcmc.LangevinSplittingDynamicsMove( + timestep=to_openmm(integrator_settings.timestep), + collision_rate=to_openmm(integrator_settings.collision_rate), + n_steps=integrator_settings.n_steps.m, + reassign_velocities=integrator_settings.reassign_velocities, + n_restart_attempts=integrator_settings.n_restart_attempts, + constraint_tolerance=integrator_settings.constraint_tolerance, + splitting=integrator_settings.splitting + ) + + # 9. Create sampler + sampler_settings = settings.alchemsampler_settings + + if sampler_settings.sampler_method.lower() == "repex": + sampler = multistate.ReplicaExchangeSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_target_error=sampler_settings.online_analysis_target_error.m, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations + ) + elif sampler_settings.sampler_method.lower() == "sams": + sampler = multistate.SAMSSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations, + flatness_criteria=sampler_settings.flatness_criteria, + gamma0=sampler_settings.gamma0, + ) + elif sampler_settings.sampler_method.lower() == 'independent': + sampler = multistate.MultiStateSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_target_error=sampler_settings.online_analysis_target_error.m, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations + ) + else: + raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") + + sampler.create( + thermodynamic_states=cmp_states, + sampler_states=sampler_states, + storage=reporter + ) + + sampler.energy_context_cache = energy_context_cache + sampler.sampler_context_cache = sampler_context_cache + + if not dry: + # minimize + if verbose: + logger.info("minimizing systems") + + sampler.minimize( + max_iterations=settings.simulation_settings.minimization_steps + ) + + # equilibrate + if verbose: + logger.info("equilibrating systems") + + sampler.equilibrate(int(equil_steps.m / mc_steps)) # type: ignore + + # production + if verbose: + logger.info("running production phase") + + sampler.extend(int(prod_steps.m / mc_steps)) # type: ignore + + # close reporter when you're done + reporter.close() + + nc = basepath / settings.simulation_settings.output_filename + chk = basepath / settings.simulation_settings.checkpoint_storage + return { + 'nc': nc, + 'last_checkpoint': chk, + } + else: + # close reporter when you're done, prevent file handle clashes + reporter.close() + + # clean up the reporter file + fns = [basepath / settings.simulation_settings.output_filename, + basepath / settings.simulation_settings.checkpoint_storage] + for fn in fns: + os.remove(fn) + return {'debug': {'sampler': sampler}} + + def _execute( + self, ctx: gufe.Context, **kwargs, + ) -> Dict[str, Any]: + # create directory for *this* unit within the context of the *DAG* + # stops output files mashing into each other within a DAG + myid = uuid.uuid4() + mypath = pathlib.Path(os.path.join(ctx.shared, str(myid))) + mypath.mkdir(parents=True, exist_ok=False) + + outputs = self.run(basepath=mypath) + + return { + 'repeat_id': self.repeat_id, + 'generation': self.generation, + **outputs + } diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py index ee5e36c82..5f3967ccf 100644 --- a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py @@ -1,195 +1,176 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -import gufe import pytest -from unittest import mock -from openff.units import unit -from openff.units.openmm import ensure_quantity -from importlib import resources -import xml.etree.ElementTree as ET - -from openmm import app, XmlSerializer from openmmtools.multistate.multistatesampler import MultiStateSampler +from openfe import ChemicalSystem, SolventComponent +from openfe.protocols import openmm_afe + -from openfe import setup -from openfe.protocols import openmm_abfe -from openmmforcefields.generators import SMIRNOFFTemplateGenerator -from openff.units.openmm import ensure_quantity +@pytest.fixture +def vacuum_system(): + return ChemicalSystem({}) @pytest.fixture def benzene_vacuum_system(benzene_modifications): - return setup.ChemicalSystem( + return ChemicalSystem( {'ligand': benzene_modifications['benzene']}, ) @pytest.fixture -def benzene_system(benzene_modifications): - return setup.ChemicalSystem( - {'ligand': benzene_modifications['benzene'], - 'solvent': setup.SolventComponent( - positive_ion='Na', negative_ion='Cl', - ion_concentration=0.15 * unit.molar) - }, +def solvent_system(): + return ChemicalSystem( + {'solvent': SolventComponent(), } ) @pytest.fixture -def benzene_complex_system(benzene_modifications, T4_protein_component): - return setup.ChemicalSystem( +def benzene_solvent_system(benzene_modifications): + return ChemicalSystem( {'ligand': benzene_modifications['benzene'], - 'solvent': setup.SolventComponent( - positive_ion='Na', negative_ion='Cl', - ion_concentration=0.15 * unit.molar), - 'protein': T4_protein_component,} + 'solvent': SolventComponent(), + }, ) @pytest.fixture -def toluene_vacuum_system(benzene_modifications): - return setup.ChemicalSystem( - {'ligand': benzene_modifications['toluene']}, +def protein_system(benzene_modifications, T4_protein_component): + return ChemicalSystem( + {'solvent': SolventComponent(), + 'protein': T4_protein_component, } ) @pytest.fixture -def toluene_system(benzene_modifications): - return setup.ChemicalSystem( - {'ligand': benzene_modifications['toluene'], - 'solvent': setup.SolventComponent( - positive_ion='Na', negative_ion='Cl', - ion_concentration=0.15 * unit.molar), - }, - ) - - -@pytest.fixture -def toluene_complex_system(benzene_modifications, T4_protein_component): - return setup.ChemicalSystem( - {'ligand': benzene_modifications['toluene'], - 'solvent': setup.SolventComponent( - positive_ion='Na', negative_ion='Cl', - ion_concentration=0.15 * unit.molar), - 'protein': T4_protein_component,} +def benzene_complex_system(benzene_modifications, T4_protein_component): + return ChemicalSystem( + {'ligand': benzene_modifications['benzene'], + 'solvent': SolventComponent(), + 'protein': T4_protein_component, } ) def test_create_default_settings(): - settings = openmm_abfe.AbsoluteTransform.default_settings() - + settings = openmm_afe.AbsoluteTransformProtocol.default_settings() assert settings def test_create_default_protocol(): # this is roughly how it should be created - protocol = openmm_abfe.AbsoluteTransform( - settings=openmm_abfe.AbsoluteTransform.default_settings(), + protocol = openmm_afe.AbsoluteTransformProtocol( + settings=openmm_afe.AbsoluteTransformProtocol.default_settings(), ) - assert protocol def test_serialize_protocol(): - protocol = openmm_abfe.AbsoluteTransform( - settings=openmm_abfe.AbsoluteTransform.default_settings(), + protocol = openmm_afe.AbsoluteTransformProtocol( + settings=openmm_afe.AbsoluteTransformProtocol.default_settings(), ) ser = protocol.to_dict() - - ret = openmm_abfe.AbsoluteTransform.from_dict(ser) - + ret = openmm_afe.AbsoluteTransformProtocol.from_dict(ser) assert protocol == ret @pytest.mark.parametrize('method', [ 'repex', 'sams', 'independent', 'InDePeNdENT' ]) -def test_dry_run_default_vacuum(benzene_vacuum_system, method, tmpdir): - vac_settings = openmm_abfe.AbsoluteTransform.default_settings() +def test_dry_run_default_vacuum(benzene_vacuum_system, vacuum_system, + method, tmpdir): + vac_settings = openmm_afe.AbsoluteTransformProtocol.default_settings() vac_settings.system_settings.nonbonded_method = 'nocutoff' - vac_settings.sampler_settings.sampler_method = method - vac_settings.sampler_settings.n_repeats = 1 + vac_settings.alchemsampler_settings.sampler_method = method + vac_settings.alchemsampler_settings.n_repeats = 1 - protocol = openmm_abfe.AbsoluteTransform( + protocol = openmm_afe.AbsoluteTransformProtocol( settings=vac_settings, ) # create DAG from protocol and take first (and only) work unit from within dag = protocol.create( - chem_system=benzene_vacuum_system, + stateA=benzene_vacuum_system, + stateB=vacuum_system, + mapping=None, ) unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - assert isinstance(unit.run(dry=True)['debug']['sampler'], - MultiStateSampler) + sampler = unit.run(dry=True)['debug']['sampler'] + assert isinstance(sampler, MultiStateSampler) + assert not sampler.is_periodic @pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) -def test_dry_run_ligand(benzene_system, method, tmpdir): +def test_dry_run_solvent(benzene_solvent_system, solvent_system, method, tmpdir): # this might be a bit time consuming - settings = openmm_abfe.AbsoluteTransform.default_settings() - settings.sampler_settings.sampler_method = method - settings.sampler_settings.n_repeats = 1 + settings = openmm_afe.AbsoluteTransformProtocol.default_settings() + settings.alchemsampler_settings.sampler_method = method + settings.alchemsampler_settings.n_repeats = 1 - protocol = openmm_abfe.AbsoluteTransform( + protocol = openmm_afe.AbsoluteTransformProtocol( settings=settings, ) dag = protocol.create( - chem_system=benzene_system, + stateA=benzene_solvent_system, + stateB=solvent_system, + mapping=None, ) unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - # Returns debug objects if everything is OK - assert isinstance(unit.run(dry=True)['debug']['sampler'], - MultiStateSampler) + sampler = unit.run(dry=True)['debug']['sampler'] + assert isinstance(sampler, MultiStateSampler) + assert sampler.is_periodic @pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) -def test_dry_run_complex(benzene_complex_system, method, tmpdir): +def test_dry_run_complex(benzene_complex_system, protein_system, + method, tmpdir): # this will be very time consuming - settings = openmm_abfe.AbsoluteTransform.default_settings() - settings.sampler_settings.sampler_method = method - settings.sampler_settings.n_repeats = 1 + settings = openmm_afe.AbsoluteTransformProtocol.default_settings() + settings.alchemsampler_settings.sampler_method = method + settings.alchemsampler_settings.n_repeats = 1 - protocol = openmm_abfe.AbsoluteTransform( + protocol = openmm_afe.AbsoluteTransformProtocol( settings=settings, ) dag = protocol.create( - chem_system=benzene_complex_system, + stateA=benzene_complex_system, + stateB=protein_system, + mapping=None, ) unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - # Returns debug contents if everything is OK - assert isinstance(unit.run(dry=True)['debug']['sampler'], - MultiStateSampler) - - -def test_n_replicas_not_n_windows(benzene_vacuum_system, tmpdir): - # For PR #125 we pin such that the number of lambda windows - # equals the numbers of replicas used - TODO: remove limitation - settings = openmm_abfe.AbsoluteTransform.default_settings() - # default lambda windows is 24 - settings.sampler_settings.n_replicas = 13 - settings.system_settings.nonbonded_method = 'nocutoff' - - errmsg = "Number of replicas 13 does not equal" - - with tmpdir.as_cwd(): - with pytest.raises(ValueError, match=errmsg): - p = openmm_abfe.AbsoluteTransform( - settings=settings, - ) - dag = p.create( - chem_system=benzene_vacuum_system, - ) - unit = list(dag.protocol_units)[0] - unit.run(dry=True) - - + sampler = unit.run(dry=True)['debug']['sampler'] + assert isinstance(sampler, MultiStateSampler) + assert sampler.is_periodic + + +#def test_n_replicas_not_n_windows(benzene_vacuum_system, tmpdir): +# # For PR #125 we pin such that the number of lambda windows +# # equals the numbers of replicas used - TODO: remove limitation +# settings = openmm_afe.AbsoluteTransform.default_settings() +# # default lambda windows is 24 +# settings.sampler_settings.n_replicas = 13 +# settings.system_settings.nonbonded_method = 'nocutoff' +# +# errmsg = "Number of replicas 13 does not equal" +# +# with tmpdir.as_cwd(): +# with pytest.raises(ValueError, match=errmsg): +# p = openmm_afe.AbsoluteTransform( +# settings=settings, +# ) +# dag = p.create( +# chem_system=benzene_vacuum_system, +# ) +# unit = list(dag.protocol_units)[0] +# unit.run(dry=True) +# +# #def test_vaccuum_PME_error(benzene_system, benzene_modifications, # benzene_to_toluene_mapping): # # state B doesn't have a solvent component (i.e. its vacuum) From a1fa702d21604ab37f93b808c493dfc2ed81b7cd Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 1 Apr 2023 14:34:07 +0100 Subject: [PATCH 07/74] fix mypy issues + start settings tests --- .../setup/test_openmm_abfe_equil_protocols.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py index 5f3967ccf..62aa0db01 100644 --- a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py @@ -51,11 +51,31 @@ def benzene_complex_system(benzene_modifications, T4_protein_component): ) +@pytest.fixture() +def default_settings(): + return openmm_afe.AbsoluteTransformProtocol.default_settings() + + def test_create_default_settings(): settings = openmm_afe.AbsoluteTransformProtocol.default_settings() assert settings +@pytest.mark.parametrize('method, fail', [ + ['Pme', False], + ['noCutoff', False], + ['Ewald', True], + ['CutoffNonPeriodic', True], + ['CutoffPeriodic', True], +]) +def test_settings_nonbonded(method, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="Only PME"): + default_settings.system_settings.nonbonded_method = method + else: + default_settings.system_settings.nonbonded_method = method + + def test_create_default_protocol(): # this is roughly how it should be created protocol = openmm_afe.AbsoluteTransformProtocol( From dc807cf3c2bc15fc1b781876564c22d3f9b3c674 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 1 Apr 2023 14:34:32 +0100 Subject: [PATCH 08/74] actually include mypy fixes --- openfe/protocols/openmm_afe/equil_afe_methods.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index 8a3437f94..a070dcdaa 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -176,7 +176,7 @@ def _default_settings(cls): @staticmethod def _get_alchemical_components( stateA: ChemicalSystem, - stateB: ChemicalSystem) -> Dict[str, List(Component)]: + stateB: ChemicalSystem) -> Dict[str, List[Component]]: """ Checks equality of ChemicalSystem components across both states and identify which components do not match. @@ -195,7 +195,7 @@ def _get_alchemical_components( state. """ matched_components = {} - alchemical_components = {'stateA': [], 'stateB': {}} + alchemical_components = {'stateA': [], 'stateB': []} for keyA, valA in stateA.components.items(): for keyB, valB in stateB.components.items(): From d2af3a004b32d3890bd210236d3b3236480076ed Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 1 Apr 2023 14:54:16 +0100 Subject: [PATCH 09/74] Does mypy like this? --- openfe/protocols/openmm_afe/equil_afe_methods.py | 4 +++- .../tests/setup/test_openmm_abfe_equil_protocols.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index a070dcdaa..395f8fd23 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -195,7 +195,9 @@ def _get_alchemical_components( state. """ matched_components = {} - alchemical_components = {'stateA': [], 'stateB': []} + alchemical_components: Dict[str, List[Any]] = { + 'stateA': [], 'stateB': [] + } for keyA, valA in stateA.components.items(): for keyB, valB in stateB.components.items(): diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py index 62aa0db01..68835c4bb 100644 --- a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py @@ -2,6 +2,7 @@ # For details, see https://github.com/OpenFreeEnergy/openfe import pytest from openmmtools.multistate.multistatesampler import MultiStateSampler +from openff.units import unit as offunit from openfe import ChemicalSystem, SolventComponent from openfe.protocols import openmm_afe @@ -68,7 +69,7 @@ def test_create_default_settings(): ['CutoffNonPeriodic', True], ['CutoffPeriodic', True], ]) -def test_settings_nonbonded(method, fail, default_settings): +def test_systemsettings_nonbonded(method, fail, default_settings): if fail: with pytest.raises(ValueError, match="Only PME"): default_settings.system_settings.nonbonded_method = method @@ -76,6 +77,15 @@ def test_settings_nonbonded(method, fail, default_settings): default_settings.system_settings.nonbonded_method = method +@pytest.mark.parametrize('val, match', [ + [-1.0 * offunit.nanometer, 'must be a positive'], + [2.5 * offunit.picoseconds, 'distance units'], +]) +def test_systemsettings_cutoff_errors(val, match, default_settings): + with pytest.raises(ValueError, match=match): + default_settings.system_settings.nonbonded_cutoff = val + + def test_create_default_protocol(): # this is roughly how it should be created protocol = openmm_afe.AbsoluteTransformProtocol( From e2aced0495bc594bbdfa6929652b779c0424930e Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 1 Apr 2023 16:36:03 +0100 Subject: [PATCH 10/74] a few more tests --- .../setup/test_openmm_abfe_equil_protocols.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py index 68835c4bb..29eee37ee 100644 --- a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py @@ -86,6 +86,46 @@ def test_systemsettings_cutoff_errors(val, match, default_settings): default_settings.system_settings.nonbonded_cutoff = val +@pytest.mark.parametrize('val, fail', [ + ['TiP3p', False], + ['SPCE', False], + ['tip4pEw', False], + ['Tip5p', False], + ['opc', True], + ['tips', True], + ['tip3p-fb', True], +]) +def test_solvent_model_setting(val, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="allowed solvent_model"): + default_settings.solvent_settings.solvent_model = val + else: + default_settings.solvent_settings.solvent_model = val + + +@pytest.mark.parametrize('val, match', [ + [-1.0 * offunit.nanometer, 'must be a positive'], + [2.5 * offunit.picoseconds, 'distance units'], +]) +def test_incorrect_padding(val, match, default_settings): + with pytest.raises(ValueError, match=match): + default_settings.solvent_settings.solvent_padding = val + + +@pytest.mark.parametrize('val', [ + {'elec': 0, 'vdw': 5}, + {'elec': -2, 'vdw': 5}, + {'elec': 5, 'vdw': -2}, + {'elec': 5, 'vdw': 0}, +]) +def test_incorrect_window_settings(val, default_settings): + errmsg = "lambda steps must be positive" + alchem_settings = default_settings.alchemical_settings + with pytest.raises(ValueError, match=errmsg): + alchem_settings.lambda_elec_windows = val['elec'] + alchem_settings.lambda_vdw_windows = val['vdw'] + + def test_create_default_protocol(): # this is roughly how it should be created protocol = openmm_afe.AbsoluteTransformProtocol( From 45c82db3845e836efcf9af030516f3e710e93d37 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 1 Apr 2023 16:36:24 +0100 Subject: [PATCH 11/74] some setting changes --- openfe/protocols/openmm_afe/afe_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/afe_settings.py b/openfe/protocols/openmm_afe/afe_settings.py index 135c72038..b6f0940d7 100644 --- a/openfe/protocols/openmm_afe/afe_settings.py +++ b/openfe/protocols/openmm_afe/afe_settings.py @@ -108,7 +108,7 @@ class AlchemicalSettings(settings.SettingsBaseModel): @validator('lambda_elec_windows', 'lambda_vdw_windows') def must_be_positive(cls, v): - if v < 0: + if v <= 0: errmsg = ("Number of lambda steps must be positive ") raise ValueError(errmsg) return v @@ -163,7 +163,7 @@ class Config: ``online_analysis_target_error``. If set, will write a yaml file with real time analysis data. Default `None`. """ - online_analysis_target_error = 0.1 * unit.boltzmann_constant * unit.kelvin + online_analysis_target_error = 0.05 * unit.boltzmann_constant * unit.kelvin """ Target error for the online analysis measured in kT. Once the free energy is at or below this value, the simulation will be considered complete. From c8d9986e1e052d03b3b8e893647231cf5d3d5f8f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 1 Apr 2023 20:57:52 +0100 Subject: [PATCH 12/74] more tests --- openfe/protocols/openmm_afe/afe_settings.py | 29 ++++++-- .../setup/test_openmm_abfe_equil_protocols.py | 73 +++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/openfe/protocols/openmm_afe/afe_settings.py b/openfe/protocols/openmm_afe/afe_settings.py index b6f0940d7..39d89848d 100644 --- a/openfe/protocols/openmm_afe/afe_settings.py +++ b/openfe/protocols/openmm_afe/afe_settings.py @@ -168,7 +168,7 @@ class Config: Target error for the online analysis measured in kT. Once the free energy is at or below this value, the simulation will be considered complete. """ - online_analysis_minimum_iterations = 50 + online_analysis_minimum_iterations = 100 """ Number of iterations which must pass before online analysis is carried out. Default 50. @@ -211,12 +211,19 @@ def supported_sampler(cls, v): raise ValueError(errmsg) return v + @validator('n_repeats', 'n_replicas') + def must_be_positive(cls, v): + if v <= 0: + errmsg = "n_repeats and n_replicas must be positive values" + raise ValueError(errmsg) + return v + @validator('online_analysis_target_error', 'n_repeats', 'online_analysis_minimum_iterations', 'gamma0', 'n_replicas') - def must_be_positive(cls, v): + def must_be_zero_or_positive(cls, v): if v < 0: errmsg = ("Online analysis target error, minimum iteration " - "and SAMS gamm0 must be 0 or positive values") + "and SAMS gamm0 must be 0 or positive values.") raise ValueError(errmsg) return v @@ -259,13 +266,19 @@ class Config: Default 25 * unit.timestep. """ - @validator('timestep', 'collision_rate', 'n_steps', - 'n_restart_attempts', 'constraint_tolerance') + @validator('collision_rate', 'n_restart_attempts') + def must_be_positive_or_zero(cls, v): + if v < 0: + errmsg = ("collision_rate, and n_restart_attempts must be " + "zero or positive values") + raise ValueError(errmsg) + return v + + @validator('timestep', 'n_steps', 'constraint_tolerance') def must_be_positive(cls, v): if v <= 0: - errmsg = ("timestep, temperature, collision_rate, n_steps, " - "n_restart_atttempts, constraint_tolerance must be " - "positive") + errmsg = ("timestep, n_steps, constraint_tolerance " + "must be positive values") raise ValueError(errmsg) return v diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py index 29eee37ee..b55a139af 100644 --- a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/setup/test_openmm_abfe_equil_protocols.py @@ -126,6 +126,79 @@ def test_incorrect_window_settings(val, default_settings): alchem_settings.lambda_vdw_windows = val['vdw'] +@pytest.mark.parametrize('val, fail', [ + ['LOGZ-FLATNESS', False], + ['MiniMum-VisiTs', False], + ['histogram-flatness', False], + ['roundrobin', True], + ['parsnips', True] +]) +def test_supported_flatness_settings(val, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="following flatness"): + default_settings.alchemsampler_settings.flatness_criteria = val + else: + default_settings.alchemsampler_settings.flatness_criteria = val + + +@pytest.mark.parametrize('var, val', [ + ['online_analysis_target_error', + -0.05 * offunit.boltzmann_constant * offunit.kelvin], + ['n_repeats', -1], + ['n_repeats', 0], + ['online_analysis_minimum_iterations', -2], + ['gamma0', -2], + ['n_replicas', -2], + ['n_replicas', 0] +]) +def test_nonnegative_alchem_settings(var, val, default_settings): + alchem_settings = default_settings.alchemsampler_settings + with pytest.raises(ValueError, match="positive values"): + setattr(alchem_settings, var, val) + + +@pytest.mark.parametrize('val, fail', [ + ['REPEX', False], + ['SaMs', False], + ['independent', False], + ['noneq', True], + ['AWH', True] +]) +def test_supported_sampler(val, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="sampler_method values"): + default_settings.alchemsampler_settings.sampler_method = val + else: + default_settings.alchemsampler_settings.sampler_method = val + + +@pytest.mark.parametrize('var, val', [ + ['collision_rate', -1 / offunit.picosecond], + ['n_restart_attempts', -2], + ['timestep', 0 * offunit.femtosecond], + ['timestep', -2 * offunit.femtosecond], + ['n_steps', 0 * offunit.timestep], + ['n_steps', -1 * offunit.timestep], + ['constraint_tolerance', -2e-06], + ['constraint_tolerance', 0] +]) +def test_nonnegative_integrator_settings(var, val, default_settings): + int_settings = default_settings.integrator_settings + with pytest.raises(ValueError, match="positive values"): + setattr(int_settings, var, val) + + +def test_timestep_is_not_time(default_settings): + with pytest.raises(ValueError, match="time units"): + default_settings.integrator_settings.timestep = 1 * offunit.nanometer + + +def test_collision_is_not_inverse_time(default_settings): + with pytest.raises(ValueError, match="inverse time"): + int_settings = default_settings.integrator_settings + int_settings.collision_rate = 1 * offunit.picosecond + + def test_create_default_protocol(): # this is roughly how it should be created protocol = openmm_afe.AbsoluteTransformProtocol( From aed8a5a20d693706a253778b96565ca194bd6dc7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 2 Apr 2023 01:32:34 +0100 Subject: [PATCH 13/74] update tests, and default settings --- openfe/protocols/openmm_afe/afe_settings.py | 4 +- .../protocols/openmm_afe/equil_afe_methods.py | 8 +- .../openmm_rbfe/_rbfe_utils/compute.py | 9 + openfe/tests/protocols/conftest.py | 29 +++ .../test_openmm_abfe_equil_protocols.py | 148 --------------- .../protocols/test_openmm_abfe_settings.py | 176 ++++++++++++++++++ 6 files changed, 220 insertions(+), 154 deletions(-) create mode 100644 openfe/tests/protocols/conftest.py rename openfe/tests/{setup => protocols}/test_openmm_abfe_equil_protocols.py (62%) create mode 100644 openfe/tests/protocols/test_openmm_abfe_settings.py diff --git a/openfe/protocols/openmm_afe/afe_settings.py b/openfe/protocols/openmm_afe/afe_settings.py index 39d89848d..707a71364 100644 --- a/openfe/protocols/openmm_afe/afe_settings.py +++ b/openfe/protocols/openmm_afe/afe_settings.py @@ -163,12 +163,12 @@ class Config: ``online_analysis_target_error``. If set, will write a yaml file with real time analysis data. Default `None`. """ - online_analysis_target_error = 0.05 * unit.boltzmann_constant * unit.kelvin + online_analysis_target_error = 0.005 * unit.boltzmann_constant * unit.kelvin """ Target error for the online analysis measured in kT. Once the free energy is at or below this value, the simulation will be considered complete. """ - online_analysis_minimum_iterations = 100 + online_analysis_minimum_iterations = 1000 """ Number of iterations which must pass before online analysis is carried out. Default 50. diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index 395f8fd23..79b4f176d 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -944,6 +944,8 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # 9. Create sampler sampler_settings = settings.alchemsampler_settings + # Select the right sampler + # Note: doesn't need else, settings already validates choices if sampler_settings.sampler_method.lower() == "repex": sampler = multistate.ReplicaExchangeSampler( mcmc_moves=integrator, @@ -966,8 +968,6 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: online_analysis_target_error=sampler_settings.online_analysis_target_error.m, online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations ) - else: - raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") sampler.create( thermodynamic_states=cmp_states, @@ -1031,7 +1031,7 @@ def _execute( outputs = self.run(basepath=mypath) return { - 'repeat_id': self.repeat_id, - 'generation': self.generation, + 'repeat_id': self._inputs['repeat_id'], + 'generation': self._inputs['generation'], **outputs } diff --git a/openfe/protocols/openmm_rbfe/_rbfe_utils/compute.py b/openfe/protocols/openmm_rbfe/_rbfe_utils/compute.py index dee5ebe63..ac0306385 100644 --- a/openfe/protocols/openmm_rbfe/_rbfe_utils/compute.py +++ b/openfe/protocols/openmm_rbfe/_rbfe_utils/compute.py @@ -22,6 +22,15 @@ def get_openmm_platform(platform_name=None): from openmmtools.utils import get_fastest_platform platform = get_fastest_platform(minimum_precision='mixed') else: + try: + platform_name = { + 'cpu': 'CPU', + 'opencl': 'OpenCL', + 'cuda': 'CUDA', + }[str(platform_name).lower()] + except KeyError: + pass + from openmm import Platform platform = Platform.getPlatformByName(platform_name) # Set precision and properties diff --git a/openfe/tests/protocols/conftest.py b/openfe/tests/protocols/conftest.py new file mode 100644 index 000000000..5fcd302c1 --- /dev/null +++ b/openfe/tests/protocols/conftest.py @@ -0,0 +1,29 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import importlib +import pytest +from importlib import resources +from rdkit import Chem + +import gufe +import openfe +from gufe import SmallMoleculeComponent + + +@pytest.fixture(scope='session') +def benzene_modifications(): + files = {} + with importlib.resources.path('openfe.tests.data', + 'benzene_modifications.sdf') as fn: + supp = Chem.SDMolSupplier(str(fn), removeHs=False) + for rdmol in supp: + files[rdmol.GetProp('_Name')] = SmallMoleculeComponent(rdmol) + return files + + +@pytest.fixture(scope='session') +def T4_protein_component(): + with resources.path('openfe.tests.data', '181l_only.pdb') as fn: + comp = gufe.ProteinComponent.from_pdb_file(str(fn), name="T4_protein") + + return comp diff --git a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py similarity index 62% rename from openfe/tests/setup/test_openmm_abfe_equil_protocols.py rename to openfe/tests/protocols/test_openmm_abfe_equil_protocols.py index b55a139af..5cb63c97d 100644 --- a/openfe/tests/setup/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py @@ -2,7 +2,6 @@ # For details, see https://github.com/OpenFreeEnergy/openfe import pytest from openmmtools.multistate.multistatesampler import MultiStateSampler -from openff.units import unit as offunit from openfe import ChemicalSystem, SolventComponent from openfe.protocols import openmm_afe @@ -52,153 +51,6 @@ def benzene_complex_system(benzene_modifications, T4_protein_component): ) -@pytest.fixture() -def default_settings(): - return openmm_afe.AbsoluteTransformProtocol.default_settings() - - -def test_create_default_settings(): - settings = openmm_afe.AbsoluteTransformProtocol.default_settings() - assert settings - - -@pytest.mark.parametrize('method, fail', [ - ['Pme', False], - ['noCutoff', False], - ['Ewald', True], - ['CutoffNonPeriodic', True], - ['CutoffPeriodic', True], -]) -def test_systemsettings_nonbonded(method, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="Only PME"): - default_settings.system_settings.nonbonded_method = method - else: - default_settings.system_settings.nonbonded_method = method - - -@pytest.mark.parametrize('val, match', [ - [-1.0 * offunit.nanometer, 'must be a positive'], - [2.5 * offunit.picoseconds, 'distance units'], -]) -def test_systemsettings_cutoff_errors(val, match, default_settings): - with pytest.raises(ValueError, match=match): - default_settings.system_settings.nonbonded_cutoff = val - - -@pytest.mark.parametrize('val, fail', [ - ['TiP3p', False], - ['SPCE', False], - ['tip4pEw', False], - ['Tip5p', False], - ['opc', True], - ['tips', True], - ['tip3p-fb', True], -]) -def test_solvent_model_setting(val, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="allowed solvent_model"): - default_settings.solvent_settings.solvent_model = val - else: - default_settings.solvent_settings.solvent_model = val - - -@pytest.mark.parametrize('val, match', [ - [-1.0 * offunit.nanometer, 'must be a positive'], - [2.5 * offunit.picoseconds, 'distance units'], -]) -def test_incorrect_padding(val, match, default_settings): - with pytest.raises(ValueError, match=match): - default_settings.solvent_settings.solvent_padding = val - - -@pytest.mark.parametrize('val', [ - {'elec': 0, 'vdw': 5}, - {'elec': -2, 'vdw': 5}, - {'elec': 5, 'vdw': -2}, - {'elec': 5, 'vdw': 0}, -]) -def test_incorrect_window_settings(val, default_settings): - errmsg = "lambda steps must be positive" - alchem_settings = default_settings.alchemical_settings - with pytest.raises(ValueError, match=errmsg): - alchem_settings.lambda_elec_windows = val['elec'] - alchem_settings.lambda_vdw_windows = val['vdw'] - - -@pytest.mark.parametrize('val, fail', [ - ['LOGZ-FLATNESS', False], - ['MiniMum-VisiTs', False], - ['histogram-flatness', False], - ['roundrobin', True], - ['parsnips', True] -]) -def test_supported_flatness_settings(val, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="following flatness"): - default_settings.alchemsampler_settings.flatness_criteria = val - else: - default_settings.alchemsampler_settings.flatness_criteria = val - - -@pytest.mark.parametrize('var, val', [ - ['online_analysis_target_error', - -0.05 * offunit.boltzmann_constant * offunit.kelvin], - ['n_repeats', -1], - ['n_repeats', 0], - ['online_analysis_minimum_iterations', -2], - ['gamma0', -2], - ['n_replicas', -2], - ['n_replicas', 0] -]) -def test_nonnegative_alchem_settings(var, val, default_settings): - alchem_settings = default_settings.alchemsampler_settings - with pytest.raises(ValueError, match="positive values"): - setattr(alchem_settings, var, val) - - -@pytest.mark.parametrize('val, fail', [ - ['REPEX', False], - ['SaMs', False], - ['independent', False], - ['noneq', True], - ['AWH', True] -]) -def test_supported_sampler(val, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="sampler_method values"): - default_settings.alchemsampler_settings.sampler_method = val - else: - default_settings.alchemsampler_settings.sampler_method = val - - -@pytest.mark.parametrize('var, val', [ - ['collision_rate', -1 / offunit.picosecond], - ['n_restart_attempts', -2], - ['timestep', 0 * offunit.femtosecond], - ['timestep', -2 * offunit.femtosecond], - ['n_steps', 0 * offunit.timestep], - ['n_steps', -1 * offunit.timestep], - ['constraint_tolerance', -2e-06], - ['constraint_tolerance', 0] -]) -def test_nonnegative_integrator_settings(var, val, default_settings): - int_settings = default_settings.integrator_settings - with pytest.raises(ValueError, match="positive values"): - setattr(int_settings, var, val) - - -def test_timestep_is_not_time(default_settings): - with pytest.raises(ValueError, match="time units"): - default_settings.integrator_settings.timestep = 1 * offunit.nanometer - - -def test_collision_is_not_inverse_time(default_settings): - with pytest.raises(ValueError, match="inverse time"): - int_settings = default_settings.integrator_settings - int_settings.collision_rate = 1 * offunit.picosecond - - def test_create_default_protocol(): # this is roughly how it should be created protocol = openmm_afe.AbsoluteTransformProtocol( diff --git a/openfe/tests/protocols/test_openmm_abfe_settings.py b/openfe/tests/protocols/test_openmm_abfe_settings.py new file mode 100644 index 000000000..8864b0701 --- /dev/null +++ b/openfe/tests/protocols/test_openmm_abfe_settings.py @@ -0,0 +1,176 @@ +import pytest +from openff.units import unit as offunit +from openfe.protocols import openmm_afe + + +@pytest.fixture() +def default_settings(): + return openmm_afe.AbsoluteTransformProtocol.default_settings() + + +def test_create_default_settings(): + settings = openmm_afe.AbsoluteTransformProtocol.default_settings() + assert settings + + +@pytest.mark.parametrize('method, fail', [ + ['Pme', False], + ['noCutoff', False], + ['Ewald', True], + ['CutoffNonPeriodic', True], + ['CutoffPeriodic', True], +]) +def test_systemsettings_nonbonded(method, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="Only PME"): + default_settings.system_settings.nonbonded_method = method + else: + default_settings.system_settings.nonbonded_method = method + + +@pytest.mark.parametrize('val, match', [ + [-1.0 * offunit.nanometer, 'must be a positive'], + [2.5 * offunit.picoseconds, 'distance units'], +]) +def test_systemsettings_cutoff_errors(val, match, default_settings): + with pytest.raises(ValueError, match=match): + default_settings.system_settings.nonbonded_cutoff = val + + +@pytest.mark.parametrize('val, fail', [ + ['TiP3p', False], + ['SPCE', False], + ['tip4pEw', False], + ['Tip5p', False], + ['opc', True], + ['tips', True], + ['tip3p-fb', True], +]) +def test_solvent_model_setting(val, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="allowed solvent_model"): + default_settings.solvent_settings.solvent_model = val + else: + default_settings.solvent_settings.solvent_model = val + + +@pytest.mark.parametrize('val, match', [ + [-1.0 * offunit.nanometer, 'must be a positive'], + [2.5 * offunit.picoseconds, 'distance units'], +]) +def test_incorrect_padding(val, match, default_settings): + with pytest.raises(ValueError, match=match): + default_settings.solvent_settings.solvent_padding = val + + +@pytest.mark.parametrize('val', [ + {'elec': 0, 'vdw': 5}, + {'elec': -2, 'vdw': 5}, + {'elec': 5, 'vdw': -2}, + {'elec': 5, 'vdw': 0}, +]) +def test_incorrect_window_settings(val, default_settings): + errmsg = "lambda steps must be positive" + alchem_settings = default_settings.alchemical_settings + with pytest.raises(ValueError, match=errmsg): + alchem_settings.lambda_elec_windows = val['elec'] + alchem_settings.lambda_vdw_windows = val['vdw'] + + +@pytest.mark.parametrize('val, fail', [ + ['LOGZ-FLATNESS', False], + ['MiniMum-VisiTs', False], + ['histogram-flatness', False], + ['roundrobin', True], + ['parsnips', True] +]) +def test_supported_flatness_settings(val, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="following flatness"): + default_settings.alchemsampler_settings.flatness_criteria = val + else: + default_settings.alchemsampler_settings.flatness_criteria = val + + +@pytest.mark.parametrize('var, val', [ + ['online_analysis_target_error', + -0.05 * offunit.boltzmann_constant * offunit.kelvin], + ['n_repeats', -1], + ['n_repeats', 0], + ['online_analysis_minimum_iterations', -2], + ['gamma0', -2], + ['n_replicas', -2], + ['n_replicas', 0] +]) +def test_nonnegative_alchem_settings(var, val, default_settings): + alchem_settings = default_settings.alchemsampler_settings + with pytest.raises(ValueError, match="positive values"): + setattr(alchem_settings, var, val) + + +@pytest.mark.parametrize('val, fail', [ + ['REPEX', False], + ['SaMs', False], + ['independent', False], + ['noneq', True], + ['AWH', True] +]) +def test_supported_sampler(val, fail, default_settings): + if fail: + with pytest.raises(ValueError, match="sampler_method values"): + default_settings.alchemsampler_settings.sampler_method = val + else: + default_settings.alchemsampler_settings.sampler_method = val + + +@pytest.mark.parametrize('var, val', [ + ['collision_rate', -1 / offunit.picosecond], + ['n_restart_attempts', -2], + ['timestep', 0 * offunit.femtosecond], + ['timestep', -2 * offunit.femtosecond], + ['n_steps', 0 * offunit.timestep], + ['n_steps', -1 * offunit.timestep], + ['constraint_tolerance', -2e-06], + ['constraint_tolerance', 0] +]) +def test_nonnegative_integrator_settings(var, val, default_settings): + int_settings = default_settings.integrator_settings + with pytest.raises(ValueError, match="positive values"): + setattr(int_settings, var, val) + + +def test_timestep_is_not_time(default_settings): + with pytest.raises(ValueError, match="time units"): + default_settings.integrator_settings.timestep = 1 * offunit.nanometer + + +def test_collision_is_not_inverse_time(default_settings): + with pytest.raises(ValueError, match="inverse time"): + int_settings = default_settings.integrator_settings + int_settings.collision_rate = 1 * offunit.picosecond + + +@pytest.mark.parametrize( + 'var', ['equilibration_length', 'production_length'] +) +def test_sim_lengths_not_time(var, default_settings): + settings = default_settings.simulation_settings + with pytest.raises(ValueError, match="must be in time units"): + setattr(settings, var, 1 * offunit.nanometer) + + +@pytest.mark.parametrize('var, val', [ + ['minimization_steps', -1], + ['minimization_steps', 0], + ['equilibration_length', -1 * offunit.picosecond], + ['equilibration_length', 0 * offunit.picosecond], + ['production_length', -1 * offunit.picosecond], + ['production_length', 0 * offunit.picosecond], + ['checkpoint_interval', -1 * offunit.timestep], + ['checkpoint_interval', 0 * offunit.timestep], +]) +def test_nonnegative_sim_settings(var, val, default_settings): + settings = default_settings.simulation_settings + with pytest.raises(ValueError, match="must be positive"): + setattr(settings, var, val) + From 8a502e7bcb7f590af5891b5a452ebf714a6f7cc3 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 2 Apr 2023 01:36:07 +0100 Subject: [PATCH 14/74] Add no cover for extends branch --- openfe/protocols/openmm_afe/equil_afe_methods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index 79b4f176d..caa3f010e 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -299,7 +299,7 @@ def _create( extends: Optional[gufe.ProtocolDAGResult] = None, ) -> list[gufe.ProtocolUnit]: # TODO: extensions - if extends: + if extends: # pragma: no-cover raise NotImplementedError("Can't extend simulations yet") # Checks on the inputs! From 9b06724d4eacb3d05d43acd8707ebf4eb1a4086d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 2 Apr 2023 02:30:37 +0100 Subject: [PATCH 15/74] add gather and tagging from rbfe tests --- .../test_openmm_abfe_equil_protocols.py | 54 +++++++++++++++++++ .../protocols/test_openmm_abfe_settings.py | 2 + 2 files changed, 56 insertions(+) diff --git a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py index 5cb63c97d..7ba8b268e 100644 --- a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py @@ -1,7 +1,9 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe import pytest +from unittest import mock from openmmtools.multistate.multistatesampler import MultiStateSampler +import gufe from openfe import ChemicalSystem, SolventComponent from openfe.protocols import openmm_afe @@ -51,6 +53,14 @@ def benzene_complex_system(benzene_modifications, T4_protein_component): ) +@pytest.fixture +def vacuum_protocol_dag(benzene_vacuum_system, vacuum_system): + settings = openmm_afe.AbsoluteTransformProtocol.default_settings() + protocol = openmm_afe.AbsoluteTransformProtocol(settings=settings) + return protocol.create(stateA=benzene_vacuum_system, stateB=vacuum_system, + mapping=None) + + def test_create_default_protocol(): # this is roughly how it should be created protocol = openmm_afe.AbsoluteTransformProtocol( @@ -144,6 +154,50 @@ def test_dry_run_complex(benzene_complex_system, protein_system, assert sampler.is_periodic +def test_unit_tagging(vacuum_protocol_dag, tmpdir): + units = vacuum_protocol_dag.protocol_units + with mock.patch('openfe.protocols.openmm_afe.equil_afe_methods.AbsoluteTransformUnit.run', + return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}): + results = [] + for u in units: + ret = u.execute(shared=tmpdir) + results.append(ret) + + repeats = set() + for ret in results: + assert isinstance(ret, gufe.ProtocolUnitResult) + assert ret.outputs['generation'] == 0 + repeats.add(ret.outputs['repeat_id']) + assert repeats == {0, 1, 2} + + +def test_gather(vacuum_protocol_dag, tmpdir): + base_import = 'openfe.protocols.openmm_afe.equil_afe_methods.' + with mock.patch(f"{base_import}AbsoluteTransformUnit.run", + return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}): + dagres = gufe.protocols.execute_DAG(vacuum_protocol_dag, shared=tmpdir) + + prot = openmm_afe.AbsoluteTransformProtocol( + settings=openmm_afe.AbsoluteTransformProtocol.default_settings(), + ) + + with mock.patch(f"{base_import}multistate") as m: + res = prot.gather([dagres]) + + # check we created the expected number of Reporters and Analyzers + assert m.MultiStateReporter.call_count == 3 + m.MultiStateReporter.assert_called_with( + storage='file.nc', checkpoint_storage='chck.nc', + ) + assert m.MultiStateSamplerAnalyzer.call_count == 3 + + assert isinstance(res, openmm_afe.AbsoluteTransformProtocolResult) + + +# TODO: +# - test lambdas +# - + #def test_n_replicas_not_n_windows(benzene_vacuum_system, tmpdir): # # For PR #125 we pin such that the number of lambda windows # # equals the numbers of replicas used - TODO: remove limitation diff --git a/openfe/tests/protocols/test_openmm_abfe_settings.py b/openfe/tests/protocols/test_openmm_abfe_settings.py index 8864b0701..8b8f44c2d 100644 --- a/openfe/tests/protocols/test_openmm_abfe_settings.py +++ b/openfe/tests/protocols/test_openmm_abfe_settings.py @@ -1,3 +1,5 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe import pytest from openff.units import unit as offunit from openfe.protocols import openmm_afe From a965cdee15bb4ab148c9583876d3458297f0c163 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 01:40:35 +0100 Subject: [PATCH 16/74] More tests + some docs --- openfe/protocols/openmm_afe/afe_settings.py | 2 +- .../protocols/openmm_afe/equil_afe_methods.py | 44 +++-- .../test_openmm_abfe_equil_protocols.py | 165 ++++++++++++++++++ 3 files changed, 199 insertions(+), 12 deletions(-) diff --git a/openfe/protocols/openmm_afe/afe_settings.py b/openfe/protocols/openmm_afe/afe_settings.py index 707a71364..54d9c320e 100644 --- a/openfe/protocols/openmm_afe/afe_settings.py +++ b/openfe/protocols/openmm_afe/afe_settings.py @@ -163,7 +163,7 @@ class Config: ``online_analysis_target_error``. If set, will write a yaml file with real time analysis data. Default `None`. """ - online_analysis_target_error = 0.005 * unit.boltzmann_constant * unit.kelvin + online_analysis_target_error = 0.05 * unit.boltzmann_constant * unit.kelvin """ Target error for the online analysis measured in kT. Once the free energy is at or below this value, the simulation will be considered complete. diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index caa3f010e..cfeaf8478 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -4,10 +4,27 @@ This module implements the necessary methodology toolking to run calculate an absolute free energy transformation using OpenMM tools and one of the -following methods: - - Hamiltonian Replica Exchange - - Self-adjusted mixture sampling - - Independent window sampling +following alchemical sampling methods: + * Hamiltonian Replica Exchange + * Self-adjusted mixture sampling + * Independent window sampling + + + + + +Current limitations +------------------- +* Disapearing molecules are only allowed in state A. Support for + appearing molecules will be added in due course. +* Only one protein component is allowed per state. We ask that, + users input all molecules intended to use an additive force field + in the one ProteinComponent. This will likely change once OpenFF + rosemary is released. +* Only small molecules are allowed to act as alchemical molecules. + Alchemically changing protein or solvent components would induce + perturbations which are too large to be handled by this Protocol. + Acknowledgements ---------------- @@ -16,7 +33,10 @@ TODO ---- -* Add support for restraints +* Add in all the AlchemicalFactory and AlchemicalRegion kwargs + as settings. +* Allow for a more flexible setting of Lambda regions. +* Add support for restraints. * Improve this docstring by adding an example use case. """ @@ -131,7 +151,7 @@ def get_uncertainty(self): return std_val * dGs[0].unit - def get_rate_of_convergence(self): + def get_rate_of_convergence(self): # pragma: no-cover raise NotImplementedError @@ -282,7 +302,7 @@ def _validate_solvent(state: ChemicalSystem, nonbonded_method: str): for component in state.components.values(): if isinstance(component, SolventComponent): if nonbonded_method.lower() == "nocutoff": - errmsg = (f"{nonbonded_method} cannot be used for vacuum " + errmsg = (f"{nonbonded_method} cannot be used for solvent " "transformation") raise ValueError(errmsg) solvents += 1 @@ -502,7 +522,7 @@ def _get_sim_steps(time: unit.Quantity, timestep: unit.Quantity, f"timesteps between MC moves {mc_steps}") ValueError(errmsg) - return steps + return steps.m ModellerReturn = Tuple[app.Modeller, Dict[str, npt.NDArray]] @@ -778,11 +798,13 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: 'nonbondedCutoff': nonbonded_cutoff, } + # Currently the else is a dead branch, we will want to investigate the + # possibility of using CutoffNonPeriodic at some point though (for RF) if nonbonded_method is not app.CutoffNonPeriodic: nonperiodic_kwargs = { 'nonbondedMethod': app.NoCutoff, } - else: + else: # pragma: no-cover nonperiodic_kwargs = periodic_kwargs system_generator = SystemGenerator( @@ -991,13 +1013,13 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: if verbose: logger.info("equilibrating systems") - sampler.equilibrate(int(equil_steps.m / mc_steps)) # type: ignore + sampler.equilibrate(int(equil_steps / mc_steps)) # type: ignore # production if verbose: logger.info("running production phase") - sampler.extend(int(prod_steps.m / mc_steps)) # type: ignore + sampler.extend(int(prod_steps / mc_steps)) # type: ignore # close reporter when you're done reporter.close() diff --git a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py index 7ba8b268e..c0c3e9fc7 100644 --- a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py @@ -3,6 +3,7 @@ import pytest from unittest import mock from openmmtools.multistate.multistatesampler import MultiStateSampler +from openff.units import unit as offunit import gufe from openfe import ChemicalSystem, SolventComponent from openfe.protocols import openmm_afe @@ -79,6 +80,141 @@ def test_serialize_protocol(): assert protocol == ret +def test_get_alchemical_components(benzene_modifications, + T4_protein_component): + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'toluene': benzene_modifications['toluene'], + 'protein': T4_protein_component, + 'solvent': SolventComponent(neutralize=False) + }) + + stateB = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'phenol': benzene_modifications['phenol'], + 'solvent': SolventComponent(), + }) + + func = openmm_afe.AbsoluteTransformProtocol._get_alchemical_components + + comps = func(stateA, stateB) + + assert len(comps['stateA']) == 3 + assert len(comps['stateB']) == 2 + + for i in ['toluene', 'protein', 'solvent']: + assert i in comps['stateA'] + + for i in ['phenol', 'solvent']: + assert i in comps['stateB'] + + +def test_validate_alchem_comps_stateB(): + + func = openmm_afe.AbsoluteTransformProtocol._validate_alchemical_components + + stateA = ChemicalSystem({}) + alchem_comps = {'stateA': [], 'stateB': ['foo', 'bar']} + with pytest.raises(ValueError, match="Components appearing in state B"): + func(stateA, alchem_comps) + + +@pytest.mark.parametrize('key', ['protein', 'solvent']) +def test_validate_alchem_comps_non_small(key, T4_protein_component): + + func = openmm_afe.AbsoluteTransformProtocol._validate_alchemical_components + + stateA = ChemicalSystem({ + 'protein': T4_protein_component, + 'solvent': SolventComponent(), + }) + + alchem_comps = {'stateA': [key,], 'stateB': []} + with pytest.raises(ValueError, match='Non SmallMoleculeComponent'): + func(stateA, alchem_comps) + + +def test_validate_solvent_vacuum(): + + state = ChemicalSystem({'solvent': SolventComponent()}) + + func = openmm_afe.AbsoluteTransformProtocol._validate_solvent + + with pytest.raises(ValueError, match="cannot be used for solvent"): + func(state, 'NoCutoff') + + +def test_validate_solvent_double_solvent(): + + state = ChemicalSystem({ + 'solvent': SolventComponent(), + 'solvent-two': SolventComponent(neutralize=False) + }) + + func = openmm_afe.AbsoluteTransformProtocol._validate_solvent + + with pytest.raises(ValueError, match="only one is supported"): + func(state, 'pme') + + +def test_parse_components_expected(T4_protein_component, + benzene_modifications): + + func = openmm_afe.AbsoluteTransformUnit._parse_components + + chem = ChemicalSystem({ + 'protein': T4_protein_component, + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent(), + 'toluene': benzene_modifications['toluene'], + 'phenol': benzene_modifications['phenol'], + }) + + solvent_comp, protein_comp, off_small_mols = func(chem) + + assert len(off_small_mols.keys()) == 3 + assert protein_comp == T4_protein_component + assert solvent_comp == SolventComponent() + + for i in ['benzene', 'toluene', 'phenol']: + off_small_mols[i] == benzene_modifications[i].to_openff() + + +def test_parse_components_multi_protein(T4_protein_component): + + func = openmm_afe.AbsoluteTransformUnit._parse_components + + chem = ChemicalSystem({ + 'protein': T4_protein_component, + 'protien2': T4_protein_component, # should this even be allowed? + }) + + with pytest.raises(ValueError, match="Multiple proteins"): + func(chem) + + +def test_simstep_return(): + + func = openmm_afe.AbsoluteTransformUnit._get_sim_steps + + steps = func(time=250000 * offunit.femtoseconds, + timestep=4 * offunit.femtoseconds, + mc_steps=250) + + # check the number of steps for a 250 ps simulations + assert steps == 62500 + + +def test_simstep_undivisible_mcsteps(): + + func = openmm_afe.AbsoluteTransformProtocol._get_sim_steps + + with pytest.raises(ValueError, match="divisible by the number"): + func(time=780 * offunit.femtoseconds, + timestep=4 * offunit.femtoseconds, + mc_steps=100) + + @pytest.mark.parametrize('method', [ 'repex', 'sams', 'independent', 'InDePeNdENT' ]) @@ -154,6 +290,35 @@ def test_dry_run_complex(benzene_complex_system, protein_system, assert sampler.is_periodic +def test_nreplicas_lambda_mismatch(benzene_vacuum_system, + vacuum_system, tmpdir): + """ + For now, should trigger failure if there are not as many replicas + as there are summed lambda windows. + """ + settings = openmm_afe.AbsoluteTransformProtocol.default_settings() + settings.alchemsampler_settings.n_replicas = 12 + settings.alchemical_settings.lambda_elec_windows = 12 + settings.alchemical_settings.lambda_vdw_windows = 12 + + protocol = openmm_afe.AbsoluteTransformProtocol( + settings=settings, + ) + + dag = protocol.create( + stateA=benzene_vacuum_system, + stateB=vacuum_system, + mapping=None, + ) + unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + errmsg = ("Number of replicas 12 does not equal the " + "number of lambda windows") + with pytest.raises(ValueError, match=errmsg): + unit.run(dry=True) + + def test_unit_tagging(vacuum_protocol_dag, tmpdir): units = vacuum_protocol_dag.protocol_units with mock.patch('openfe.protocols.openmm_afe.equil_afe_methods.AbsoluteTransformUnit.run', From 9e9255bdacb83aef1ae46906f91434668f164974 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 02:00:49 +0100 Subject: [PATCH 17/74] fix mc steps check --- .../protocols/openmm_afe/equil_afe_methods.py | 30 +++++++++---------- .../test_openmm_abfe_equil_protocols.py | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index cfeaf8478..79e7d41d1 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -12,7 +12,7 @@ - + Current limitations ------------------- * Disapearing molecules are only allowed in state A. Support for @@ -514,15 +514,15 @@ def _get_sim_steps(time: unit.Quantity, timestep: unit.Quantity, ValueError If the number of steps is not divisible by the number of mc_steps. """ - steps = round(time / timestep) + steps = round(time / timestep).m - if (steps.m % mc_steps) != 0: # type: ignore + if (steps % mc_steps) != 0: # type: ignore errmsg = (f"Simulation time {time} should contain a number of " "steps divisible by the number of integrator " f"timesteps between MC moves {mc_steps}") - ValueError(errmsg) + raise ValueError(errmsg) - return steps.m + return steps ModellerReturn = Tuple[app.Modeller, Dict[str, npt.NDArray]] @@ -813,7 +813,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: forcefield_kwargs=forcefield_kwargs, nonperiodic_forcefield_kwargs=nonperiodic_kwargs, periodic_forcefield_kwargs=periodic_kwargs, - cache=settings.simulation_settings.forcefield_cache, + cache=sim_settings.forcefield_cache, ) # Add a barostat if necessary note, was broken pre 0.11.2 of openmmff @@ -922,15 +922,15 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # a. Get the sub selection of the system to print coords for mdt_top = mdt.Topology.from_openmm(system_topology) selection_indices = mdt_top.select( - settings.simulation_settings.output_indices + sim_settings.output_indices ) # b. Create the multistate reporter reporter = multistate.MultiStateReporter( - storage=basepath / settings.simulation_settings.output_filename, + storage=basepath / sim_settings.output_filename, analysis_particle_indices=selection_indices, - checkpoint_interval=settings.simulation_settings.checkpoint_interval.m, - checkpoint_storage=basepath / settings.simulation_settings.checkpoint_storage, + checkpoint_interval=sim_settings.checkpoint_interval.m, + checkpoint_storage=basepath / sim_settings.checkpoint_storage, ) # 7. Get platform and context caches @@ -1006,7 +1006,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: logger.info("minimizing systems") sampler.minimize( - max_iterations=settings.simulation_settings.minimization_steps + max_iterations=sim_settings.minimization_steps ) # equilibrate @@ -1024,8 +1024,8 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # close reporter when you're done reporter.close() - nc = basepath / settings.simulation_settings.output_filename - chk = basepath / settings.simulation_settings.checkpoint_storage + nc = basepath / sim_settings.output_filename + chk = basepath / sim_settings.checkpoint_storage return { 'nc': nc, 'last_checkpoint': chk, @@ -1035,8 +1035,8 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: reporter.close() # clean up the reporter file - fns = [basepath / settings.simulation_settings.output_filename, - basepath / settings.simulation_settings.checkpoint_storage] + fns = [basepath / sim_settings.output_filename, + basepath / sim_settings.checkpoint_storage] for fn in fns: os.remove(fn) return {'debug': {'sampler': sampler}} diff --git a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py index c0c3e9fc7..22f303cb1 100644 --- a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py @@ -207,7 +207,7 @@ def test_simstep_return(): def test_simstep_undivisible_mcsteps(): - func = openmm_afe.AbsoluteTransformProtocol._get_sim_steps + func = openmm_afe.AbsoluteTransformUnit._get_sim_steps with pytest.raises(ValueError, match="divisible by the number"): func(time=780 * offunit.femtoseconds, From f8740648ec4186b4d87628a162039e14ea6d956e Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 03:45:18 +0100 Subject: [PATCH 18/74] clean up tests --- .../test_openmm_abfe_equil_protocols.py | 98 ------------------- 1 file changed, 98 deletions(-) diff --git a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py index 22f303cb1..16af5aa44 100644 --- a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py @@ -358,101 +358,3 @@ def test_gather(vacuum_protocol_dag, tmpdir): assert isinstance(res, openmm_afe.AbsoluteTransformProtocolResult) - -# TODO: -# - test lambdas -# - - -#def test_n_replicas_not_n_windows(benzene_vacuum_system, tmpdir): -# # For PR #125 we pin such that the number of lambda windows -# # equals the numbers of replicas used - TODO: remove limitation -# settings = openmm_afe.AbsoluteTransform.default_settings() -# # default lambda windows is 24 -# settings.sampler_settings.n_replicas = 13 -# settings.system_settings.nonbonded_method = 'nocutoff' -# -# errmsg = "Number of replicas 13 does not equal" -# -# with tmpdir.as_cwd(): -# with pytest.raises(ValueError, match=errmsg): -# p = openmm_afe.AbsoluteTransform( -# settings=settings, -# ) -# dag = p.create( -# chem_system=benzene_vacuum_system, -# ) -# unit = list(dag.protocol_units)[0] -# unit.run(dry=True) -# -# -#def test_vaccuum_PME_error(benzene_system, benzene_modifications, -# benzene_to_toluene_mapping): -# # state B doesn't have a solvent component (i.e. its vacuum) -# stateB = setup.ChemicalSystem({'ligand': benzene_modifications['toluene']}) -# -# p = openmm_rbfe.RelativeLigandTransform( -# settings=openmm_rbfe.RelativeLigandTransform.default_settings(), -# ) -# errmsg = "PME cannot be used for vacuum transform" -# with pytest.raises(ValueError, match=errmsg): -# _ = p.create( -# stateA=benzene_system, -# stateB=stateB, -# mapping={'ligand': benzene_to_toluene_mapping}, -# ) -# -# -#@pytest.fixture -#def solvent_protocol_dag(benzene_system, toluene_system, benzene_to_toluene_mapping): -# settings = openmm_rbfe.RelativeLigandTransform.default_settings() -# -# protocol = openmm_rbfe.RelativeLigandTransform( -# settings=settings, -# ) -# -# return protocol.create( -# stateA=benzene_system, stateB=toluene_system, -# mapping={'ligand': benzene_to_toluene_mapping}, -# ) -# -# -#def test_unit_tagging(solvent_protocol_dag, tmpdir): -# # test that executing the Units includes correct generation and repeat info -# units = solvent_protocol_dag.protocol_units -# with mock.patch('openfe.protocols.openmm_rbfe.equil_rbfe_methods.RelativeLigandTransformUnit.run', -# return_value={'nc': 'file.nc', 'last_checkpoint': 'chk.nc'}): -# results = [] -# for u in units: -# ret = u.execute(shared=tmpdir) -# results.append(ret) -# -# repeats = set() -# for ret in results: -# assert isinstance(ret, gufe.ProtocolUnitResult) -# assert ret.outputs['generation'] == 0 -# repeats.add(ret.outputs['repeat_id']) -# assert repeats == {0, 1, 2} -# -# -#def test_gather(solvent_protocol_dag, tmpdir): -# # check .gather behaves as expected -# with mock.patch('openfe.protocols.openmm_rbfe.equil_rbfe_methods.RelativeLigandTransformUnit.run', -# return_value={'nc': 'file.nc', 'last_checkpoint': 'chk.nc'}): -# dagres = gufe.protocols.execute_DAG(solvent_protocol_dag, -# shared=tmpdir) -# -# prot = openmm_rbfe.RelativeLigandTransform( -# settings=openmm_rbfe.RelativeLigandTransform.default_settings() -# ) -# -# with mock.patch('openfe.protocols.openmm_rbfe.equil_rbfe_methods.multistate') as m: -# res = prot.gather([dagres]) -# -# # check we created the expected number of Reporters and Analyzers -# assert m.MultiStateReporter.call_count == 3 -# m.MultiStateReporter.assert_called_with( -# storage='file.nc', checkpoint_storage='chk.nc', -# ) -# assert m.MultiStateSamplerAnalyzer.call_count == 3 -# -# assert isinstance(res, openmm_rbfe.RelativeLigandTransformResult) From 97e38ca7195b09295ff2c40acd8f6bac1e7386c7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 03:55:27 +0100 Subject: [PATCH 19/74] Add docs --- docs/protocols/index.rst | 6 ++++++ docs/protocols/openmm_afe.rst | 1 + openfe/protocols/openmm_afe/equil_afe_methods.py | 5 +++-- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 docs/protocols/index.rst create mode 100644 docs/protocols/openmm_afe.rst diff --git a/docs/protocols/index.rst b/docs/protocols/index.rst new file mode 100644 index 000000000..3b5926100 --- /dev/null +++ b/docs/protocols/index.rst @@ -0,0 +1,6 @@ +Alchemical free energy protocols in ``openfe`` +============================================== + +.. toctree:: + + openmm_afe diff --git a/docs/protocols/openmm_afe.rst b/docs/protocols/openmm_afe.rst new file mode 100644 index 000000000..72d14e64d --- /dev/null +++ b/docs/protocols/openmm_afe.rst @@ -0,0 +1 @@ +.. automodule:: openfe.protocols.openmm_afe.equil_afe_methods diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index 79e7d41d1..3741c9ab9 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -1,6 +1,7 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -"""Equilibrium AFE Protocol using OpenMM + OpenMMTools +r"""OpenMM Equilibrium AFE Protocol +=================================== This module implements the necessary methodology toolking to run calculate an absolute free energy transformation using OpenMM tools and one of the @@ -10,7 +11,7 @@ * Independent window sampling - +.. versionadded:: 0.7.0 Current limitations From 90db3955b1c923ee9cf67c14cbfb6a71b38edf9d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 04:20:25 +0100 Subject: [PATCH 20/74] add some titles, see what sticks --- docs/protocols/openmm_afe.rst | 3 +++ openfe/protocols/openmm_afe/equil_afe_methods.py | 4 ++-- openfe/tests/protocols/test_openmm_abfe_equil_protocols.py | 1 - 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/protocols/openmm_afe.rst b/docs/protocols/openmm_afe.rst index 72d14e64d..05f1a9995 100644 --- a/docs/protocols/openmm_afe.rst +++ b/docs/protocols/openmm_afe.rst @@ -1 +1,4 @@ +OpenMM AFE Protocol +=================== + .. automodule:: openfe.protocols.openmm_afe.equil_afe_methods diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index 3741c9ab9..ab47d5b02 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -1,7 +1,7 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -r"""OpenMM Equilibrium AFE Protocol -=================================== +"""OpenMM Equilibrium AFE Protocol --- :mod:`openfe.protocols.openmm_afe.equil_afe_methods` +=========================================================================================== This module implements the necessary methodology toolking to run calculate an absolute free energy transformation using OpenMM tools and one of the diff --git a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py index 16af5aa44..254d9788c 100644 --- a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py +++ b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py @@ -357,4 +357,3 @@ def test_gather(vacuum_protocol_dag, tmpdir): assert m.MultiStateSamplerAnalyzer.call_count == 3 assert isinstance(res, openmm_afe.AbsoluteTransformProtocolResult) - From 2f1f75fa5b8a74d68b0a0852a6f21756c45f35de Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 04:35:42 +0100 Subject: [PATCH 21/74] Add protocols to toctree --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index 156e92294..6d54d13f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,6 +13,7 @@ Welcome to OpenFE's documentation! installation setup dumping_transformation + protocols/index api cli/index From 0066b121fbe33d58b0e3623c6a48266ff5298205 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 05:34:59 +0100 Subject: [PATCH 22/74] add autodoc_pydantic deps and complete solvation free energy docs --- docs/conf.py | 3 +- environment.yml | 1 + .../protocols/openmm_afe/equil_afe_methods.py | 114 ++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 971e08013..25feddf27 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,8 @@ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'myst_parser', - 'sphinx_click.ext' + 'sphinx_click.ext', + 'sphinxcontrib.autodoc_pydantic', ] # Add any paths that contain templates here, relative to this directory. diff --git a/environment.yml b/environment.yml index 40f904a33..ea56da32e 100644 --- a/environment.yml +++ b/environment.yml @@ -30,6 +30,7 @@ dependencies: - sphinx-click # - gufe==0.5 - pip: + - autodoc_pydantic - git+https://github.com/OpenFreeEnergy/gufe@main - git+https://github.com/mikemhenry/openff-models.git@support_nested_models diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index ab47d5b02..5c921ac5c 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -14,6 +14,120 @@ .. versionadded:: 0.7.0 +Running a Solvation Free Energy Calculation +------------------------------------------- + +One use case of this Protocol is to carry out absolute solvation free energy +calculations. This involves capturing the free energy cost associated with +taking a small molecule from a solvent environment to gas phase. + +In practice, because OpenMM currently only allows for charge annhilation when +using an exact treatment of charges using PME, this ends up requiring two +transformations. The first is carried out in solvent, where we annhilate the +charges of the ligand, and then decouple the LJ interactions. The second is +done in gas phase and involves recharging the ligand. + +Here we provide a short overview on how such a thermodynamic cycle would be +achieved using this protocol. + +Assuming we have a ligand of interest contained within an SDF file named +`ligands.sdf`, we can start by loading it into a SmallMoleculeComponent. + + +.. code-block:: + + from gufe import SmallMoleculeComponent + + mol = SmallMoleculeComponent.from_sdf_file('ligand.sdf') + + +With this, we can next create ChemicalSystem objects for the four +different end states of our thermodynamic cycle. + + +.. code-block:: + + from gufe import ChemicalSystem + + # State with a ligand in solvent + ligand_solvent = ChemicalSystem({ + 'ligand': mol, 'solvent': SolventComponent() + }) + + # State with only solvent + solvent = ChemicalSystem({'solvent': SolventComponent()}) + + # State with only the ligand in gas phase + ligand_gas = ChemicalSystem({'ligand': mol}) + + # State that is purely gas phase + gas = ChemicalSystem({'ligand': mol}) + + +Next we generate settings to run both solvent and gas phase transformations. +Aside form unique file names for the trajectory & checkpoint files, the main +difference in the settings is that we have to set the nonbonded method to be +`nocutoff` for gas phase and `pme` (the default) for periodic solvated systems. +Note: for everything else we use the default settings, howeve rin practice you +may find that much shorter simulation times may be adequate for gas phase +simulations. + + +.. code-block:: + + solvent_settings = AbsoluteTransformProtocol._default_settings() + solvent_settings.simulation_settings.output_filename = "ligand_solvent.nc" + solvent_settings.simulation_settings.checkpoint_storage = "ligand_solvent_checkpoint.nc" + + gas_setttings = AbsoluteTransformProtocol._default_settings() + gas_settings.simulation_settings.output_filename = "ligand_gas.nc" + gas_settings.simulation_settings.checkpoint_storage = "ligand_gas_checkpoint.nc" + + # By default the nonbonded method is PME, this needs to be nocutoff for gas phase + gas_settings.system_settings.nonbonded_method = 'nocutoff' + + +With this, we can create protocols and simulation DAGs for each leg of the +cycle. We pass to create the corresponding chemical end states of each leg +(e.g. ligand in solvent and pure solvent for the solvent transformation leg) +We note that no mapping is passed through to the Protocol. The Protocol +automatically compares the components present in the ChemicalSystems passed to +stateA and stateB and identifies any components missing either either of the +end states as undergoing an alchemical transformation. + + +.. code-block:: + + solvent_transform = AbsoluteTransformProtocol(settings=solvent_settings) + solvent_dag = solvent_transform.create(stateA=ligand_solvent, stateB=solvent, mapping=None) + gas_transform = AbsoluteTransformProtocol(settings=gas_settings) + gas_dag = solvent_transform.create(stateA=ligand_gas, stateB=gas, mapping=None) + + +Next we execute the transformations. By default, this will simulate 3 repeats +of both the ligand in solvent and ligand in gas transformations. Note: this +will take a while to run. + + +.. code-block:: + + from gufe.protocols import execute_DAG + solvent_data = execute_DAG(solvent_dag, shared='./solvent') + gas_data = execute_DAG(gas_dag, shared='./gas') + + +Once completed, we gather the results and then get our estimate as the +difference between the gas and solvent transformations. + + +.. code-block:: + + solvent_results = solvent_transform.gather([solvent_data,]) + gas_results = gas_transform.gather([gas_data,]) + dG = gas_results.get_estimate() - solvent_results.get_estimate() + print(dG) + + Current limitations ------------------- * Disapearing molecules are only allowed in state A. Support for From bed6d65d41f7fcc2b0260b3041c973142f185373 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 05:52:05 +0100 Subject: [PATCH 23/74] update environment --- environment.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index ea56da32e..0a0043bb7 100644 --- a/environment.yml +++ b/environment.yml @@ -14,7 +14,8 @@ dependencies: - pydantic - coverage - openff-toolkit>=0.12.0 - - openff-units + - openff-units>=0.2.0 + - openff-models>=0.0.4 - click - openeye-toolkits - typing-extensions From 4d4e65f1f3b1e06106dafbaf4ed682f71ea15e4f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 06:05:08 +0100 Subject: [PATCH 24/74] clashing installs? --- environment.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/environment.yml b/environment.yml index 0a0043bb7..24a1e4f1f 100644 --- a/environment.yml +++ b/environment.yml @@ -33,5 +33,3 @@ dependencies: - pip: - autodoc_pydantic - git+https://github.com/OpenFreeEnergy/gufe@main - - git+https://github.com/mikemhenry/openff-models.git@support_nested_models - From 17708f736f8a126cf9b86a3ea9e46143ec566556 Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Mon, 3 Apr 2023 15:41:43 +0100 Subject: [PATCH 25/74] Update conf.py --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 25feddf27..fa9adb21f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,7 +45,7 @@ autodoc_mock_imports = ['lomap', 'networkx', 'openff', 'openff.toolkit', 'openeye', 'rdkit', 'pytest', 'typing_extensions', - 'click', 'plugcli'] + 'click', 'plugcli', 'gufe'] # -- Options for HTML output ------------------------------------------------- From c07c3134f136b2d268b8c8a30d56d9c7757f98cd Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 3 Apr 2023 15:59:43 +0100 Subject: [PATCH 26/74] Add todo extension --- docs/conf.py | 1 + openfe/protocols/openmm_afe/equil_afe_methods.py | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index fa9adb21f..dc36110ae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,6 +33,7 @@ 'myst_parser', 'sphinx_click.ext', 'sphinxcontrib.autodoc_pydantic', + 'sphinx.ext.todo', ] # Add any paths that contain templates here, relative to this directory. diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index 5c921ac5c..f75efc7f2 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -6,9 +6,10 @@ This module implements the necessary methodology toolking to run calculate an absolute free energy transformation using OpenMM tools and one of the following alchemical sampling methods: - * Hamiltonian Replica Exchange - * Self-adjusted mixture sampling - * Independent window sampling + +* Hamiltonian Replica Exchange +* Self-adjusted mixture sampling +* Independent window sampling .. versionadded:: 0.7.0 @@ -143,9 +144,10 @@ Acknowledgements ---------------- -* Originally based on a script from hydration.py in +* Originally based on the hydration.py in `espaloma `_ + TODO ---- * Add in all the AlchemicalFactory and AlchemicalRegion kwargs From 50099a6410bc42f04a31fa34385d34d860e03d0e Mon Sep 17 00:00:00 2001 From: Irfan Alibay Date: Mon, 3 Apr 2023 21:27:48 +0100 Subject: [PATCH 27/74] Update conf.py --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index dc36110ae..0acdda9c7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,7 @@ autodoc_mock_imports = ['lomap', 'networkx', 'openff', 'openff.toolkit', 'openeye', 'rdkit', 'pytest', 'typing_extensions', - 'click', 'plugcli', 'gufe'] + 'click', 'plugcli',] # -- Options for HTML output ------------------------------------------------- From 54d7cf05aa4ba04feb6020fe376aeef980e6cd5d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 4 Apr 2023 11:08:09 +0100 Subject: [PATCH 28/74] don't cover non-dry run --- openfe/protocols/openmm_afe/equil_afe_methods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/equil_afe_methods.py index f75efc7f2..be894cdf4 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/equil_afe_methods.py @@ -1117,7 +1117,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: sampler.energy_context_cache = energy_context_cache sampler.sampler_context_cache = sampler_context_cache - if not dry: + if not dry: # pragma: no-cover # minimize if verbose: logger.info("minimizing systems") From bf1e700056f4248add68c971516645999bc10a6f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 27 Jun 2023 12:40:11 +0100 Subject: [PATCH 29/74] Centralize settings --- .../openmm_rfe/equil_rfe_settings.py | 449 +---------------- openfe/protocols/openmm_utils/omm_settings.py | 451 ++++++++++++++++++ 2 files changed, 459 insertions(+), 441 deletions(-) create mode 100644 openfe/protocols/openmm_utils/omm_settings.py diff --git a/openfe/protocols/openmm_rfe/equil_rfe_settings.py b/openfe/protocols/openmm_rfe/equil_rfe_settings.py index b3d53c808..b84247a88 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_settings.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_settings.py @@ -2,7 +2,7 @@ # For details, see https://github.com/OpenFreeEnergy/openfe """Equilibrium Relative Free Energy Protocol input settings. -This module implements the necessary settings necessary to run absolute free +This module implements the necessary settings necessary to run relative free energies using :class:`openfe.protocols.openmm_rfe.equil_rfe_methods.py` """ @@ -12,184 +12,13 @@ from typing import Optional, Union from pydantic import Extra, validator, BaseModel, PositiveFloat, Field from openff.units import unit -import os - - -if os.environ.get('SPHINX', False): #pragma: no cover - class SettingsBaseModel(BaseModel): - class Config: - extra = Extra.forbid - validate_assignment = True - arbitrary_types_allowed = True - smart_union = True - - - class ThermoSettings(SettingsBaseModel): - """Settings for thermodynamic parameters. - - .. note:: - No checking is done to ensure a valid thermodynamic ensemble is - possible. - """ - - temperature = 298.15 * unit.kelvin - """Simulation temperature, default units kelvin""" - - pressure = 1.0 * unit.bar - """Simulation pressure, default units standard atmosphere (atm)""" - - ph: Union[PositiveFloat, None] = Field(None, description="Simulation pH") - redox_potential: Optional[float] = Field( - None, description="Simulation redox potential" - ) - - - class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): - """Base class for ForceFieldSettings objects""" - ... - - - class OpenMMSystemGeneratorFFSettings(SettingsBaseModel): - """Parameters to set up the force field with OpenMM ForceFields - - .. note:: - Right now we just basically just grab what we need for the - :class:`openmmforcefields.system_generators.SystemGenerator` - signature. See the `OpenMMForceField SystemGenerator documentation`_ - for more details. - - - .. _`OpenMMForceField SystemGenerator documentation`: - https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator - """ - constraints: Optional[str] = 'hbonds' - """Constraints to be applied to system. - One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" - - rigid_water: bool = True - remove_com: bool = False - hydrogen_mass: float = 3.0 - """Mass to be repartitioned to hydrogens from neighbouring - heavy atoms (in amu), default 3.0""" - - forcefields: list[str] = [ - "amber/ff14SB.xml", # ff14SB protein force field - "amber/tip3p_standard.xml", # TIP3P and recommended monovalent ion parameters - "amber/tip3p_HFE_multivalent.xml", # for divalent ions - "amber/phosaa10.xml", # Handles THE TPO - ] - """List of force field paths for all components except :class:`SmallMoleculeComponent` """ - - small_molecule_forcefield: str = "openff-2.0.0" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' - """Name of the force field to be used for :class:`SmallMoleculeComponent` """ - - @validator('constraints') - def constraint_check(cls, v): - allowed = {'hbonds', 'hangles', 'allbonds'} - - if not (v is None or v.lower() in allowed): - raise ValueError(f"Bad constraints value, use one of {allowed}") - - return v - - - class Settings(SettingsBaseModel): - """ - Container for all settings needed by a protocol - - This represents the minimal surface that all settings objects will have. - - Protocols can subclass this to extend this to cater for their additional settings. - """ - forcefield_settings: BaseForceFieldSettings - thermo_settings: ThermoSettings - - @classmethod - def get_defaults(cls): - return cls( - forcefield_settings=OpenMMSystemGeneratorFFSettings(), - thermo_settings=ThermoSettings(temperature=300 * unit.kelvin), - ) -else: - from gufe.settings import Settings, SettingsBaseModel # type: ignore - - -class SystemSettings(SettingsBaseModel): - """Settings describing the simulation system settings.""" - - class Config: - arbitrary_types_allowed = True - - nonbonded_method = 'PME' - """ - Method for treating nonbonded interactions, currently only PME and - NoCutoff are allowed. Default PME. - """ - nonbonded_cutoff = 1.0 * unit.nanometer - """ - Cutoff value for short range nonbonded interactions. - Default 1.0 * unit.nanometer. - """ - - @validator('nonbonded_method') - def allowed_nonbonded(cls, v): - if v.lower() not in ['pme', 'nocutoff']: - errmsg = ("Only PME and NoCutoff are allowed nonbonded_methods") - raise ValueError(errmsg) - return v - - @validator('nonbonded_cutoff') - def is_positive_distance(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.nanometer): - raise ValueError("nonbonded_cutoff must be in distance units " - "(i.e. nanometers)") - if v < 0: - errmsg = "nonbonded_cutoff must be a positive value" - raise ValueError(errmsg) - return v - - -class SolvationSettings(SettingsBaseModel): - """Settings for solvating the system - - Note - ---- - No solvation will happen if a SolventComponent is not passed. - - """ - class Config: - arbitrary_types_allowed = True - - solvent_model = 'tip3p' - """ - Force field water model to use. - Allowed values are; `tip3p`, `spce`, `tip4pew`, and `tip5p`. - """ - - solvent_padding = 1.2 * unit.nanometer - """Minimum distance from any solute atoms to the solvent box edge.""" - - @validator('solvent_model') - def allowed_solvent(cls, v): - allowed_models = ['tip3p', 'spce', 'tip4pew', 'tip5p'] - if v.lower() not in allowed_models: - errmsg = ( - f"Only {allowed_models} are allowed solvent_model values" - ) - raise ValueError(errmsg) - return v - - @validator('solvent_padding') - def is_positive_distance(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.nanometer): - raise ValueError("solvent_padding must be in distance units " - "(i.e. nanometers)") - if v < 0: - errmsg = "solvent_padding must be a positive value" - raise ValueError(errmsg) - return v +from openfe.protocols.openmm_utils.omm_settings import ( + Settings, SettingsBaseModel, ThermoSettings, + OpenMMSystemGeneratorFFSettings, SystemSettings, + SolvationSettings, AlchemicalSamplerSettings, + OpenMMEngineSettings, IntegratorSettings, + SimulationSettings +) class AlchemicalSettings(SettingsBaseModel): @@ -247,268 +76,6 @@ class AlchemicalSettings(SettingsBaseModel): """ -class AlchemicalSamplerSettings(SettingsBaseModel): - """Settings for the Equilibrium Alchemical sampler, currently supporting - either MultistateSampler, SAMSSampler or ReplicaExchangeSampler. - - """ - - """ - TODO - ---- - * It'd be great if we could pass in the sampler object rather than using - strings to define which one we want. - * Make n_replicas optional such that: If `None` or greater than the number - of lambda windows set in :class:`AlchemicalSettings`, this will default - to the number of lambda windows. If less than the number of lambda - windows, the replica lambda states will be picked at equidistant - intervals along the lambda schedule. - """ - class Config: - arbitrary_types_allowed = True - - sampler_method = "repex" - """ - Alchemical sampling method, must be one of; - `repex` (Hamiltonian Replica Exchange), - `sams` (Self-Adjusted Mixture Sampling), - or `independent` (independently sampled lambda windows). - Default `repex`. - """ - online_analysis_interval: Optional[int] = 250 - """ - MCMC steps (i.e. ``IntegratorSettings.n_steps``) interval at which - to perform an analysis of the free energies. - - At each interval, real time analysis data (e.g. current free energy - estimate and timing data) will be written to a yaml file named - ``_real_time_analysis.yaml``. The - current error in the estimate will also be assed and if it drops - below ``AlchemicalSamplerSettings.online_analysis_target_error`` - the simulation will be terminated. - - If ``None``, no real time analysis will be performed and the yaml - file will not be written. - - Must be a multiple of ``SimulationSettings.checkpoint_interval`` - - Default `250`. - """ - online_analysis_target_error = 0.0 * unit.boltzmann_constant * unit.kelvin - """ - Target error for the online analysis measured in kT. Once the free energy - is at or below this value, the simulation will be considered complete. A - suggested value of 0.2 * `unit.boltzmann_constant` * `unit.kelvin` has - shown to be effective in both hydration and binding free energy benchmarks. - Default 0.0 * `unit.boltzmann_constant` * `unit.kelvin`, i.e. no early - termination will occur. - """ - online_analysis_minimum_iterations = 500 - """ - Number of iterations which must pass before online analysis is - carried out. Default 500. - """ - n_repeats: int = 3 - """ - Number of independent repeats to run. Default 3 - """ - flatness_criteria = 'logZ-flatness' - """ - SAMS only. Method for assessing when to switch to asymptomatically - optimal scheme. - One of ['logZ-flatness', 'minimum-visits', 'histogram-flatness']. - Default 'logZ-flatness'. - """ - gamma0 = 1.0 - """SAMS only. Initial weight adaptation rate. Default 1.0.""" - n_replicas = 11 - """Number of replicas to use. Default 11.""" - - @validator('flatness_criteria') - def supported_flatness(cls, v): - supported = [ - 'logz-flatness', 'minimum-visits', 'histogram-flatness' - ] - if v.lower() not in supported: - errmsg = ("Only the following flatness_criteria are " - f"supported: {supported}") - raise ValueError(errmsg) - return v - - @validator('sampler_method') - def supported_sampler(cls, v): - supported = ['repex', 'sams', 'independent'] - if v.lower() not in supported: - errmsg = ("Only the following sampler_method values are " - f"supported: {supported}") - raise ValueError(errmsg) - return v - - @validator('n_repeats', 'n_replicas') - def must_be_positive(cls, v): - if v <= 0: - errmsg = "n_repeats and n_replicas must be positive values" - raise ValueError(errmsg) - return v - - @validator('online_analysis_target_error', 'n_repeats', - 'online_analysis_minimum_iterations', 'gamma0', 'n_replicas') - def must_be_zero_or_positive(cls, v): - if v < 0: - errmsg = ("Online analysis target error, minimum iteration " - "and SAMS gamm0 must be 0 or positive values.") - raise ValueError(errmsg) - return v - - -class OpenMMEngineSettings(SettingsBaseModel): - """OpenMM MD engine settings""" - - - """ - TODO - ---- - * In the future make precision and deterministic forces user defined too. - """ - - compute_platform: Optional[str] = None - """ - OpenMM compute platform to perform MD integration with. If None, will - choose fastest available platform. Default None. - """ - - -class IntegratorSettings(SettingsBaseModel): - """Settings for the LangevinSplittingDynamicsMove integrator""" - - class Config: - arbitrary_types_allowed = True - - timestep = 4 * unit.femtosecond - """Size of the simulation timestep. Default 4 * unit.femtosecond.""" - collision_rate = 1.0 / unit.picosecond - """Collision frequency. Default 1.0 / unit.pisecond.""" - n_steps = 250 * unit.timestep - """ - Number of integration timesteps between each time the MCMC move - is applied. Default 250 * unit.timestep. - """ - reassign_velocities = False - """ - If True, velocities are reassigned from the Maxwell-Boltzmann - distribution at the beginning of move. Default False. - """ - n_restart_attempts = 20 - """ - Number of attempts to restart from Context if there are NaNs in the - energies after integration. Default 20. - """ - constraint_tolerance = 1e-06 - """Tolerance for the constraint solver. Default 1e-6.""" - barostat_frequency = 25 * unit.timestep - """ - Frequency at which volume scaling changes should be attempted. - Default 25 * unit.timestep. - """ - - @validator('collision_rate', 'n_restart_attempts') - def must_be_positive_or_zero(cls, v): - if v < 0: - errmsg = ("collision_rate, and n_restart_attempts must be " - "zero or positive values") - raise ValueError(errmsg) - return v - - @validator('timestep', 'n_steps', 'constraint_tolerance') - def must_be_positive(cls, v): - if v <= 0: - errmsg = ("timestep, n_steps, constraint_tolerance " - "must be positive values") - raise ValueError(errmsg) - return v - - @validator('timestep') - def is_time(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.picosecond): - raise ValueError("timestep must be in time units " - "(i.e. picoseconds)") - return v - - @validator('collision_rate') - def must_be_inverse_time(cls, v): - if not v.is_compatible_with(1 / unit.picosecond): - raise ValueError("collision_rate must be in inverse time " - "(i.e. 1/picoseconds)") - return v - - -class SimulationSettings(SettingsBaseModel): - """ - Settings for simulation control, including lengths, - writing to disk, etc... - """ - class Config: - arbitrary_types_allowed = True - - minimization_steps = 5000 - """Number of minimization steps to perform. Default 5000.""" - equilibration_length: unit.Quantity - """ - Length of the equilibration phase in units of time. The total number of - steps from this equilibration length - (i.e. ``equilibration_length`` / :class:`IntegratorSettings.timestep`) - must be a multiple of the value defined for - :class:`IntegratorSettings.n_steps`. - """ - production_length: unit.Quantity - """ - Length of the production phase in units of time. The total number of - steps from this production length (i.e. - ``production_length`` / :class:`IntegratorSettings.timestep`) must be - a multiple of the value defined for :class:`IntegratorSettings.nsteps`. - """ - - # reporter settings - output_filename = 'simulation.nc' - """Path to the storage file for analysis. Default 'simulation.nc'.""" - output_indices = 'not water' - """ - Selection string for which part of the system to write coordinates for. - Default 'not water'. - """ - checkpoint_interval = 250 * unit.timestep - """ - Frequency to write the checkpoint file. Default 250 * unit.timestep. - """ - checkpoint_storage = 'checkpoint.nc' - """ - Separate filename for the checkpoint file. Note, this should - not be a full path, just a filename. Default 'checkpoint.nc'. - """ - forcefield_cache: Optional[str] = 'db.json' - """ - Filename for caching small molecule residue templates so they can be - later reused. - """ - - @validator('equilibration_length', 'production_length') - def is_time(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.picosecond): - raise ValueError("Durations must be in time units") - return v - - @validator('minimization_steps', 'equilibration_length', - 'production_length', 'checkpoint_interval') - def must_be_positive(cls, v): - if v <= 0: - errmsg = ("Minimization steps, MD lengths, and checkpoint " - "intervals must be positive") - raise ValueError(errmsg) - return v - - class RelativeHybridTopologyProtocolSettings(Settings): class Config: arbitrary_types_allowed = True diff --git a/openfe/protocols/openmm_utils/omm_settings.py b/openfe/protocols/openmm_utils/omm_settings.py new file mode 100644 index 000000000..75b0eda53 --- /dev/null +++ b/openfe/protocols/openmm_utils/omm_settings.py @@ -0,0 +1,451 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +""" +Standardised set of settings for OpenMM based Protocols. +""" +from __future__ import annotations + +import abc +from typing import Optional, Union +from pydantic import Extra, validator, BaseModel, PositiveFloat, Field +from openff.units import unit +import os + + +if os.environ.get('SPHINX', False): #pragma: no cover + class SettingsBaseModel(BaseModel): + class Config: + extra = Extra.forbid + validate_assignment = True + arbitrary_types_allowed = True + smart_union = True + + + class ThermoSettings(SettingsBaseModel): + """Settings for thermodynamic parameters. + + .. note:: + No checking is done to ensure a valid thermodynamic ensemble is + possible. + """ + + temperature = 298.15 * unit.kelvin + """Simulation temperature, default units kelvin""" + + pressure = 1.0 * unit.bar + """Simulation pressure, default units standard atmosphere (atm)""" + + ph: Union[PositiveFloat, None] = Field(None, description="Simulation pH") + redox_potential: Optional[float] = Field( + None, description="Simulation redox potential" + ) + + + class BaseForceFieldSettings(SettingsBaseModel, abc.ABC): + """Base class for ForceFieldSettings objects""" + ... + + + class OpenMMSystemGeneratorFFSettings(SettingsBaseModel): + """Parameters to set up the force field with OpenMM ForceFields + + .. note:: + Right now we just basically just grab what we need for the + :class:`openmmforcefields.system_generators.SystemGenerator` + signature. See the `OpenMMForceField SystemGenerator documentation`_ + for more details. + + + .. _`OpenMMForceField SystemGenerator documentation`: + https://github.com/openmm/openmmforcefields#automating-force-field-management-with-systemgenerator + """ + constraints: Optional[str] = 'hbonds' + """Constraints to be applied to system. + One of 'hbonds', 'allbonds', 'hangles' or None, default 'hbonds'""" + + rigid_water: bool = True + remove_com: bool = False + hydrogen_mass: float = 3.0 + """Mass to be repartitioned to hydrogens from neighbouring + heavy atoms (in amu), default 3.0""" + + forcefields: list[str] = [ + "amber/ff14SB.xml", # ff14SB protein force field + "amber/tip3p_standard.xml", # TIP3P and recommended monovalent ion parameters + "amber/tip3p_HFE_multivalent.xml", # for divalent ions + "amber/phosaa10.xml", # Handles THE TPO + ] + """List of force field paths for all components except :class:`SmallMoleculeComponent` """ + + small_molecule_forcefield: str = "openff-2.0.0" # other default ideas 'openff-2.0.0', 'gaff-2.11', 'espaloma-0.2.0' + """Name of the force field to be used for :class:`SmallMoleculeComponent` """ + + @validator('constraints') + def constraint_check(cls, v): + allowed = {'hbonds', 'hangles', 'allbonds'} + + if not (v is None or v.lower() in allowed): + raise ValueError(f"Bad constraints value, use one of {allowed}") + + return v + + + class Settings(SettingsBaseModel): + """ + Container for all settings needed by a protocol + + This represents the minimal surface that all settings objects will have. + + Protocols can subclass this to extend this to cater for their additional settings. + """ + forcefield_settings: BaseForceFieldSettings + thermo_settings: ThermoSettings + + @classmethod + def get_defaults(cls): + return cls( + forcefield_settings=OpenMMSystemGeneratorFFSettings(), + thermo_settings=ThermoSettings(temperature=300 * unit.kelvin), + ) +else: + from gufe.settings import Settings, SettingsBaseModel, ThermoSettings, OpenMMSystemGeneratorFFSettings # type: ignore + + +class SystemSettings(SettingsBaseModel): + """Settings describing the simulation system settings.""" + + class Config: + arbitrary_types_allowed = True + + nonbonded_method = 'PME' + """ + Method for treating nonbonded interactions, currently only PME and + NoCutoff are allowed. Default PME. + """ + nonbonded_cutoff = 1.0 * unit.nanometer + """ + Cutoff value for short range nonbonded interactions. + Default 1.0 * unit.nanometer. + """ + + @validator('nonbonded_method') + def allowed_nonbonded(cls, v): + if v.lower() not in ['pme', 'nocutoff']: + errmsg = ("Only PME and NoCutoff are allowed nonbonded_methods") + raise ValueError(errmsg) + return v + + @validator('nonbonded_cutoff') + def is_positive_distance(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.nanometer): + raise ValueError("nonbonded_cutoff must be in distance units " + "(i.e. nanometers)") + if v < 0: + errmsg = "nonbonded_cutoff must be a positive value" + raise ValueError(errmsg) + return v + + +class SolvationSettings(SettingsBaseModel): + """Settings for solvating the system + + Note + ---- + No solvation will happen if a SolventComponent is not passed. + + """ + class Config: + arbitrary_types_allowed = True + + solvent_model = 'tip3p' + """ + Force field water model to use. + Allowed values are; `tip3p`, `spce`, `tip4pew`, and `tip5p`. + """ + + solvent_padding = 1.2 * unit.nanometer + """Minimum distance from any solute atoms to the solvent box edge.""" + + @validator('solvent_model') + def allowed_solvent(cls, v): + allowed_models = ['tip3p', 'spce', 'tip4pew', 'tip5p'] + if v.lower() not in allowed_models: + errmsg = ( + f"Only {allowed_models} are allowed solvent_model values" + ) + raise ValueError(errmsg) + return v + + @validator('solvent_padding') + def is_positive_distance(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.nanometer): + raise ValueError("solvent_padding must be in distance units " + "(i.e. nanometers)") + if v < 0: + errmsg = "solvent_padding must be a positive value" + raise ValueError(errmsg) + return v + + +class AlchemicalSamplerSettings(SettingsBaseModel): + """Settings for the Equilibrium Alchemical sampler, currently supporting + either MultistateSampler, SAMSSampler or ReplicaExchangeSampler. + + """ + + """ + TODO + ---- + * It'd be great if we could pass in the sampler object rather than using + strings to define which one we want. + * Make n_replicas optional such that: If `None` or greater than the number + of lambda windows set in :class:`AlchemicalSettings`, this will default + to the number of lambda windows. If less than the number of lambda + windows, the replica lambda states will be picked at equidistant + intervals along the lambda schedule. + """ + class Config: + arbitrary_types_allowed = True + + sampler_method = "repex" + """ + Alchemical sampling method, must be one of; + `repex` (Hamiltonian Replica Exchange), + `sams` (Self-Adjusted Mixture Sampling), + or `independent` (independently sampled lambda windows). + Default `repex`. + """ + online_analysis_interval: Optional[int] = 250 + """ + MCMC steps (i.e. ``IntegratorSettings.n_steps``) interval at which + to perform an analysis of the free energies. + + At each interval, real time analysis data (e.g. current free energy + estimate and timing data) will be written to a yaml file named + ``_real_time_analysis.yaml``. The + current error in the estimate will also be assed and if it drops + below ``AlchemicalSamplerSettings.online_analysis_target_error`` + the simulation will be terminated. + + If ``None``, no real time analysis will be performed and the yaml + file will not be written. + + Must be a multiple of ``SimulationSettings.checkpoint_interval`` + + Default `250`. + """ + online_analysis_target_error = 0.0 * unit.boltzmann_constant * unit.kelvin + """ + Target error for the online analysis measured in kT. Once the free energy + is at or below this value, the simulation will be considered complete. A + suggested value of 0.2 * `unit.boltzmann_constant` * `unit.kelvin` has + shown to be effective in both hydration and binding free energy benchmarks. + Default 0.0 * `unit.boltzmann_constant` * `unit.kelvin`, i.e. no early + termination will occur. + """ + online_analysis_minimum_iterations = 500 + """ + Number of iterations which must pass before online analysis is + carried out. Default 500. + """ + n_repeats: int = 3 + """ + Number of independent repeats to run. Default 3 + """ + flatness_criteria = 'logZ-flatness' + """ + SAMS only. Method for assessing when to switch to asymptomatically + optimal scheme. + One of ['logZ-flatness', 'minimum-visits', 'histogram-flatness']. + Default 'logZ-flatness'. + """ + gamma0 = 1.0 + """SAMS only. Initial weight adaptation rate. Default 1.0.""" + n_replicas = 11 + """Number of replicas to use. Default 11.""" + + @validator('flatness_criteria') + def supported_flatness(cls, v): + supported = [ + 'logz-flatness', 'minimum-visits', 'histogram-flatness' + ] + if v.lower() not in supported: + errmsg = ("Only the following flatness_criteria are " + f"supported: {supported}") + raise ValueError(errmsg) + return v + + @validator('sampler_method') + def supported_sampler(cls, v): + supported = ['repex', 'sams', 'independent'] + if v.lower() not in supported: + errmsg = ("Only the following sampler_method values are " + f"supported: {supported}") + raise ValueError(errmsg) + return v + + @validator('n_repeats', 'n_replicas') + def must_be_positive(cls, v): + if v <= 0: + errmsg = "n_repeats and n_replicas must be positive values" + raise ValueError(errmsg) + return v + + @validator('online_analysis_target_error', 'n_repeats', + 'online_analysis_minimum_iterations', 'gamma0', 'n_replicas') + def must_be_zero_or_positive(cls, v): + if v < 0: + errmsg = ("Online analysis target error, minimum iteration " + "and SAMS gamm0 must be 0 or positive values.") + raise ValueError(errmsg) + return v + + +class OpenMMEngineSettings(SettingsBaseModel): + """OpenMM MD engine settings""" + + + """ + TODO + ---- + * In the future make precision and deterministic forces user defined too. + """ + + compute_platform: Optional[str] = None + """ + OpenMM compute platform to perform MD integration with. If None, will + choose fastest available platform. Default None. + """ + + +class IntegratorSettings(SettingsBaseModel): + """Settings for the LangevinSplittingDynamicsMove integrator""" + + class Config: + arbitrary_types_allowed = True + + timestep = 4 * unit.femtosecond + """Size of the simulation timestep. Default 4 * unit.femtosecond.""" + collision_rate = 1.0 / unit.picosecond + """Collision frequency. Default 1.0 / unit.pisecond.""" + n_steps = 250 * unit.timestep + """ + Number of integration timesteps between each time the MCMC move + is applied. Default 250 * unit.timestep. + """ + reassign_velocities = False + """ + If True, velocities are reassigned from the Maxwell-Boltzmann + distribution at the beginning of move. Default False. + """ + n_restart_attempts = 20 + """ + Number of attempts to restart from Context if there are NaNs in the + energies after integration. Default 20. + """ + constraint_tolerance = 1e-06 + """Tolerance for the constraint solver. Default 1e-6.""" + barostat_frequency = 25 * unit.timestep + """ + Frequency at which volume scaling changes should be attempted. + Default 25 * unit.timestep. + """ + + @validator('collision_rate', 'n_restart_attempts') + def must_be_positive_or_zero(cls, v): + if v < 0: + errmsg = ("collision_rate, and n_restart_attempts must be " + "zero or positive values") + raise ValueError(errmsg) + return v + + @validator('timestep', 'n_steps', 'constraint_tolerance') + def must_be_positive(cls, v): + if v <= 0: + errmsg = ("timestep, n_steps, constraint_tolerance " + "must be positive values") + raise ValueError(errmsg) + return v + + @validator('timestep') + def is_time(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.picosecond): + raise ValueError("timestep must be in time units " + "(i.e. picoseconds)") + return v + + @validator('collision_rate') + def must_be_inverse_time(cls, v): + if not v.is_compatible_with(1 / unit.picosecond): + raise ValueError("collision_rate must be in inverse time " + "(i.e. 1/picoseconds)") + return v + + +class SimulationSettings(SettingsBaseModel): + """ + Settings for simulation control, including lengths, + writing to disk, etc... + """ + class Config: + arbitrary_types_allowed = True + + minimization_steps = 5000 + """Number of minimization steps to perform. Default 5000.""" + equilibration_length: unit.Quantity + """ + Length of the equilibration phase in units of time. The total number of + steps from this equilibration length + (i.e. ``equilibration_length`` / :class:`IntegratorSettings.timestep`) + must be a multiple of the value defined for + :class:`IntegratorSettings.n_steps`. + """ + production_length: unit.Quantity + """ + Length of the production phase in units of time. The total number of + steps from this production length (i.e. + ``production_length`` / :class:`IntegratorSettings.timestep`) must be + a multiple of the value defined for :class:`IntegratorSettings.nsteps`. + """ + + # reporter settings + output_filename = 'simulation.nc' + """Path to the storage file for analysis. Default 'simulation.nc'.""" + output_indices = 'not water' + """ + Selection string for which part of the system to write coordinates for. + Default 'not water'. + """ + checkpoint_interval = 250 * unit.timestep + """ + Frequency to write the checkpoint file. Default 250 * unit.timestep. + """ + checkpoint_storage = 'checkpoint.nc' + """ + Separate filename for the checkpoint file. Note, this should + not be a full path, just a filename. Default 'checkpoint.nc'. + """ + forcefield_cache: Optional[str] = 'db.json' + """ + Filename for caching small molecule residue templates so they can be + later reused. + """ + + @validator('equilibration_length', 'production_length') + def is_time(cls, v): + # these are time units, not simulation steps + if not v.is_compatible_with(unit.picosecond): + raise ValueError("Durations must be in time units") + return v + + @validator('minimization_steps', 'equilibration_length', + 'production_length', 'checkpoint_interval') + def must_be_positive(cls, v): + if v <= 0: + errmsg = ("Minimization steps, MD lengths, and checkpoint " + "intervals must be positive") + raise ValueError(errmsg) + return v From 98c3e67c07f3909e067e8f7fd07fe53be3e2b4d2 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 29 Jun 2023 13:46:29 +0100 Subject: [PATCH 30/74] continue refactor --- openfe/protocols/openmm_afe/afe_settings.py | 386 ---------------- .../openmm_afe/equil_afe_settings.py | 68 +++ ...afe_methods.py => solvation_afe_method.py} | 414 +++++------------- 3 files changed, 170 insertions(+), 698 deletions(-) delete mode 100644 openfe/protocols/openmm_afe/afe_settings.py create mode 100644 openfe/protocols/openmm_afe/equil_afe_settings.py rename openfe/protocols/openmm_afe/{equil_afe_methods.py => solvation_afe_method.py} (72%) diff --git a/openfe/protocols/openmm_afe/afe_settings.py b/openfe/protocols/openmm_afe/afe_settings.py deleted file mode 100644 index 54d9c320e..000000000 --- a/openfe/protocols/openmm_afe/afe_settings.py +++ /dev/null @@ -1,386 +0,0 @@ -# This code is part of OpenFE and is licensed under the MIT license. -# For details, see https://github.com/OpenFreeEnergy/openfe - -"""Settings class for equilibrium AFE Protocols using OpenMM + OpenMMTools - -This module implements the necessary settings necessary to run absolute free -energies using :class:`openfe.protocols.openmm_abfe.equil_abfe_methods.py` - - -TODO ----- -* Add support for restraints -* Improve this docstring by adding an example use case. - -""" -from typing import Optional -from pydantic import validator -from openff.units import unit -from gufe import settings - - -class SystemSettings(settings.SettingsBaseModel): - """Settings describing the simulation system settings.""" - - class Config: - arbitrary_types_allowed = True - - nonbonded_method = 'PME' - """ - Method for treating nonbonded interactions, currently only PME and - NoCutoff are allowed. Default PME. - """ - nonbonded_cutoff = 1.0 * unit.nanometer - """ - Cutoff value for short range nonbonded interactions. - Default 1.0 * unit.nanometer. - """ - - @validator('nonbonded_method') - def allowed_nonbonded(cls, v): - if v.lower() not in ['pme', 'nocutoff']: - errmsg = ("Only PME and NoCutoff are allowed nonbonded_methods") - raise ValueError(errmsg) - return v - - @validator('nonbonded_cutoff') - def is_positive_distance(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.nanometer): - raise ValueError("nonbonded_cutoff must be in distance units " - "(i.e. nanometers)") - if v < 0: - errmsg = "nonbonded_cutoff must be a positive value" - raise ValueError(errmsg) - return v - - -class SolventSettings(settings.SettingsBaseModel): - """Settings for solvating the system - - NOTE - ---- - * No solvation will happen if a SolventComponent is not passed. - """ - solvent_model = 'tip3p' - """ - Force field water model to use. - Allowed values are; `tip3p`, `spce`, `tip4pew`, and `tip5p`. - """ - class Config: - arbitrary_types_allowed = True - - solvent_padding = 1.2 * unit.nanometer - - @validator('solvent_model') - def allowed_solvent(cls, v): - allowed_models = ['tip3p', 'spce', 'tip4pew', 'tip5p'] - if v.lower() not in allowed_models: - errmsg = ( - f"Only {allowed_models} are allowed solvent_model values" - ) - raise ValueError(errmsg) - return v - - @validator('solvent_padding') - def is_positive_distance(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.nanometer): - raise ValueError("solvent_padding must be in distance units " - "(i.e. nanometers)") - if v < 0: - errmsg = "solvent_padding must be a positive value" - raise ValueError(errmsg) - return v - - -class AlchemicalSettings(settings.SettingsBaseModel): - """Settings for the alchemical protocol - - These settings describe the lambda schedule and the creation of the - hybrid system. - """ - - lambda_elec_windows = 12 - """Number of lambda electrostatic alchemical steps, default 12""" - lambda_vdw_windows = 12 - """Number of lambda vdw alchemical steps, default 12""" - - @validator('lambda_elec_windows', 'lambda_vdw_windows') - def must_be_positive(cls, v): - if v <= 0: - errmsg = ("Number of lambda steps must be positive ") - raise ValueError(errmsg) - return v - - -class OpenMMEngineSettings(settings.SettingsBaseModel): - """OpenMM MD engine settings - - TODO - ---- - * In the future make precision and deterministic forces user defined too. - """ - - compute_platform: Optional[str] = None - """ - OpenMM compute platform to perform MD integration with. If None, will - choose fastest available platform. Default None. - """ - - -class AlchemicalSamplerSettings(settings.SettingsBaseModel): - """Settings for the Equilibrium Alchemical sampler, currently supporting - either MultistateSampler, SAMSSampler or ReplicaExchangeSampler. - - - TODO - ---- - * It'd be great if we could pass in the sampler object rather than using - strings to define which one we want. - * Make n_replicas optional such that: If `None` or greater than the number - of lambda windows set in :class:`AlchemicalSettings`, this will default - to the number of lambda windows. If less than the number of lambda - windows, the replica lambda states will be picked at equidistant - intervals along the lambda schedule. - """ - class Config: - arbitrary_types_allowed = True - - sampler_method = "repex" - """ - Alchemical sampling method, must be one of; - `repex` (Hamiltonian Replica Exchange), - `sams` (Self-Adjusted Mixture Sampling), - or `independent` (independently sampled lambda windows). - Default `repex`. - """ - online_analysis_interval: Optional[int] = None - """ - Interval at which to perform an analysis of the free energies. - At each interval the free energy is estimate and the simulation is - considered complete if the free energy estimate is below - ``online_analysis_target_error``. If set, will write a yaml file with - real time analysis data. Default `None`. - """ - online_analysis_target_error = 0.05 * unit.boltzmann_constant * unit.kelvin - """ - Target error for the online analysis measured in kT. Once the free energy - is at or below this value, the simulation will be considered complete. - """ - online_analysis_minimum_iterations = 1000 - """ - Number of iterations which must pass before online analysis is - carried out. Default 50. - """ - n_repeats: int = 3 - """ - Number of independent repeats to run. Default 3 - """ - flatness_criteria = 'logZ-flatness' - """ - SAMS only. Method for assessing when to switch to asymptomatically - optimal scheme. - - One of ['logZ-flatness', 'minimum-visits', 'histogram-flatness']. - - Default 'logZ-flatness'. - """ - gamma0 = 1.0 - """SAMS only. Initial weight adaptation rate. Default 1.0.""" - n_replicas = 24 - """Number of replicas to use. Default 24.""" - - @validator('flatness_criteria') - def supported_flatness(cls, v): - supported = [ - 'logz-flatness', 'minimum-visits', 'histogram-flatness' - ] - if v.lower() not in supported: - errmsg = ("Only the following flatness_criteria are " - f"supported: {supported}") - raise ValueError(errmsg) - return v - - @validator('sampler_method') - def supported_sampler(cls, v): - supported = ['repex', 'sams', 'independent'] - if v.lower() not in supported: - errmsg = ("Only the following sampler_method values are " - f"supported: {supported}") - raise ValueError(errmsg) - return v - - @validator('n_repeats', 'n_replicas') - def must_be_positive(cls, v): - if v <= 0: - errmsg = "n_repeats and n_replicas must be positive values" - raise ValueError(errmsg) - return v - - @validator('online_analysis_target_error', 'n_repeats', - 'online_analysis_minimum_iterations', 'gamma0', 'n_replicas') - def must_be_zero_or_positive(cls, v): - if v < 0: - errmsg = ("Online analysis target error, minimum iteration " - "and SAMS gamm0 must be 0 or positive values.") - raise ValueError(errmsg) - return v - - -class IntegratorSettings(settings.SettingsBaseModel): - """Settings for the LangevinSplittingDynamicsMove integrator""" - - class Config: - arbitrary_types_allowed = True - - timestep = 2 * unit.femtosecond - """Size of the simulation timestep. Default 2 * unit.femtosecond.""" - collision_rate = 1 / unit.picosecond - """Collision frequency. Default 1 / unit.pisecond.""" - n_steps = 250 * unit.timestep - """ - Number of integration timesteps between each time the MCMC move - is applied. Default 1000. - """ - reassign_velocities = True - """ - If True, velocities are reassigned from the Maxwell-Boltzmann - distribution at the beginning of move. Default False. - """ - splitting = "V R O R V" - """ - Sequence of "R", "V", "O" substeps to be carried out at each timestep. - Default "V R O R V". - """ - n_restart_attempts = 20 - """ - Number of attempts to restart from Context if there are NaNs in the - energies after integration. Default 20. - """ - constraint_tolerance = 1e-06 - """Tolerance for the constraint solver. Default 1e-6.""" - barostat_frequency = 25 * unit.timestep - """ - Frequency at which volume scaling changes should be attempted. - Default 25 * unit.timestep. - """ - - @validator('collision_rate', 'n_restart_attempts') - def must_be_positive_or_zero(cls, v): - if v < 0: - errmsg = ("collision_rate, and n_restart_attempts must be " - "zero or positive values") - raise ValueError(errmsg) - return v - - @validator('timestep', 'n_steps', 'constraint_tolerance') - def must_be_positive(cls, v): - if v <= 0: - errmsg = ("timestep, n_steps, constraint_tolerance " - "must be positive values") - raise ValueError(errmsg) - return v - - @validator('timestep') - def is_time(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.picosecond): - raise ValueError("timestep must be in time units " - "(i.e. picoseconds)") - return v - - @validator('collision_rate') - def must_be_inverse_time(cls, v): - if not v.is_compatible_with(1 / unit.picosecond): - raise ValueError("collision_rate must be in inverse time " - "(i.e. 1/picoseconds)") - return v - - -class SimulationSettings(settings.SettingsBaseModel): - """ - Settings for simulation control, including lengths, - writing to disk, etc... - """ - class Config: - arbitrary_types_allowed = True - - minimization_steps = 5000 - """Number of minimization steps to perform. Default 10000.""" - equilibration_length: unit.Quantity - """ - Length of the equilibration phase in units of time. The total number of - steps from this equilibration length - (i.e. ``equilibration_length`` / :class:`IntegratorSettings.timestep`) - must be a multiple of the value defined for - :class:`IntegratorSettings.n_steps`. - """ - production_length: unit.Quantity - """ - Length of the production phase in units of time. The total number of - steps from this production length (i.e. - ``production_length`` / :class:`IntegratorSettings.timestep`) must be - a multiple of the value defined for :class:`IntegratorSettings.nsteps`. - """ - - # reporter settings - output_filename = 'abfe.nc' - """Path to the storage file for analysis. Default 'rbfe.nc'.""" - output_indices = 'all' - """ - Selection string for which part of the system to write coordinates for. - Default 'all'. - """ - checkpoint_interval = 100 * unit.timestep - """ - Frequency to write the checkpoint file. Default 50 * unit.timestep. - """ - checkpoint_storage = 'abfe_checkpoint.nc' - """ - Separate filename for the checkpoint file. Note, this should - not be a full path, just a filename. Default 'rbfe_checkpoint.nc'. - """ - forcefield_cache: Optional[str] = None - """ - Filename for caching small molecule residue templates so they can be - later reused. - """ - - @validator('equilibration_length', 'production_length') - def is_time(cls, v): - # these are time units, not simulation steps - if not v.is_compatible_with(unit.picosecond): - raise ValueError("Durations must be in time units") - return v - - @validator('minimization_steps', 'equilibration_length', - 'production_length', 'checkpoint_interval') - def must_be_positive(cls, v): - if v <= 0: - errmsg = ("Minimization steps, MD lengths, and checkpoint " - "intervals must be positive") - raise ValueError(errmsg) - return v - - -class AbsoluteTransformSettings(settings.Settings): - class Config: - arbitrary_types_allowed = True - - # Things for creating the systems - system_settings: SystemSettings - solvent_settings: SolventSettings - - # Alchemical settings - alchemical_settings: AlchemicalSettings - alchemsampler_settings: AlchemicalSamplerSettings - - # MD Engine things - engine_settings: OpenMMEngineSettings - - # Sampling State defining things - integrator_settings: IntegratorSettings - - # Simulation run settings - simulation_settings: SimulationSettings diff --git a/openfe/protocols/openmm_afe/equil_afe_settings.py b/openfe/protocols/openmm_afe/equil_afe_settings.py new file mode 100644 index 000000000..93d6b8a2f --- /dev/null +++ b/openfe/protocols/openmm_afe/equil_afe_settings.py @@ -0,0 +1,68 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +"""Settings class for equilibrium AFE Protocols using OpenMM + OpenMMTools + +This module implements the necessary settings necessary to run absolute free +energies using :class:`openfe.protocols.openmm_abfe.equil_abfe_methods.py` + + +TODO +---- +* Add support for restraints +* Improve this docstring by adding an example use case. + +""" +from typing import Optional +from pydantic import validator +from openff.units import unit +from openfe.protocols.openmm_utils.omm_settings import ( + Settings, SettingsBaseModel, ThermoSettings, + OpenMMSystemGeneratorFFSettings, SystemSettings, + SolvationSettings, AlchemicalSamplerSettings, + OpenMMEngineSettings, IntegratorSettings, + SimulationSettings +) + + + +class AlchemicalSettings(SettingsBaseModel): + """Settings for the alchemical protocol + + These settings describe the lambda schedule and the creation of the + hybrid system. + """ + + lambda_elec_windows = 12 + """Number of lambda electrostatic alchemical steps, default 12""" + lambda_vdw_windows = 12 + """Number of lambda vdw alchemical steps, default 12""" + + @validator('lambda_elec_windows', 'lambda_vdw_windows') + def must_be_positive(cls, v): + if v <= 0: + errmsg = ("Number of lambda steps must be positive ") + raise ValueError(errmsg) + return v + + +class AbsoluteTransformSettings(Settings): + class Config: + arbitrary_types_allowed = True + + # Things for creating the systems + system_settings: SystemSettings + solvation_settings: SolvationSettings + + # Alchemical settings + alchemical_settings: AlchemicalSettings + alchemsampler_settings: AlchemicalSamplerSettings + + # MD Engine things + engine_settings: OpenMMEngineSettings + + # Sampling State defining things + integrator_settings: IntegratorSettings + + # Simulation run settings + simulation_settings: SimulationSettings diff --git a/openfe/protocols/openmm_afe/equil_afe_methods.py b/openfe/protocols/openmm_afe/solvation_afe_method.py similarity index 72% rename from openfe/protocols/openmm_afe/equil_afe_methods.py rename to openfe/protocols/openmm_afe/solvation_afe_method.py index be894cdf4..a37c8de65 100644 --- a/openfe/protocols/openmm_afe/equil_afe_methods.py +++ b/openfe/protocols/openmm_afe/solvation_afe_method.py @@ -12,7 +12,7 @@ * Independent window sampling -.. versionadded:: 0.7.0 +.. versionadded:: 0.10.2 Running a Solvation Free Energy Calculation @@ -190,13 +190,16 @@ settings, ChemicalSystem, SmallMoleculeComponent, ProteinComponent, SolventComponent ) -from openfe.protocols.openmm_afe.afe_settings import ( +from openfe.protocols.openmm_afe.equil_afe_settings import ( AbsoluteTransformSettings, SystemSettings, - SolventSettings, AlchemicalSettings, + SolvationSettings, AlchemicalSettings, AlchemicalSamplerSettings, OpenMMEngineSettings, IntegratorSettings, SimulationSettings, ) -from openfe.protocols.openmm_rbfe._rbfe_utils import compute +from openfe.protocols.openmm_rfe._rfe_utils import compute +from ..openmm_utils import ( + system_validation, settings_validation, system_creation +) logger = logging.getLogger(__name__) @@ -272,7 +275,7 @@ def get_rate_of_convergence(self): # pragma: no-cover raise NotImplementedError -class AbsoluteTransformProtocol(gufe.Protocol): +class AbsoluteSolvationProtocol(gufe.Protocol): result_cls = AbsoluteTransformProtocolResult _settings: AbsoluteTransformSettings @@ -295,77 +298,69 @@ def _default_settings(cls): temperature=298.15 * unit.kelvin, pressure=1 * unit.bar, ), - system_settings=SystemSettings(), + solvent_system_settings=SystemSettings(), + vacuum_system_settings=SystemSettings(nonbonded_method='nocutoff'), alchemical_settings=AlchemicalSettings(), alchemsampler_settings=AlchemicalSamplerSettings(), - solvent_settings=SolventSettings(), + solvation_settings=SolvationSettings(), engine_settings=OpenMMEngineSettings(), - integrator_settings=IntegratorSettings( - timestep=4.0 * unit.femtosecond, - n_steps=250 * unit.timestep, + integrator_settings=IntegratorSettings(), + solvent_simulation_settings=SimulationSettings( + equilibration_length=1.0 * unit.nanosecond, + production_length=10.0 * unit.nanosecond, + output_filename='solvent.nc', + checkpoint_storage='solvent_checkpoint.nc', + ), + vacuum_simulation_settings=SimulationSettings( + equilibration_length=0.5 * unit.nanosecond, + production_length=2.0 * unit.nanosecond, + output_filename='vacuum.nc', + checkpoint_storage='vacuum_checkpoint.nc' ), - simulation_settings=SimulationSettings( - equilibration_length=2.0 * unit.nanosecond, - production_length=5.0 * unit.nanosecond, - ) ) @staticmethod - def _get_alchemical_components( - stateA: ChemicalSystem, - stateB: ChemicalSystem) -> Dict[str, List[Component]]: + def _validate_solvent_endstates( + stateA: ChemicalSystem, stateB: ChemicalSystem, + ) -> None: """ - Checks equality of ChemicalSystem components across both states and - identify which components do not match. + A solvent transformation is defined (in terms of gufe components) + as starting from a ligand in solvent and ending up just in solvent. Parameters ---------- stateA : ChemicalSystem - The chemical system of end state A. + The chemical system of end state A stateB : ChemicalSystem - The chemical system of end state B. + The chemical system of end state B - Returns - ------- - alchemical_components : Dictionary - Dictionary containing a list of alchemical components for each - state. + Raises + ------ + ValueError + If stateB contains anything else but a SolventComponent. + If stateA contains a ProteinComponent """ - matched_components = {} - alchemical_components: Dict[str, List[Any]] = { - 'stateA': [], 'stateB': [] - } - - for keyA, valA in stateA.components.items(): - for keyB, valB in stateB.components.items(): - if valA.to_dict() == valB.to_dict(): - matched_components[keyA] = keyB - break - - # populate state A alchemical components - for keyA in stateA.components.keys(): - if keyA not in matched_components.keys(): - alchemical_components['stateA'].append(keyA) - - # populate state B alchemical components - for keyB in stateB.components.keys(): - if keyB not in matched_components.values(): - alchemical_components['stateB'].append(keyB) + if ((len(stateB) != 1) or + (not isinstance(stateB.values()[0], SolventComponent))): + errmsg = "Only a single SolventComponent is allowed in stateB" + raise ValueError(errmsg) - return alchemical_components + for comp in stateA.values(): + if isinstance(comp, ProteinComponent): + errmsg = ("Protein components are not allow for " + "absolute solvation free energies") + raise ValueError(errmsg) @staticmethod def _validate_alchemical_components( - stateA: ChemicalSystem, - alchemical_components: Dict[str, List[str]]): + alchemical_components: dict[str, list[Component]] + ) -> None: """ Checks that the ChemicalSystem alchemical components are correct. Parameters ---------- - stateA : ChemicalSystem - The chemical system of end state A. - alchemical_components : Dict[str, List[str]] + alchemical_components : Dict[str, list[Component]] Dictionary containing the alchemical components for stateA and stateB. @@ -374,12 +369,15 @@ def _validate_alchemical_components( ValueError If there are alchemical components in state B. If there are non SmallMoleculeComponent alchemical species. + If there are more than one alchemical species. Notes ----- * Currently doesn't support alchemical components in state B. * Currently doesn't support alchemical components which are not SmallMoleculeComponents. + * Currently doesn't support more than one alchemical component + being desolvated. """ # Crash out if there are any alchemical components in state B for now @@ -387,47 +385,19 @@ def _validate_alchemical_components( errmsg = ("Components appearing in state B are not " "currently supported") raise ValueError(errmsg) + + if len(alchemical_components['stateA']) > 1: + errmsg = ("More than one alchemical components is not supported " + "for absolute solvation free energies") # Crash out if any of the alchemical components are not # SmallMoleculeComponent - for key in alchemical_components['stateA']: - comp = stateA.components[key] + for comp in alchemical_components['stateA']: if not isinstance(comp, SmallMoleculeComponent): errmsg = ("Non SmallMoleculeComponent alchemical species " "are not currently supported") raise ValueError(errmsg) - @staticmethod - def _validate_solvent(state: ChemicalSystem, nonbonded_method: str): - """ - Checks that the ChemicalSystem component has the right solvent - composition with an input nonbonded_method. - - Parameters - ---------- - state : ChemicalSystem - The chemical system to inspect - - Raises - ------ - ValueError - If there are multiple SolventComponents in the ChemicalSystem - or if there is a SolventComponent and - `nonbonded_method` is `nocutoff` - """ - solvents = 0 - for component in state.components.values(): - if isinstance(component, SolventComponent): - if nonbonded_method.lower() == "nocutoff": - errmsg = (f"{nonbonded_method} cannot be used for solvent " - "transformation") - raise ValueError(errmsg) - solvents += 1 - - if solvents > 1: - errmsg = "Multiple SolventComponents found, only one is supported" - raise ValueError(errmsg) - def _create( self, stateA: ChemicalSystem, @@ -439,34 +409,55 @@ def _create( if extends: # pragma: no-cover raise NotImplementedError("Can't extend simulations yet") - # Checks on the inputs! - # 1) check solvent compatibility - nonbonded_method = self.settings.system_settings.nonbonded_method - self._validate_solvent(stateA, nonbonded_method) - self._validate_solvent(stateB, nonbonded_method) - - # 2) check your alchemical molecules - # Note: currently only SmallMoleculeComponents in state A are - # supported - alchemical_comps = self._get_alchemical_components(stateA, stateB) - self._validate_alchemical_components(stateA, alchemical_comps) - - # Get a list of names for all the alchemical molecules - stateA_alchnames = ','.join( - [stateA.components[c].name for c in alchemical_comps['stateA']] + # Validate components and get alchemical components + self._validate_solvation_endstates(stateA, stateB) + alchem_comps = system_validation.get_alchemical_components( + stateA, stateB, ) + self._validate_alchemical_components(alchem_comps) + + # Check nonbond & solvent compatibility + solv_nonbonded_method = self.settings.solvent_system_settings.nonbonded_method + vac_nonbonded_method = self.settings.vacuum_system_settings.nonbonded_method + # Use the more complete system validation solvent checks + system_validation.validate_solvent(stateA, solv_nonbonded_method) + # Gas phase is always gas phase + assert vac_nonbonded_method.lower() != 'pme' + + # Get the name of the alchemical species + alchname = alchem_comps['stateA'][0].name + + # Create list units for vacuum and solvent transforms + + solvent_units = [ + AbsoluteSolventTransformUnit( + stateA=stateA, stateB=stateB, + settings=self.settings, + alchemical_components=alchemical_comps, + generation=0, repeat_id=i, + name=(f"Absolute Solvation, {alchname} solvent leg: " + f"repeat {i} generation 0"), + ) + for i in range(self.settings.alchemsampler_settings.n_repeats) + ] - # our DAG has no dependencies, so just list units - units = [AbsoluteTransformUnit( - stateA=stateA, stateB=stateB, - settings=self.settings, - alchemical_components=alchemical_comps, - generation=0, repeat_id=i, - name=f'Absolute {stateA_alchnames}: repeat {i} generation 0') - for i in range(self.settings.alchemsampler_settings.n_repeats)] + vacuum_units = [ + AbsoluteVacuumTransformUnit( + # These don't really reflect the actual transform + # Should these be overriden to be ChemicalSystem{smc} -> ChemicalSystem{} ? + stateA=stateA, stateB=stateB, + settings=self.settings, + alchemical_components=alchemical_comps, + generation=0, repeat_id=i, + name=(f"Absolute Solvation, {alchname} solvent leg: " + f"repeat {i} generation 0"), + ) + for i in range(self.settings.alchemsampler_settings.n_repeats) + ] - return units + return solvent_units + vacuum_units + # TODO: update to match new unit list def _gather( self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] ) -> Dict[str, Any]: @@ -502,9 +493,9 @@ def _gather( } -class AbsoluteTransformUnit(gufe.ProtocolUnit): +class BaseAbsoluteTransformUnit(gufe.ProtocolUnit): """ - Calculates an alchemical absolute free energy transformation of a ligand. + Base class for ligand absolute free energy transformations. """ def __init__(self, *, stateA: ChemicalSystem, @@ -547,207 +538,6 @@ def __init__(self, *, generation=generation, ) - ParseCompRet = Tuple[ - Optional[SolventComponent], Optional[ProteinComponent], - Dict[str, OFFMol], - ] - - @staticmethod - def _parse_components(state: ChemicalSystem) -> ParseCompRet: - """ - Establish all necessary Components for the transformation. - - Parameters - ---------- - state : ChemicalSystem - Chemical system to get all necessary components from. - - Returns - ------- - solvent_comp : Optional[SolventComponent] - If it exists, the SolventComponent for the state, otherwise None. - protein_comp : Optional[ProteinComponent] - If it exists, the ProteinComponent for the state, otherwise None. - openff_mols : Dict[str, openff.toolkit.Molecule] - A dictionary of openff.toolkit Molecules for each - SmallMoleculeComponent in the input state keyed by the original - component name. - - Raises - ------ - ValueError - If there are more than one ProteinComponent - - TODO - ---- - * Fix things so that we can have multiple ProteinComponents - """ - # Is the system solvated? - solvent_comp = None - for comp in state.components.values(): - if isinstance(comp, SolventComponent): - solvent_comp = comp - - # Is it complexed? - # TODO: we intentionally crash if there's multiple proteins, fix this! - protein_comp = None - for comp in state.components.values(): - if isinstance(comp, ProteinComponent): - if protein_comp is not None: - errmsg = "Multiple proteins are not currently supported" - raise ValueError(errmsg) - protein_comp = comp - - # Get a dictionary of SmallMoleculeComponents as openff Molecules - off_small_mols = {} - for key, comp in state.components.items(): - if isinstance(comp, SmallMoleculeComponent): - off_small_mols[key] = comp.to_openff() - - return solvent_comp, protein_comp, off_small_mols - - @staticmethod - def _get_sim_steps(time: unit.Quantity, timestep: unit.Quantity, - mc_steps: int) -> unit.Quantity: - """ - Get and validate the number of simulation steps - - Parameters - ---------- - time : unit.Quantity - Simulation time in femtoseconds. - timestep : unit.Quantity - Simulation timestep in femtoseconds. - mc_steps : int - Number of integration steps between MC moves. - - Returns - ------- - steps : unit.Quantity - Total number of integration steps - - Raises - ------ - ValueError - If the number of steps is not divisible by the number of mc_steps. - """ - steps = round(time / timestep).m - - if (steps % mc_steps) != 0: # type: ignore - errmsg = (f"Simulation time {time} should contain a number of " - "steps divisible by the number of integrator " - f"timesteps between MC moves {mc_steps}") - raise ValueError(errmsg) - - return steps - - ModellerReturn = Tuple[app.Modeller, Dict[str, npt.NDArray]] - - @staticmethod - def _get_omm_modeller(protein_comp: Optional[ProteinComponent], - solvent_comp: Optional[SolventComponent], - off_mols: Dict[str, OFFMol], - omm_forcefield: app.ForceField, - solvent_settings: settings.SettingsBaseModel, - ) -> ModellerReturn: - """ - Generate an OpenMM Modeller class based on a potential input - ProteinComponent, and a set of openff molecules. - - Parameters - ---------- - protein_comp : Optional[ProteinComponent] - Protein Component, if it exists. - solvent_comp : Optional[ProteinCompoinent] - Solvent COmponent, if it exists. - off_mols : List[openff.toolkit.Molecule] - List of small molecules as OpenFF Molecule. - omm_forcefield : app.ForceField - ForceField object for system. - solvent_settings : settings.SettingsBaseModel - Solventation settings - - Returns - ------- - system_modeller : app.Modeller - OpenMM Modeller object generated from ProteinComponent and - OpenFF Molecules. - component_resids : Dict[str, npt.NDArray] - List of residue indices for each component in system. - """ - component_resids = {} - - def _add_small_mol(compkey: str, mol: OFFMol, - system_modeller: app.Modeller, - comp_resids: Dict[str, npt.NDArray]): - """ - Helper method to add off molecules to an existing Modeller - object and update a dictionary tracking residue indices - for each component. - """ - omm_top = mol.to_topology().to_openmm() - system_modeller.add( - omm_top, - ensure_quantity(mol.conformers[0], 'openmm') - ) - - nres = omm_top.getNumResidues() - resids = [res.index for res in system_modeller.topology.residues()] - comp_resids[key] = np.array(resids[-nres:]) - - # If there's a protein in the system, we add it first to Modeller - if protein_comp is not None: - system_modeller = app.Modeller(protein_comp.to_openmm_topology(), - protein_comp.to_openmm_positions()) - component_resids['protein'] = np.array( - [res.index for res in system_modeller.topology.residues()] - ) - - for key, mol in off_mols.items(): - _add_small_mol(key, mol, system_modeller, component_resids) - # Otherwise, we add the first small molecule, and then the rest - else: - mol_items = list(off_mols.items()) - - system_modeller = app.Modeller( - mol_items[0][1].to_topology().to_openmm(), - ensure_quantity(mol_items[0][1].conformers[0], 'openmm') - ) - component_resids[mol_items[0][0]] = np.array( - [res.index for res in system_modeller.topology.residues()] - ) - - for key, mol in mol_items[1:]: - _add_small_mol(key, mol, system_modeller, component_resids) - - # If there's solvent, add it and then set leftover resids to solvent - if solvent_comp is not None: - conc = solvent_comp.ion_concentration - pos = solvent_comp.positive_ion - neg = solvent_comp.negative_ion - - system_modeller.addSolvent( - omm_forcefield, - model=solvent_settings.solvent_model, - padding=to_openmm(solvent_settings.solvent_padding), - positiveIon=pos, negativeIon=neg, - ionicStrength=to_openmm(conc), - ) - - all_resids = np.array( - [res.index for res in system_modeller.topology.residues()] - ) - - existing_resids = np.concatenate( - [resids for resids in component_resids.values()] - ) - - component_resids['solvent'] = np.setdiff1d( - all_resids, existing_resids - ) - - return system_modeller, component_resids - @staticmethod def _get_alchemical_indices(omm_top: openmm.Topology, comp_resids: Dict[str, npt.NDArray], From 7d2193281d7ae89b28fbfb523f1b5706c296142d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 5 Jul 2023 20:02:14 +0100 Subject: [PATCH 31/74] base class refactor --- openfe/protocols/openmm_afe/__init__.py | 2 +- .../openmm_afe/solvation_afe_method.py | 197 ++++++++---------- 2 files changed, 90 insertions(+), 109 deletions(-) diff --git a/openfe/protocols/openmm_afe/__init__.py b/openfe/protocols/openmm_afe/__init__.py index 851859930..77bcfa71d 100644 --- a/openfe/protocols/openmm_afe/__init__.py +++ b/openfe/protocols/openmm_afe/__init__.py @@ -1,7 +1,7 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -from .equil_afe_methods import ( +from .solvation_afe_method import ( AbsoluteTransformProtocol, AbsoluteTransformSettings, AbsoluteTransformProtocolResult, diff --git a/openfe/protocols/openmm_afe/solvation_afe_method.py b/openfe/protocols/openmm_afe/solvation_afe_method.py index a37c8de65..91f32e9df 100644 --- a/openfe/protocols/openmm_afe/solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/solvation_afe_method.py @@ -610,6 +610,73 @@ def _pre_minimize(system: openmm.System, minimized_positions = state.getPositions(asNumpy=True) return minimized_positions + def _prepare(self, verbose, basepath): + if verbose: + logger.info("setting up alchemical system") + + # set basepath + if basepath is None: + self.basepath = pathlib.Path('.') + else: + self.basepath = basepath + + def _get_components(self): + """ + stateA = self._inputs['stateA'] + alchem_comps = self._inputs['alchemical_components'] + # Get the relevant solvent & protein components & openff molecules + solvent_comp, protein_comp, off_mols = self._parse_components(stateA) + """ + raise NotImplementedError + + def _get_settings(self): + """ + Settings may change depending on what type of simulation you are + running. Cherry pick them and return them to be available later on. + + Base case would be: + protocol_settings: RelativeHybridTopologyProtocolSettings = self._inputs['settings'] + + Also add: + # a. Validation checks + settings_validation.validate_timestep( + settings.forcefield_settings.hydrogen_mass, + settings.integrator_settings.timestep + ) + """ + raise NotImplementedError + + def _get_modeller(self, protein_component, solvent_component, + smc_components, system_generator, settings): + """ + # force the creation of parameters for the small molecules + # this is cached and shouldn't incur further cost + for mol in off_mols.values(): + system_generator.create_system(mol.to_topology().to_openmm(), + molecules=[mol]) + + # b. Get OpenMM Modller + a dictionary of resids for each component + system_modeller, comp_resids = self._get_omm_modeller( + protein_comp, solvent_comp, off_mols, system_generator.forcefield, + settings.solvent_settings + ) + + """ + ... + + def _get_omm_objects(self, ...): + """ + system_topology = system_modeller.getTopology() + + # roundtrip via off_units to canocalize + positions = to_openmm(from_openmm(system_modeller.getPositions())) + + omm_system = system_generator.create_system( + system_modeller.topology, + molecules=list(off_mols.values()) + ) + """ + def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: """Run the absolute free energy calculation. @@ -641,129 +708,43 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: List of OpenFF Molecule objects for each SmallMoleculeComponent in the stateA ChemicalSystem """ - if verbose: - logger.info("setting up alchemical system") + # 0. Generaly preparation tasks + self._prepare(verbose, basepath) - # Get basepath - if basepath is None: - # use cwd - basepath = pathlib.Path('.') + # 1. Get components + alchem_comps, solv_comp, prot_comp, smc_comps = self._get_components() - # 0. General setup and settings dependency resolution step + # 2. Get settings + settings = self._handle_settings() - # a. Establish chemical system and their components - stateA = self._inputs['stateA'] - alchem_comps = self._inputs['alchemical_components'] - # Get the relevant solvent & protein components & openff molecules - solvent_comp, protein_comp, off_mols = self._parse_components(stateA) - - # b. Establish integration nsettings - settings = self._inputs['settings'] - sim_settings = settings.simulation_settings - timestep = settings.integrator_settings.timestep - mc_steps = settings.integrator_settings.n_steps.m - equil_time = sim_settings.equilibration_length.to('femtosecond') - prod_time = sim_settings.production_length.to('femtosecond') - equil_steps = self._get_sim_steps(equil_time, timestep, mc_steps) - prod_steps = self._get_sim_steps(prod_time, timestep, mc_steps) - - # 1. Parameterise System - # a. Set up SystemGenerator object - ffsettings = settings.forcefield_settings - protein_ffs = ffsettings.forcefields - small_ffs = ffsettings.small_molecule_forcefield - - constraints = { - 'hbonds': app.HBonds, - 'none': None, - 'allbonds': app.AllBonds, - 'hangles': app.HAngles - # vvv can be None so string it - }[str(ffsettings.constraints).lower()] - - forcefield_kwargs = { - 'constraints': constraints, - 'rigidWater': ffsettings.rigid_water, - 'removeCMMotion': ffsettings.remove_com, - 'hydrogenMass': ffsettings.hydrogen_mass * omm_unit.amu, - } - - nonbonded_method = { - 'pme': app.PME, - 'nocutoff': app.NoCutoff, - 'cutoffnonperiodic': app.CutoffNonPeriodic, - 'cutoffperiodic': app.CutoffPeriodic, - 'ewald': app.Ewald - }[settings.system_settings.nonbonded_method.lower()] - - nonbonded_cutoff = to_openmm( - settings.system_settings.nonbonded_cutoff + # 3. Get system generator + system_generator = self._handle_system_generation( + settings, solv_comp ) - periodic_kwargs = { - 'nonbondedMethod': nonbonded_method, - 'nonbondedCutoff': nonbonded_cutoff, - } - - # Currently the else is a dead branch, we will want to investigate the - # possibility of using CutoffNonPeriodic at some point though (for RF) - if nonbonded_method is not app.CutoffNonPeriodic: - nonperiodic_kwargs = { - 'nonbondedMethod': app.NoCutoff, - } - else: # pragma: no-cover - nonperiodic_kwargs = periodic_kwargs - - system_generator = SystemGenerator( - forcefields=protein_ffs, - small_molecule_forcefield=small_ffs, - forcefield_kwargs=forcefield_kwargs, - nonperiodic_forcefield_kwargs=nonperiodic_kwargs, - periodic_forcefield_kwargs=periodic_kwargs, - cache=sim_settings.forcefield_cache, + # 4. Get modeller + system_modeller, comp_resids = self._get_modeller( + prot_comp, solv_comp, smc_comps, system_generator, + settings ) - # Add a barostat if necessary note, was broken pre 0.11.2 of openmmff - pressure = settings.thermo_settings.pressure - temperature = settings.thermo_settings.temperature - if solvent_comp is not None: - barostat = openmm.MonteCarloBarostat( - ensure_quantity(pressure, 'openmm'), - ensure_quantity(temperature, 'openmm') - ) - system_generator.barostat = barostat - - # force the creation of parameters for the small molecules - # this is cached and shouldn't incur further cost - for mol in off_mols.values(): - system_generator.create_system(mol.to_topology().to_openmm(), - molecules=[mol]) - - # b. Get OpenMM Modller + a dictionary of resids for each component - system_modeller, comp_resids = self._get_omm_modeller( - protein_comp, solvent_comp, off_mols, system_generator.forcefield, - settings.solvent_settings + # 5. Get OpenMM topology, positions and system + omm_topology, omm_system, positions = self._get_omm_objects( + system_generator, system_modeller, smc_comps ) - # c. Get OpenMM topology - system_topology = system_modeller.getTopology() - - # d. Get initial positions (roundtrip via off_units to canocalize) - positions = to_openmm(from_openmm(system_modeller.getPositions())) + # Probably will need to handle restraints somewhere here - # d. Create System - omm_system = system_generator.create_system( - system_modeller.topology, - molecules=list(off_mols.values()) - ) + # 6. Pre-minimize System (Test + Avoid NaNs) + positions = self._pre_minimize(omm_system, positions) + # 7. Create # e. Get a list of indices for the alchemical species + alchemical_indices = self._get_alchemical_indices( - system_topology, comp_resids, alchem_comps + omm_topology, comp_resids, alchem_comps ) - # 2. Pre-minimize System (Test + Avoid NaNs) - positions = self._pre_minimize(omm_system, positions) # 3. Create the alchemical system # a. Get alchemical settings From d0bdae5a6d1995c4206713a48cd4c085dfa6f94b Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 12 Jul 2023 22:48:05 +0100 Subject: [PATCH 32/74] partial changes - just so I can keep working from laptop --- ...ethod.py => equil_solvation_afe_method.py} | 132 ++++++++++++------ 1 file changed, 87 insertions(+), 45 deletions(-) rename openfe/protocols/openmm_afe/{solvation_afe_method.py => equil_solvation_afe_method.py} (95%) diff --git a/openfe/protocols/openmm_afe/solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py similarity index 95% rename from openfe/protocols/openmm_afe/solvation_afe_method.py rename to openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 91f32e9df..8d9f035b4 100644 --- a/openfe/protocols/openmm_afe/solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -144,7 +144,7 @@ Acknowledgements ---------------- -* Originally based on the hydration.py in +* Originally based on hydration.py in `espaloma `_ @@ -676,6 +676,84 @@ def _get_omm_objects(self, ...): molecules=list(off_mols.values()) ) """ + ... + + def _get_lambda_schedule(self, settings): + """ + # c. Create the lambda schedule + # TODO: do this properly using LambdaProtocol + # TODO: double check we definitely don't need to define + # temperature & pressure (pressure sure that's the case) + lambdas = dict() + n_elec = alchem_settings.lambda_elec_windows + n_vdw = alchem_settings.lambda_vdw_windows + 1 + lambdas['lambda_electrostatics'] = np.concatenate( + [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] + ) + lambdas['lambda_sterics'] = np.concatenate( + [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] + ) + + # d. Check that the lambda schedule matches n_replicas + # TODO: undo for SAMS + n_replicas = settings.alchemsampler_settings.n_replicas + + if n_replicas != (len(lambdas['lambda_sterics'])): + errmsg = (f"Number of replicas {n_replicas} " + "does not equal the number of lambda windows ") + raise ValueError(errmsg) + """ + ... + + def _get_alchemical_system(self, omm_topology, comp_resids, alchem_comps): + """ + alchemical_indices = self._get_alchemical_indices( + omm_topology, comp_resids, alchem_comps + ) + + + # 3. Create the alchemical system + # a. Get alchemical settings + alchem_settings = settings.alchemical_settings + + # b. Set the alchemical region & alchemical factory + # TODO: add support for all the variants here + # TODO: check that adding indices this way works + alchemical_region = AlchemicalRegion( + alchemical_atoms=alchemical_indices, + ) + alchemical_factory = AbsoluteAlchemicalFactory() + alchemical_system = alchemical_factory.create_alchemical_system( + omm_system, alchemical_region + ) + """ + ... + + def _get_states(self, ...): + """ + # 4. Create compound states + alchemical_state = AlchemicalState.from_system(alchemical_system) + constants = dict() + constants['temperature'] = ensure_quantity(temperature, 'openmm') + if solvent_comp is not None: + constants['pressure'] = ensure_quantity(pressure, 'openmm') + cmp_states = create_thermodynamic_state_protocol( + alchemical_system, + protocol=lambdas, + constants=constants, + composable_states=[alchemical_state], + ) + + # 5. Create the sampler states + # Fill up a list of sampler states all with the same starting state + sampler_state = SamplerState(positions=positions) + if omm_system.usesPeriodicBoundaryConditions(): + box = omm_system.getDefaultPeriodicBoxVectors() + sampler_state.box_vectors = box + + sampler_states = [sampler_state for _ in cmp_states] + """ + ... def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: """Run the absolute free energy calculation. @@ -718,9 +796,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: settings = self._handle_settings() # 3. Get system generator - system_generator = self._handle_system_generation( - settings, solv_comp - ) + system_generator = self._handle_system_generation(settings, solv_comp) # 4. Get modeller system_modeller, comp_resids = self._get_modeller( @@ -738,51 +814,17 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # 6. Pre-minimize System (Test + Avoid NaNs) positions = self._pre_minimize(omm_system, positions) - # 7. Create - # e. Get a list of indices for the alchemical species - - alchemical_indices = self._get_alchemical_indices( - omm_topology, comp_resids, alchem_comps - ) - + # 7. Get lambdas + lambdas = self._get_lambda_schedule(settings) - # 3. Create the alchemical system - # a. Get alchemical settings - alchem_settings = settings.alchemical_settings - - # b. Set the alchemical region & alchemical factory - # TODO: add support for all the variants here - # TODO: check that adding indices this way works - alchemical_region = AlchemicalRegion( - alchemical_atoms=alchemical_indices, - ) - alchemical_factory = AbsoluteAlchemicalFactory() - alchemical_system = alchemical_factory.create_alchemical_system( - omm_system, alchemical_region + # 8. Get alchemical system + alchem_system, alchem_factory = self._get_alchemical_system( + omm_topology, comp_resids, alchem_comps ) - # c. Create the lambda schedule - # TODO: do this properly using LambdaProtocol - # TODO: double check we definitely don't need to define - # temperature & pressure (pressure sure that's the case) - lambdas = dict() - n_elec = alchem_settings.lambda_elec_windows - n_vdw = alchem_settings.lambda_vdw_windows + 1 - lambdas['lambda_electrostatics'] = np.concatenate( - [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] + cmp_states, sampler_states = self._get_states( + alchem_system, solvent_comp, settings ) - lambdas['lambda_sterics'] = np.concatenate( - [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] - ) - - # d. Check that the lambda schedule matches n_replicas - # TODO: undo for SAMS - n_replicas = settings.alchemsampler_settings.n_replicas - - if n_replicas != (len(lambdas['lambda_sterics'])): - errmsg = (f"Number of replicas {n_replicas} " - "does not equal the number of lambda windows ") - raise ValueError(errmsg) # 4. Create compound states alchemical_state = AlchemicalState.from_system(alchemical_system) From af3b60f9dd1529c18f8279afbe9dfe26eac75640 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 13 Jul 2023 06:58:06 +0100 Subject: [PATCH 33/74] some more interim changes --- .../openmm_afe/equil_solvation_afe_method.py | 235 +++++++++++------- 1 file changed, 141 insertions(+), 94 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 8d9f035b4..342a160c9 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -610,7 +610,18 @@ def _pre_minimize(system: openmm.System, minimized_positions = state.getPositions(asNumpy=True) return minimized_positions - def _prepare(self, verbose, basepath): + def _prepare(self, verbose: bool, basepath: Optional[pathlib.Path]): + """ + Set basepaths and do some initial logging. + + Parameters + ---------- + verbose : bool + Verbose output of the simulation progress. Output is provided via + INFO level logging. + basepath : Optional[pathlib.Path] + Optional base path to write files to. + """ if verbose: logger.info("setting up alchemical system") @@ -622,6 +633,20 @@ def _prepare(self, verbose, basepath): def _get_components(self): """ + Get the relevant components to create the alchemical system with. + + Note + ---- + Must be implemented in child class. + + Returns + ------- + alchem_comps : .. + solv_comp : .. + prot_comp : .. + smc_comps : .. + + To move: stateA = self._inputs['stateA'] alchem_comps = self._inputs['alchemical_components'] # Get the relevant solvent & protein components & openff molecules @@ -755,100 +780,8 @@ def _get_states(self, ...): """ ... - def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: - """Run the absolute free energy calculation. - - Parameters - ---------- - dry : bool - Do a dry run of the calculation, creating all necessary alchemical - system components (topology, system, sampler, etc...) but without - running the simulation. - verbose : bool - Verbose output of the simulation progress. Output is provided via - INFO level logging. - basepath : Pathlike, optional - Where to run the calculation, defaults to current working directory - - Returns - ------- - dict - Outputs created in the basepath directory or the debug objects - (i.e. sampler) if ``dry==True``. - - Attributes - ---------- - solvent : Optional[SolventComponent] - SolventComponent to be applied to the system - protein : Optional[ProteinComponent] - ProteinComponent for the system - openff_mols : List[openff.Molecule] - List of OpenFF Molecule objects for each SmallMoleculeComponent in - the stateA ChemicalSystem + def _get_reporter(self, ...): """ - # 0. Generaly preparation tasks - self._prepare(verbose, basepath) - - # 1. Get components - alchem_comps, solv_comp, prot_comp, smc_comps = self._get_components() - - # 2. Get settings - settings = self._handle_settings() - - # 3. Get system generator - system_generator = self._handle_system_generation(settings, solv_comp) - - # 4. Get modeller - system_modeller, comp_resids = self._get_modeller( - prot_comp, solv_comp, smc_comps, system_generator, - settings - ) - - # 5. Get OpenMM topology, positions and system - omm_topology, omm_system, positions = self._get_omm_objects( - system_generator, system_modeller, smc_comps - ) - - # Probably will need to handle restraints somewhere here - - # 6. Pre-minimize System (Test + Avoid NaNs) - positions = self._pre_minimize(omm_system, positions) - - # 7. Get lambdas - lambdas = self._get_lambda_schedule(settings) - - # 8. Get alchemical system - alchem_system, alchem_factory = self._get_alchemical_system( - omm_topology, comp_resids, alchem_comps - ) - - cmp_states, sampler_states = self._get_states( - alchem_system, solvent_comp, settings - ) - - # 4. Create compound states - alchemical_state = AlchemicalState.from_system(alchemical_system) - constants = dict() - constants['temperature'] = ensure_quantity(temperature, 'openmm') - if solvent_comp is not None: - constants['pressure'] = ensure_quantity(pressure, 'openmm') - cmp_states = create_thermodynamic_state_protocol( - alchemical_system, - protocol=lambdas, - constants=constants, - composable_states=[alchemical_state], - ) - - # 5. Create the sampler states - # Fill up a list of sampler states all with the same starting state - sampler_state = SamplerState(positions=positions) - if omm_system.usesPeriodicBoundaryConditions(): - box = omm_system.getDefaultPeriodicBoxVectors() - sampler_state.box_vectors = box - - sampler_states = [sampler_state for _ in cmp_states] - - # 6. Create the multistate reporter # a. Get the sub selection of the system to print coords for mdt_top = mdt.Topology.from_openmm(system_topology) selection_indices = mdt_top.select( @@ -862,7 +795,11 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: checkpoint_interval=sim_settings.checkpoint_interval.m, checkpoint_storage=basepath / sim_settings.checkpoint_storage, ) + """ + ... + def _get_ctx_caches(self, ...): + """ # 7. Get platform and context caches platform = compute.get_openmm_platform( settings.engine_settings.compute_platform @@ -877,7 +814,11 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: sampler_context_cache = openmmtools.cache.ContextCache( capacity=None, time_to_live=None, platform=platform, ) + """ + ... + def _get_integrator(self, integrator_settings): + """ # 8. Set the integrator # a. get integrator settings integrator_settings = settings.integrator_settings @@ -892,7 +833,11 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: constraint_tolerance=integrator_settings.constraint_tolerance, splitting=integrator_settings.splitting ) + """ + ... + def _get_sampler(self, ...): + """ # 9. Create sampler sampler_settings = settings.alchemsampler_settings @@ -929,7 +874,11 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: sampler.energy_context_cache = energy_context_cache sampler.sampler_context_cache = sampler_context_cache + """ + ... + def _run_simulation(self, ...): + """ if not dry: # pragma: no-cover # minimize if verbose: @@ -970,6 +919,104 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: for fn in fns: os.remove(fn) return {'debug': {'sampler': sampler}} + """ + ... + + def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: + """Run the absolute free energy calculation. + + Parameters + ---------- + dry : bool + Do a dry run of the calculation, creating all necessary alchemical + system components (topology, system, sampler, etc...) but without + running the simulation. + verbose : bool + Verbose output of the simulation progress. Output is provided via + INFO level logging. + basepath : Pathlike, optional + Where to run the calculation, defaults to current working directory + + Returns + ------- + dict + Outputs created in the basepath directory or the debug objects + (i.e. sampler) if ``dry==True``. + + Attributes + ---------- + solvent : Optional[SolventComponent] + SolventComponent to be applied to the system + protein : Optional[ProteinComponent] + ProteinComponent for the system + openff_mols : List[openff.Molecule] + List of OpenFF Molecule objects for each SmallMoleculeComponent in + the stateA ChemicalSystem + """ + # 0. Generaly preparation tasks + self._prepare(verbose, basepath) + + # 1. Get components + alchem_comps, solv_comp, prot_comp, smc_comps = self._get_components() + + # 2. Get settings + settings = self._handle_settings() + + # 3. Get system generator + system_generator = self._handle_system_generation(settings, solv_comp) + + # 4. Get modeller + system_modeller, comp_resids = self._get_modeller( + prot_comp, solv_comp, smc_comps, system_generator, + settings + ) + + # 5. Get OpenMM topology, positions and system + omm_topology, omm_system, positions = self._get_omm_objects( + system_generator, system_modeller, smc_comps + ) + + # Probably will need to handle restraints somewhere here + + # 6. Pre-minimize System (Test + Avoid NaNs) + positions = self._pre_minimize(omm_system, positions) + + # 7. Get lambdas + lambdas = self._get_lambda_schedule(settings) + + # 8. Get alchemical system + alchem_system, alchem_factory = self._get_alchemical_system( + omm_topology, comp_resids, alchem_comps + ) + + # 9. Get compound and sampler states + cmp_states, sampler_states = self._get_states( + alchem_system, solvent_comp, settings + ) + + # 10. Create the multistate reporter & create PDB + reporter = self._get_reporter( + omm_topology, settings.simulation_setttings + ) + + # 11. Get context caches + energy_ctx_cache, sampler_ctx_cache = self._get_ctx_caches( + settings.engine_settings + ) + + # 12. Get integrator + integrator = self._get_integrator(settings.integrator_settings) + + # 13. Get sampler + sampler = self._get_sampler( + integrator, settings.sampler_settings, cmp_states, sampler_states, + reporter, energy_ctx_cache, sampler_ctx_cache + ) + + # 14. Run simulation + self._run_simulation( + dry, verbose, sampler, reporter, settings.simulation_settings + ) def _execute( self, ctx: gufe.Context, **kwargs, From 12792e9bc00e9ab53b8f82c0d5ce6e55bf42ffb1 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 9 Sep 2023 08:59:53 +0100 Subject: [PATCH 34/74] towards refactor --- .../openmm_afe/equil_solvation_afe_method.py | 299 +++++++----------- 1 file changed, 122 insertions(+), 177 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 342a160c9..87d6d00a7 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -1,142 +1,20 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -"""OpenMM Equilibrium AFE Protocol --- :mod:`openfe.protocols.openmm_afe.equil_afe_methods` -=========================================================================================== +"""OpenMM Equilibrium Solvation AFE Protocol --- :mod:`openfe.protocols.openmm_afe.equil_solvation_afe_methods` +=============================================================================================================== -This module implements the necessary methodology toolking to run calculate an -absolute free energy transformation using OpenMM tools and one of the -following alchemical sampling methods: +This module implements the necessary methodology tooling to run calculate an +absolute solvation free energy using OpenMM tools and one of the following +alchemical sampling methods: * Hamiltonian Replica Exchange * Self-adjusted mixture sampling * Independent window sampling - -.. versionadded:: 0.10.2 - - -Running a Solvation Free Energy Calculation -------------------------------------------- - -One use case of this Protocol is to carry out absolute solvation free energy -calculations. This involves capturing the free energy cost associated with -taking a small molecule from a solvent environment to gas phase. - -In practice, because OpenMM currently only allows for charge annhilation when -using an exact treatment of charges using PME, this ends up requiring two -transformations. The first is carried out in solvent, where we annhilate the -charges of the ligand, and then decouple the LJ interactions. The second is -done in gas phase and involves recharging the ligand. - -Here we provide a short overview on how such a thermodynamic cycle would be -achieved using this protocol. - -Assuming we have a ligand of interest contained within an SDF file named -`ligands.sdf`, we can start by loading it into a SmallMoleculeComponent. - - -.. code-block:: - - from gufe import SmallMoleculeComponent - - mol = SmallMoleculeComponent.from_sdf_file('ligand.sdf') - - -With this, we can next create ChemicalSystem objects for the four -different end states of our thermodynamic cycle. - - -.. code-block:: - - from gufe import ChemicalSystem - - # State with a ligand in solvent - ligand_solvent = ChemicalSystem({ - 'ligand': mol, 'solvent': SolventComponent() - }) - - # State with only solvent - solvent = ChemicalSystem({'solvent': SolventComponent()}) - - # State with only the ligand in gas phase - ligand_gas = ChemicalSystem({'ligand': mol}) - - # State that is purely gas phase - gas = ChemicalSystem({'ligand': mol}) - - -Next we generate settings to run both solvent and gas phase transformations. -Aside form unique file names for the trajectory & checkpoint files, the main -difference in the settings is that we have to set the nonbonded method to be -`nocutoff` for gas phase and `pme` (the default) for periodic solvated systems. -Note: for everything else we use the default settings, howeve rin practice you -may find that much shorter simulation times may be adequate for gas phase -simulations. - - -.. code-block:: - - solvent_settings = AbsoluteTransformProtocol._default_settings() - solvent_settings.simulation_settings.output_filename = "ligand_solvent.nc" - solvent_settings.simulation_settings.checkpoint_storage = "ligand_solvent_checkpoint.nc" - - gas_setttings = AbsoluteTransformProtocol._default_settings() - gas_settings.simulation_settings.output_filename = "ligand_gas.nc" - gas_settings.simulation_settings.checkpoint_storage = "ligand_gas_checkpoint.nc" - - # By default the nonbonded method is PME, this needs to be nocutoff for gas phase - gas_settings.system_settings.nonbonded_method = 'nocutoff' - - -With this, we can create protocols and simulation DAGs for each leg of the -cycle. We pass to create the corresponding chemical end states of each leg -(e.g. ligand in solvent and pure solvent for the solvent transformation leg) -We note that no mapping is passed through to the Protocol. The Protocol -automatically compares the components present in the ChemicalSystems passed to -stateA and stateB and identifies any components missing either either of the -end states as undergoing an alchemical transformation. - - -.. code-block:: - - solvent_transform = AbsoluteTransformProtocol(settings=solvent_settings) - solvent_dag = solvent_transform.create(stateA=ligand_solvent, stateB=solvent, mapping=None) - gas_transform = AbsoluteTransformProtocol(settings=gas_settings) - gas_dag = solvent_transform.create(stateA=ligand_gas, stateB=gas, mapping=None) - - -Next we execute the transformations. By default, this will simulate 3 repeats -of both the ligand in solvent and ligand in gas transformations. Note: this -will take a while to run. - - -.. code-block:: - - from gufe.protocols import execute_DAG - solvent_data = execute_DAG(solvent_dag, shared='./solvent') - gas_data = execute_DAG(gas_dag, shared='./gas') - - -Once completed, we gather the results and then get our estimate as the -difference between the gas and solvent transformations. - - -.. code-block:: - - solvent_results = solvent_transform.gather([solvent_data,]) - gas_results = gas_transform.gather([gas_data,]) - dG = gas_results.get_estimate() - solvent_results.get_estimate() - print(dG) - - Current limitations ------------------- * Disapearing molecules are only allowed in state A. Support for appearing molecules will be added in due course. -* Only one protein component is allowed per state. We ask that, - users input all molecules intended to use an additive force field - in the one ProteinComponent. This will likely change once OpenFF - rosemary is released. * Only small molecules are allowed to act as alchemical molecules. Alchemically changing protein or solvent components would induce perturbations which are too large to be handled by this Protocol. @@ -203,7 +81,6 @@ logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) class AbsoluteTransformProtocolResult(gufe.ProtocolResult): @@ -610,7 +487,11 @@ def _pre_minimize(system: openmm.System, minimized_positions = state.getPositions(asNumpy=True) return minimized_positions - def _prepare(self, verbose: bool, basepath: Optional[pathlib.Path]): + def _prepare( + self, verbose: bool, + scratch_basepath: Optional[pathlib.Path], + shared_basepath: Optional[pathlib.Path], + ): """ Set basepaths and do some initial logging. @@ -622,14 +503,19 @@ def _prepare(self, verbose: bool, basepath: Optional[pathlib.Path]): basepath : Optional[pathlib.Path] Optional base path to write files to. """ - if verbose: + self.verbose = verbose + + if self.verbose: logger.info("setting up alchemical system") - # set basepath - if basepath is None: - self.basepath = pathlib.Path('.') - else: - self.basepath = basepath + # set basepaths + def _set_optional_path(basepath): + if basepath is None: + return pathlib.Path('.') + return basepath + + self.scratch_basepath = _set_optional_path(scratch_basepath) + self.shared_basepath = _set_optional_path(shared_basepath) def _get_components(self): """ @@ -671,6 +557,28 @@ def _get_settings(self): """ raise NotImplementedError + def _get_system_generator(self, settings, solvent_comp): + """ + Get a system generator through the system creation + utilities + + Parameters + ---------- + settings : + solv_comp : + """ + ffcache = settings.simulation_settings.forcefield_cache + if ffcache is not None: + ffcache = self.shared_basepath / ffcache + + system_generator = system_creation.get_system_generator( + forcefield_settings=settings.forcefield_settings, + thermo_settings=settings.thermo_settings, + cache=ffcache, + has_solvent=solvent_comp is not None, + ) + return system_generator + def _get_modeller(self, protein_component, solvent_component, smc_components, system_generator, settings): """ @@ -687,9 +595,33 @@ def _get_modeller(self, protein_component, solvent_component, ) """ - ... + if self.verbose: + logger.info("Parameterizing molecules") + + # force the creation of parameters for the small molecules + # this is necessary because we need to have the FF generated ahead + # of solvating the system. + # Note by default this is cached to ctx.shared/db.json which should + # reduce some of the costs. + for comp in smc_components: + offmol = comp.to_openff() + system_generator.create_system( + offmol.to_topology().to_openmm(), molecules=[offmol] + ) + + # get OpenMM modeller + dictionary of resids for each component + system_modeller, comp_resids = system_creation.get_omm_modeller( + protein_comp=protein_component, + solvent_comp=solvent_component, + small_mols=smc_components, + omm_forcefield=system_generator.forcefield, + solvent_settings=settings.solvation_settings, + ) + + return system_modeller, comp_resids - def _get_omm_objects(self, ...): + def _get_omm_objects(self, system_modeller, system_generator, + smc_components): """ system_topology = system_modeller.getTopology() @@ -701,17 +633,25 @@ def _get_omm_objects(self, ...): molecules=list(off_mols.values()) ) """ - ... + topology = system_modeller.getTopology() + # roundtrip positions to remove vec3 issues + positions = to_openmm(from_openmm(system_modeller.getPositions())) + system = system_generator.create_system( + system_modeller.topology, + molecules=[s.to_openff() for s in smc_components] + ) + return topology, positions, system def _get_lambda_schedule(self, settings): """ - # c. Create the lambda schedule - # TODO: do this properly using LambdaProtocol - # TODO: double check we definitely don't need to define - # temperature & pressure (pressure sure that's the case) + Create the lambda schedule + TODO: do this properly using LambdaProtocol + TODO: double check we definitely don't need to define + temperature & pressure (pressure sure that's the case) + """ lambdas = dict() - n_elec = alchem_settings.lambda_elec_windows - n_vdw = alchem_settings.lambda_vdw_windows + 1 + n_elec = settings.alchemical_settings.lambda_elec_windows + n_vdw = settings.alchemical_settings.lambda_vdw_windows + 1 lambdas['lambda_electrostatics'] = np.concatenate( [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] ) @@ -719,66 +659,61 @@ def _get_lambda_schedule(self, settings): [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] ) - # d. Check that the lambda schedule matches n_replicas - # TODO: undo for SAMS - n_replicas = settings.alchemsampler_settings.n_replicas + n_replicas = settings.alchemical_sampler_settings.n_replicas if n_replicas != (len(lambdas['lambda_sterics'])): errmsg = (f"Number of replicas {n_replicas} " "does not equal the number of lambda windows ") raise ValueError(errmsg) - """ - ... + + return lambdas - def _get_alchemical_system(self, omm_topology, comp_resids, alchem_comps): + def _get_alchemical_system(self, topology, system, comp_resids, + alchem_comps): + """ + # TODO: add support for all the variants here + # TODO: check that adding indices this way works """ alchemical_indices = self._get_alchemical_indices( - omm_topology, comp_resids, alchem_comps + topology, comp_resids, alchem_comps ) - - # 3. Create the alchemical system - # a. Get alchemical settings - alchem_settings = settings.alchemical_settings - - # b. Set the alchemical region & alchemical factory - # TODO: add support for all the variants here - # TODO: check that adding indices this way works alchemical_region = AlchemicalRegion( - alchemical_atoms=alchemical_indices, + alchemical_atoms=alchemical_indices, ) + alchemical_factory = AbsoluteAlchemicalFactory() alchemical_system = alchemical_factory.create_alchemical_system( - omm_system, alchemical_region + system, alchemical_region ) - """ - ... - def _get_states(self, ...): + return alchemical_factory, alchemical_system, alchemical_indices + + def _get_states(self, alchemical_system, positions, settings, lambdas, solvent_comp): + """ """ - # 4. Create compound states alchemical_state = AlchemicalState.from_system(alchemical_system) + # Set up the system constants + temperature = settings.thermo_settings.temperature + pressure = settings.thermo_settings.pressure constants = dict() constants['temperature'] = ensure_quantity(temperature, 'openmm') if solvent_comp is not None: constants['pressure'] = ensure_quantity(pressure, 'openmm') + cmp_states = create_thermodynamic_state_protocol( - alchemical_system, - protocol=lambdas, - constants=constants, - composable_states=[alchemical_state], + alchemical_system, protocol=lambdas, + consatnts=constants, composable_states=[alchemical_state], ) - # 5. Create the sampler states - # Fill up a list of sampler states all with the same starting state sampler_state = SamplerState(positions=positions) - if omm_system.usesPeriodicBoundaryConditions(): - box = omm_system.getDefaultPeriodicBoxVectors() + if alchemical_system.usesPeriodicBoundaryConditions(): + box = alchemical_system.getDefaultPeriodicBoxVectors() sampler_state.box_vectors = box sampler_states = [sampler_state for _ in cmp_states] - """ - ... + + return sampler_states, cmp_states def _get_reporter(self, ...): """ @@ -796,7 +731,17 @@ def _get_reporter(self, ...): checkpoint_storage=basepath / sim_settings.checkpoint_storage, ) """ - ... + mdt_top = mdt.Topology.from_openmm(system_topology) + selection_indices = mdt_top.select( + sim_settings.output_indices + ) + sim_settings = settings.simulation_settings + reporter = multistate.MultiStateReporter( + storage=self.shared_basepathbasepath / sim_settings.output_filename, + analysis_particle_indices=selection_indices, + checkpoint_interval=sim_settings.checkpoint_interval.m, + checkpoint_storage=basepath / sim_settings.checkpoint_storage, + ) def _get_ctx_caches(self, ...): """ @@ -963,7 +908,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: settings = self._handle_settings() # 3. Get system generator - system_generator = self._handle_system_generation(settings, solv_comp) + system_generator = self._get_system_generator(settings, solv_comp) # 4. Get modeller system_modeller, comp_resids = self._get_modeller( From a624477f234299da73bfa87ef021eaa1b98c4db8 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 5 Oct 2023 13:43:48 +0100 Subject: [PATCH 35/74] more refactoring --- .../openmm_afe/equil_solvation_afe_method.py | 101 ++++++++---------- 1 file changed, 45 insertions(+), 56 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 87d6d00a7..a06613270 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -668,6 +668,12 @@ def _get_lambda_schedule(self, settings): return lambdas + def _add_restraints(self, system, topology, settings): + """ + Placeholder method to add restraints if necessary + """ + return + def _get_alchemical_system(self, topology, system, comp_resids, alchem_comps): """ @@ -715,76 +721,60 @@ def _get_states(self, alchemical_system, positions, settings, lambdas, solvent_c return sampler_states, cmp_states - def _get_reporter(self, ...): + def _get_reporter(self, topology, simulation_settings): """ - # a. Get the sub selection of the system to print coords for - mdt_top = mdt.Topology.from_openmm(system_topology) - selection_indices = mdt_top.select( - sim_settings.output_indices - ) - - # b. Create the multistate reporter - reporter = multistate.MultiStateReporter( - storage=basepath / sim_settings.output_filename, - analysis_particle_indices=selection_indices, - checkpoint_interval=sim_settings.checkpoint_interval.m, - checkpoint_storage=basepath / sim_settings.checkpoint_storage, - ) """ - mdt_top = mdt.Topology.from_openmm(system_topology) + mdt_top = mdt.Topology.from_openmm(topology) + selection_indices = mdt_top.select( - sim_settings.output_indices + simulation_settings.output_indices ) - sim_settings = settings.simulation_settings + reporter = multistate.MultiStateReporter( - storage=self.shared_basepathbasepath / sim_settings.output_filename, + storage=self.shared_basepathbasepath / simulation_settings.output_filename, analysis_particle_indices=selection_indices, - checkpoint_interval=sim_settings.checkpoint_interval.m, - checkpoint_storage=basepath / sim_settings.checkpoint_storage, + checkpoint_interval=simultation_settings.checkpoint_interval.m, + checkpoint_storage=basepath / simultation_settings.checkpoint_storage, ) - def _get_ctx_caches(self, ...): + return reporter + + def _get_ctx_caches(self, engine_settings): + """ """ - # 7. Get platform and context caches platform = compute.get_openmm_platform( - settings.engine_settings.compute_platform + engine_settings.compute_platform, ) - # a. Create context caches (energy + sampler) - # Note: these needs to exist on the compute node energy_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_live=None, platform=platform, + capacity=None, time_to_line=None, platform=platform, ) sampler_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_live=None, platform=platform, + capacity=None, time_to_line=None, platform=platform, ) - """ - ... + + return energy_context_cache, sampler_context_cache def _get_integrator(self, integrator_settings): """ - # 8. Set the integrator - # a. get integrator settings - integrator_settings = settings.integrator_settings - - # b. create langevin integrator - integrator = openmmtools.mcmc.LangevinSplittingDynamicsMove( + """ + integrator = openmmtools.mcmc.LangevinDynamicsMove( timestep=to_openmm(integrator_settings.timestep), collision_rate=to_openmm(integrator_settings.collision_rate), n_steps=integrator_settings.n_steps.m, reassign_velocities=integrator_settings.reassign_velocities, n_restart_attempts=integrator_settings.n_restart_attempts, constraint_tolerance=integrator_settings.constraint_tolerance, - splitting=integrator_settings.splitting ) - """ - ... - def _get_sampler(self, ...): + return integrator + + + def _get_sampler(self, integrator, alchemsampler_settings, + energy_context_cache, sampler_context_cache): + """ """ - # 9. Create sampler - sampler_settings = settings.alchemsampler_settings # Select the right sampler # Note: doesn't need else, settings already validates choices @@ -819,11 +809,11 @@ def _get_sampler(self, ...): sampler.energy_context_cache = energy_context_cache sampler.sampler_context_cache = sampler_context_cache - """ - ... - def _run_simulation(self, ...): - """ + return sampler + + def _run_simulation(self, sampler, dry, minimization_steps, equil_steps, + prod_steps, mc_steps, output_filename, checkpoint_storage): if not dry: # pragma: no-cover # minimize if verbose: @@ -864,8 +854,6 @@ def _run_simulation(self, ...): for fn in fns: os.remove(fn) return {'debug': {'sampler': sampler}} - """ - ... def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: """Run the absolute free energy calculation. @@ -921,44 +909,45 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: system_generator, system_modeller, smc_comps ) - # Probably will need to handle restraints somewhere here - # 6. Pre-minimize System (Test + Avoid NaNs) positions = self._pre_minimize(omm_system, positions) # 7. Get lambdas lambdas = self._get_lambda_schedule(settings) - # 8. Get alchemical system + # 8. Add restraints + self._add_restraints(omm_system, omm_topology, settings) + + # 9. Get alchemical system alchem_system, alchem_factory = self._get_alchemical_system( omm_topology, comp_resids, alchem_comps ) - # 9. Get compound and sampler states + # 10. Get compound and sampler states cmp_states, sampler_states = self._get_states( alchem_system, solvent_comp, settings ) - # 10. Create the multistate reporter & create PDB + # 11. Create the multistate reporter & create PDB reporter = self._get_reporter( omm_topology, settings.simulation_setttings ) - # 11. Get context caches + # 12. Get context caches energy_ctx_cache, sampler_ctx_cache = self._get_ctx_caches( settings.engine_settings ) - # 12. Get integrator + # 13. Get integrator integrator = self._get_integrator(settings.integrator_settings) - # 13. Get sampler + # 14. Get sampler sampler = self._get_sampler( integrator, settings.sampler_settings, cmp_states, sampler_states, reporter, energy_ctx_cache, sampler_ctx_cache ) - # 14. Run simulation + # 15. Run simulation self._run_simulation( dry, verbose, sampler, reporter, settings.simulation_settings ) From f342a75261d904aed99ffcb79f374a0bd97eea01 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 5 Oct 2023 21:00:10 +0100 Subject: [PATCH 36/74] more refactor --- .../openmm_afe/equil_solvation_afe_method.py | 427 ++++++++++++++---- 1 file changed, 335 insertions(+), 92 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index a06613270..df52a0bf6 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -418,7 +418,7 @@ def __init__(self, *, @staticmethod def _get_alchemical_indices(omm_top: openmm.Topology, comp_resids: Dict[str, npt.NDArray], - alchem_comps: Dict[str, List[str]] + alchem_comps: Dict[str, List[Component]] ) -> List[int]: """ Get a list of atom indices for all the alchemical species @@ -429,7 +429,7 @@ def _get_alchemical_indices(omm_top: openmm.Topology, Topology of OpenMM System. comp_resids : Dict[str, npt.NDArray] A dictionary of residues for each component in the System. - alchem_comps : Dict[str, List[str]] + alchem_comps : Dict[str, List[Component]] A dictionary of alchemical components for each end state. Return @@ -506,7 +506,7 @@ def _prepare( self.verbose = verbose if self.verbose: - logger.info("setting up alchemical system") + self.logger.info("setting up alchemical system") # set basepaths def _set_optional_path(basepath): @@ -525,13 +525,6 @@ def _get_components(self): ---- Must be implemented in child class. - Returns - ------- - alchem_comps : .. - solv_comp : .. - prot_comp : .. - smc_comps : .. - To move: stateA = self._inputs['stateA'] alchem_comps = self._inputs['alchemical_components'] @@ -540,15 +533,24 @@ def _get_components(self): """ raise NotImplementedError - def _get_settings(self): + def _handle_settings(self): """ + Get a dictionary with the following entries: + * forcefield_settings : OpenMMSystemGeneratorFFSettings + * thermo_settings : ThermoSettings + * system_settings : SystemSettings + * solvation_settings : SolvationSettings + * alchemical_settings : AlchemicalSettings + * sampler_settings : AlchemicalSamplerSettings + * engine_settings : OpenMMEngineSettings + * integrator_settings : IntegratorSettings + * simulation_settings : SimulationSettings + Settings may change depending on what type of simulation you are running. Cherry pick them and return them to be available later on. - Base case would be: - protocol_settings: RelativeHybridTopologyProtocolSettings = self._inputs['settings'] + This method should also add various validation checks as necessary. - Also add: # a. Validation checks settings_validation.validate_timestep( settings.forcefield_settings.hydrogen_mass, @@ -557,46 +559,72 @@ def _get_settings(self): """ raise NotImplementedError - def _get_system_generator(self, settings, solvent_comp): + def _get_system_generator( + self, settings: dict[str, SettingsBaseModel], + solvent_comp: Optional[SolventComponent]) -> SystemGenerator: """ Get a system generator through the system creation utilities Parameters ---------- - settings : - solv_comp : + settings : dict[str, SettingsBaseModel] + A dictionary of settings object for the unit. + solvent_comp : Optional[SolventComponent] + The solvent component of this system, if there is one. + + Returns + ------- + system_generator : openmmforcefields.generator.SystemGenerator + System Generator to parameterise this unit. """ - ffcache = settings.simulation_settings.forcefield_cache + ffcache = settings['simulation_settings'].forcefield_cache if ffcache is not None: ffcache = self.shared_basepath / ffcache system_generator = system_creation.get_system_generator( - forcefield_settings=settings.forcefield_settings, - thermo_settings=settings.thermo_settings, + forcefield_settings=settings['forcefield_settings'], + thermo_settings=settings['thermo_settings'], cache=ffcache, has_solvent=solvent_comp is not None, ) return system_generator - def _get_modeller(self, protein_component, solvent_component, - smc_components, system_generator, settings): + def _get_modeller( + self, + protein_component: Optional[ProteinComponent], + solvent_component: Optional[SolventComponent], + smc_components: list[SmallMoleculeComponent], + system_generator: SystemGenerator, + solvation_settings: SolvationSettings + ) -> tuple[app.Modeller, dict[Component, npt.NDArray]]: """ - # force the creation of parameters for the small molecules - # this is cached and shouldn't incur further cost - for mol in off_mols.values(): - system_generator.create_system(mol.to_topology().to_openmm(), - molecules=[mol]) - - # b. Get OpenMM Modller + a dictionary of resids for each component - system_modeller, comp_resids = self._get_omm_modeller( - protein_comp, solvent_comp, off_mols, system_generator.forcefield, - settings.solvent_settings - ) + Get an OpenMM Modeller object and a list of residue indices + for each component in the system. + + Parameters + ---------- + protein_component : Optional[ProteinComponent] + Protein Component, if it exists. + solvent_component : Optional[ProteinCompoinent] + Solvent Component, if it exists. + smc_components : list[SmallMoleculeComponents] + List of SmallMoleculeComponents to add. + system_generator : openmmforcefields.generator.SystemGenerator + System Generator to parameterise this unit. + solvation_settings : SolvationSettings + Settings detailing how to solvate the system. + Returns + ------- + system_modeller : app.Modeller + OpenMM Modeller object generated from ProteinComponent and + OpenFF Molecules. + comp_resids : dict[Component, npt.NDArray] + Dictionary of residue indices for each component in system. """ if self.verbose: - logger.info("Parameterizing molecules") + self.logger.info("Parameterizing molecules") # force the creation of parameters for the small molecules # this is necessary because we need to have the FF generated ahead @@ -615,23 +643,39 @@ def _get_modeller(self, protein_component, solvent_component, solvent_comp=solvent_component, small_mols=smc_components, omm_forcefield=system_generator.forcefield, - solvent_settings=settings.solvation_settings, + solvent_settings=solvation_settings, ) return system_modeller, comp_resids - def _get_omm_objects(self, system_modeller, system_generator, - smc_components): + def _get_omm_objects( + self, + system_modeller: app.Modeller, + system_generator: SystemGenerator, + smc_components: list[SmallMoleculeComponent], + ) -> tuple[app.Topology, openmm.unit.Quantity, openmm.System]: """ - system_topology = system_modeller.getTopology() + Get the OpenMM Topology, Positions and System of the + parameterised system. - # roundtrip via off_units to canocalize - positions = to_openmm(from_openmm(system_modeller.getPositions())) + Parameters + ---------- + system_modeller : app.Modeller + OpenMM Modeller object representing the system to be + parametrized. + system_generator : SystemGenerator + SystemGenerator object to create a System with. + smc_components : list[SmallMoleculeComponent] + A list of SmallMoleculeComponents to add to the system. - omm_system = system_generator.create_system( - system_modeller.topology, - molecules=list(off_mols.values()) - ) + Returns + ------- + topology : app.Topology + Topology object describing the parameterized system + positionns : openmm.unit.Quantity + Positions of the system. + system : openmm.System + An OpenMM System of the alchemical system. """ topology = system_modeller.getTopology() # roundtrip positions to remove vec3 issues @@ -642,16 +686,29 @@ def _get_omm_objects(self, system_modeller, system_generator, ) return topology, positions, system - def _get_lambda_schedule(self, settings): + def _get_lambda_schedule( + self, settings: dict[str, SettingsBaseModel] + ) -> dict[str, npt.NDArray]: """ Create the lambda schedule - TODO: do this properly using LambdaProtocol - TODO: double check we definitely don't need to define - temperature & pressure (pressure sure that's the case) + + Parameters + ---------- + settings : dict[str, SettingsBaseModel] + Settings for the unit. + + Returns + ------- + lambdas : dict[str, npt.NDArray] + + TODO + ---- + * Augment this by using something akin to the RFE protocol's + LambdaProtocol """ lambdas = dict() - n_elec = settings.alchemical_settings.lambda_elec_windows - n_vdw = settings.alchemical_settings.lambda_vdw_windows + 1 + n_elec = settings['alchemical_settings'].lambda_elec_windows + n_vdw = settings['alchemical_settings'].lambda_vdw_windows + 1 lambdas['lambda_electrostatics'] = np.concatenate( [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] ) @@ -659,7 +716,7 @@ def _get_lambda_schedule(self, settings): [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] ) - n_replicas = settings.alchemical_sampler_settings.n_replicas + n_replicas = settings['sampler_settings'].n_replicas if n_replicas != (len(lambdas['lambda_sterics'])): errmsg = (f"Number of replicas {n_replicas} " @@ -674,11 +731,41 @@ def _add_restraints(self, system, topology, settings): """ return - def _get_alchemical_system(self, topology, system, comp_resids, - alchem_comps): + def _get_alchemical_system( + self, + topology: app.Topology, + system: openmm.System, + comp_resids: dict[Component, npt.NDArray], + alchem_comps: dict[str, list[Component]] + ) -> tuple[AbsoluteAlchemicalFactory, openmm.System, list[int]]: """ - # TODO: add support for all the variants here - # TODO: check that adding indices this way works + Get an alchemically modified system and its associated factory + + Parameters + ---------- + topology : openmm.Topology + Topology of OpenMM System. + system : openmm.System + System to alchemically modify. + comp_resids : dict[str, npt.NDArray] + A dictionary of residues for each component in the System. + alchem_comps : dict[str, list[Component]] + A dictionary of alchemical components for each end state. + + + Returns + ------- + alchemical_factory : AbsoluteAlchemicalFactory + Factory for creating an alchemically modified system. + alchemical_system : openmm.System + Alchemically modified system + alchemical_indices : list[int] + A list of atom indices for the alchemically modified + species in the system. + + TODO + ---- + * Add support for all alchemical factory options """ alchemical_indices = self._get_alchemical_indices( topology, comp_resids, alchem_comps @@ -695,8 +782,37 @@ def _get_alchemical_system(self, topology, system, comp_resids, return alchemical_factory, alchemical_system, alchemical_indices - def _get_states(self, alchemical_system, positions, settings, lambdas, solvent_comp): + def _get_states( + self, + alchemical_system: openmm.System, + positions: openmm.unit.Quantity, + settings: dict[str, SettingsBaseModel], + lambdas: dict[str, npt.NDArray], + solvent_comp: Optional[SolventComponent], + ) -> tuple[list[SamplerState], list[ThermodynamicState]]: """ + Get a list of sampler and thermodynmic states from an + input alchemical system. + + Parameters + ---------- + alchemical_system : openmm.System + Alchemical system to get states for. + positions : openmm.unit.Quantity + Positions of the alchemical system. + settings : dict[str, SettingsBaseModel] + A dictionary of settings for the protocol unit. + lambdas : dict[str, npt.NDArray] + A dictionary of lambda scales. + solvent_comp : Optional[SolventComponent] + The solvent component of the system, if there is one. + + Returns + ------- + sampler_states : list[SamplerState] + A list of SamplerStates for each replica in the system. + cmp_states : list[ThermodynamicState] + A list of ThermodynamicState for each replica in the system. """ alchemical_state = AlchemicalState.from_system(alchemical_system) # Set up the system constants @@ -721,8 +837,25 @@ def _get_states(self, alchemical_system, positions, settings, lambdas, solvent_c return sampler_states, cmp_states - def _get_reporter(self, topology, simulation_settings): + def _get_reporter( + self, + topology: app.Topology, + simulation_settings: SimulationSettings, + ) -> multistate.MultiStateReporter: """ + Get a MultistateReporter for the simulation you are running. + + Parameters + ---------- + topology : app.Topology + A Topology of the system being created. + simulation_settings : SimulationSettings + Settings for the simulation. + + Returns + ------- + reporter : multistate.MultiStateReporter + The reporter for the simulation. """ mdt_top = mdt.Topology.from_openmm(topology) @@ -739,8 +872,23 @@ def _get_reporter(self, topology, simulation_settings): return reporter - def _get_ctx_caches(self, engine_settings): + def _get_ctx_caches( + self, + engine_settings: OpenMMEngineSettings + ) -> tuple[openmmtools.cache.ContextCache, openmmtools.cache.ContextCache]: """ + Set the context caches based on the chosen platform + + Parameters + ---------- + engine_settings : OpenMMEngineSettings, + + Returns + ------- + energy_context_cache : openmmtools.cache.ContextCache + The energy state context cache. + sampler_context_cache : openmmtools.cache.ContextCache + The sampler state context cache. """ platform = compute.get_openmm_platform( engine_settings.compute_platform, @@ -756,8 +904,21 @@ def _get_ctx_caches(self, engine_settings): return energy_context_cache, sampler_context_cache - def _get_integrator(self, integrator_settings): + def _get_integrator( + self, + integrator_settings: IntegratorSettings + ) -> openmmtools.mcmc.LangevinDynamicsMove: """ + Return a LangevinDynamicsMove integrator + + Parameters + ---------- + integrator_settings : IntegratorSettings + + Returns + ------- + integrator : openmmtools.mcmc.LangevinDynamicsMove + A configured integrator object. """ integrator = openmmtools.mcmc.LangevinDynamicsMove( timestep=to_openmm(integrator_settings.timestep), @@ -770,10 +931,40 @@ def _get_integrator(self, integrator_settings): return integrator - - def _get_sampler(self, integrator, alchemsampler_settings, - energy_context_cache, sampler_context_cache): + def _get_sampler( + self, + integrator: openmmtools.mcmc.LangevinDynamicsMove, + reporter: openmmtools.multistate.MultiStateReporter, + sampler_settings: AlchemicalSamplerSettings, + cmp_states: list[ThermodynamicState], + sampler_states: list[SamplerState], + energy_context_cache: openmmtools.cache.ContextCache, + sampler_context_cache: openmmtools.cache.ContextCache + ) -> multistate.MultiStateSampler: """ + Get a sampler based on the equilibrium sampling method requested. + + Parameters + ---------- + integrator : openmmtools.mcmc.LangevinDynamicsMove + The simulation integrator. + reporter : openmmtools.multistate.MultiStateReporter + The reporter to hook up to the sampler. + sampler_settings : AlchemicalSamplerSettings + Settings for the alchemical sampler. + cmp_states : list[ThermodynamicState] + A list of thermodynamic states to sample. + sampler_states : list[SamplerState] + A list of sampler states. + energy_context_cache : openmmtools.cache.ContextCache + Context cache for the energy states. + sampler_context_cache : openmmtool.cache.ContextCache + Context cache for the sampler states. + + Returns + ------- + sampler : multistate.MultistateSampler + A sampler configured for the chosen sampling method. """ # Select the right sampler @@ -802,9 +993,9 @@ def _get_sampler(self, integrator, alchemsampler_settings, ) sampler.create( - thermodynamic_states=cmp_states, - sampler_states=sampler_states, - storage=reporter + thermodynamic_states=cmp_states, + sampler_states=sampler_states, + storage=reporter ) sampler.energy_context_cache = energy_context_cache @@ -812,26 +1003,54 @@ def _get_sampler(self, integrator, alchemsampler_settings, return sampler - def _run_simulation(self, sampler, dry, minimization_steps, equil_steps, - prod_steps, mc_steps, output_filename, checkpoint_storage): + def _run_simulation( + self, + sampler: multistate.MultiStateSampler, + reporter: multistate.MultiStateReporter, + settings: dict[str, SettingsBaseModel] + dry: bool): + """ + Run the simulation. + + Parameters + ---------- + sampler : multistate.MultiStateSampler + The sampler associated with the simulation to run. + reporter : multistate.MultiStateReporter + The reporter associated with the sampler. + settings : dict[str, SettingsBaseModel] + The dictionary of settings for the protocol. + dry : bool + Whether or not to dry run the simulation + """ + # Get the relevant simulation steps + mc_steps = settings['integrator_settings'].n_steps.m + + equil_steps, prod_steps = settings_validation.get_simsteps( + equil_length=settings['simulation_settings'].equilibration_length, + prod_length=settings['simulation_settings'].production_length, + timestep=settings['integrator_settings'].timestep, + mc_steps=mc_steps, + ) + if not dry: # pragma: no-cover # minimize - if verbose: - logger.info("minimizing systems") + if self.verbose: + self.logger.info("minimizing systems") sampler.minimize( max_iterations=sim_settings.minimization_steps ) # equilibrate - if verbose: - logger.info("equilibrating systems") + if self.verbose: + self.logger.info("equilibrating systems") sampler.equilibrate(int(equil_steps / mc_steps)) # type: ignore # production - if verbose: - logger.info("running production phase") + if self.verbose: + self.logger.info("running production phase") sampler.extend(int(prod_steps / mc_steps)) # type: ignore @@ -901,7 +1120,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # 4. Get modeller system_modeller, comp_resids = self._get_modeller( prot_comp, solv_comp, smc_comps, system_generator, - settings + settings['solvation_settings'] ) # 5. Get OpenMM topology, positions and system @@ -920,37 +1139,61 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # 9. Get alchemical system alchem_system, alchem_factory = self._get_alchemical_system( - omm_topology, comp_resids, alchem_comps + omm_topology, omm_system, comp_resids, alchem_comps ) # 10. Get compound and sampler states cmp_states, sampler_states = self._get_states( - alchem_system, solvent_comp, settings + alchem_system, positions, settings, + lambdas, solvent_comp ) # 11. Create the multistate reporter & create PDB reporter = self._get_reporter( - omm_topology, settings.simulation_setttings + omm_topology, settings['simulation_setttings'], ) - # 12. Get context caches - energy_ctx_cache, sampler_ctx_cache = self._get_ctx_caches( - settings.engine_settings - ) + # Wrap in try/finally to avoid memory leak issues + try: + # 12. Get context caches + energy_ctx_cache, sampler_ctx_cache = self._get_ctx_caches( + settings['engine_settings'] + ) - # 13. Get integrator - integrator = self._get_integrator(settings.integrator_settings) + # 13. Get integrator + integrator = self._get_integrator(settings['integrator_settings']) - # 14. Get sampler - sampler = self._get_sampler( - integrator, settings.sampler_settings, cmp_states, sampler_states, - reporter, energy_ctx_cache, sampler_ctx_cache - ) + # 14. Get sampler + sampler = self._get_sampler( + integrator, reporter, settings['sampler_settings'], + cmp_states, sampler_states, + energy_ctx_cache, sampler_ctx_cache + ) - # 15. Run simulation - self._run_simulation( - dry, verbose, sampler, reporter, settings.simulation_settings - ) + # 15. Run simulation + self._run_simulation( + sampler, reporter, settings, dry + ) + finally: + # close reporter when you're done to prevent file handle clashes + reporter.close() + + # clear GPU context + # Note: use cache.empty() when openmmtools #690 is resolved + for context in list(energy_ctx_cache._lru._data.keys()): + del energy_ctx_cache._lru._data[context] + for context in list(sampler_ctx_cache._lru._data.keys()): + del sampler_ctx_cache._lru._data[context] + # cautiously clear out the global context cache too + for context in list( + openmmtools.cache.global_context_cache._lru._data.keys()): + del openmmtools.cache.global_context_cache._lru._data[context] + + del sampler_ctx_cache, energy_ctx_cache + + # Keep these around in a dry run so we can inspect things + if not dry: + del integrator, sampler def _execute( self, ctx: gufe.Context, **kwargs, From 53cafc6fa528406c73fade93472488b0dc728241 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 5 Oct 2023 21:24:52 +0100 Subject: [PATCH 37/74] Various fixes --- .../openmm_afe/equil_afe_settings.py | 52 +++++++++++++-- .../openmm_afe/equil_solvation_afe_method.py | 64 +++++++++++-------- 2 files changed, 81 insertions(+), 35 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_afe_settings.py b/openfe/protocols/openmm_afe/equil_afe_settings.py index 93d6b8a2f..6f1149003 100644 --- a/openfe/protocols/openmm_afe/equil_afe_settings.py +++ b/openfe/protocols/openmm_afe/equil_afe_settings.py @@ -13,18 +13,27 @@ * Improve this docstring by adding an example use case. """ -from typing import Optional -from pydantic import validator -from openff.units import unit +from gufe.settings import ( + Settings, + SettingsBaseModel, + OpenMMSystemGeneratorFFSettings, + ThermoSettings, +) from openfe.protocols.openmm_utils.omm_settings import ( - Settings, SettingsBaseModel, ThermoSettings, - OpenMMSystemGeneratorFFSettings, SystemSettings, - SolvationSettings, AlchemicalSamplerSettings, - OpenMMEngineSettings, IntegratorSettings, + SystemSettings, + SolvationSettings, + AlchemicalSamplerSettings, + OpenMMEngineSettings, + IntegratorSettings, SimulationSettings ) +try: + from pydantic.v1 import validator +except ImportError: + from pydantic import validator # type: ignore[assignment] + class AlchemicalSettings(SettingsBaseModel): """Settings for the alchemical protocol @@ -50,19 +59,48 @@ class AbsoluteTransformSettings(Settings): class Config: arbitrary_types_allowed = True + # Inherited things + forcefield_settings: OpenMMSystemGeneratorFFSettings + """Parameters to set up the force field with OpenMM Force Fields""" + thermo_settings: ThermoSettings + """Settings for thermodynamic parameters""" + # Things for creating the systems system_settings: SystemSettings + """ + Simulation system settings including the + long-range non-bonded methods. + """ solvation_settings: SolvationSettings + """Settings for solvating the system.""" # Alchemical settings alchemical_settings: AlchemicalSettings + """ + Alchemical protocol settings including lambda windows. + """ alchemsampler_settings: AlchemicalSamplerSettings + """ + Settings for controling how we sample alchemical space, including the + number of repeats. + """ # MD Engine things engine_settings: OpenMMEngineSettings + """ + Settings specific to the OpenMM engine, such as the compute platform. + """ # Sampling State defining things integrator_settings: IntegratorSettings + """ + Settings for controlling the integrator, such as the timestep and + barostat settings. + """ # Simulation run settings simulation_settings: SimulationSettings + """ + Simulation control settings, including simulation lengths and + record-keeping. + """ diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index df52a0bf6..84135c32c 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -46,15 +46,15 @@ import numpy as np import numpy.typing as npt import openmm -from openff.toolkit import Molecule as OFFMol from openff.units import unit from openff.units.openmm import from_openmm, to_openmm, ensure_quantity from openmmtools import multistate from openmmtools.states import (SamplerState, + ThermodynamicState, create_thermodynamic_state_protocol,) from openmmtools.alchemy import (AlchemicalRegion, AbsoluteAlchemicalFactory, AlchemicalState,) -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional from openmm import app from openmm import unit as omm_unit from openmmforcefields.generators import SystemGenerator @@ -68,6 +68,9 @@ settings, ChemicalSystem, SmallMoleculeComponent, ProteinComponent, SolventComponent ) +from openfe.protocols.openmm_utils.omm_settings import ( + SettingsBaseModel, +) from openfe.protocols.openmm_afe.equil_afe_settings import ( AbsoluteTransformSettings, SystemSettings, SolvationSettings, AlchemicalSettings, @@ -218,7 +221,7 @@ def _validate_solvent_endstates( If stateA contains a ProteinComponent """ if ((len(stateB) != 1) or - (not isinstance(stateB.values()[0], SolventComponent))): + (not isinstance(stateB.values()[0], SolventComponent))): errmsg = "Only a single SolventComponent is allowed in stateB" raise ValueError(errmsg) @@ -262,7 +265,7 @@ def _validate_alchemical_components( errmsg = ("Components appearing in state B are not " "currently supported") raise ValueError(errmsg) - + if len(alchemical_components['stateA']) > 1: errmsg = ("More than one alchemical components is not supported " "for absolute solvation free energies") @@ -310,7 +313,7 @@ def _create( AbsoluteSolventTransformUnit( stateA=stateA, stateB=stateB, settings=self.settings, - alchemical_components=alchemical_comps, + alchemical_components=alchem_comps, generation=0, repeat_id=i, name=(f"Absolute Solvation, {alchname} solvent leg: " f"repeat {i} generation 0"), @@ -324,7 +327,7 @@ def _create( # Should these be overriden to be ChemicalSystem{smc} -> ChemicalSystem{} ? stateA=stateA, stateB=stateB, settings=self.settings, - alchemical_components=alchemical_comps, + alchemical_components=alchem_comps, generation=0, repeat_id=i, name=(f"Absolute Solvation, {alchname} solvent leg: " f"repeat {i} generation 0"), @@ -488,7 +491,7 @@ def _pre_minimize(system: openmm.System, return minimized_positions def _prepare( - self, verbose: bool, + self, verbose: bool, scratch_basepath: Optional[pathlib.Path], shared_basepath: Optional[pathlib.Path], ): @@ -513,7 +516,7 @@ def _set_optional_path(basepath): if basepath is None: return pathlib.Path('.') return basepath - + self.scratch_basepath = _set_optional_path(scratch_basepath) self.shared_basepath = _set_optional_path(shared_basepath) @@ -532,7 +535,7 @@ def _get_components(self): solvent_comp, protein_comp, off_mols = self._parse_components(stateA) """ raise NotImplementedError - + def _handle_settings(self): """ Get a dictionary with the following entries: @@ -558,10 +561,11 @@ def _handle_settings(self): ) """ raise NotImplementedError - + def _get_system_generator( self, settings: dict[str, SettingsBaseModel], - solvent_comp: Optional[SolventComponent]) -> SystemGenerator: + solvent_comp: Optional[SolventComponent] + ) -> SystemGenerator: """ Get a system generator through the system creation utilities @@ -589,7 +593,7 @@ def _get_system_generator( has_solvent=solvent_comp is not None, ) return system_generator - + def _get_modeller( self, protein_component: Optional[ProteinComponent], @@ -722,7 +726,7 @@ def _get_lambda_schedule( errmsg = (f"Number of replicas {n_replicas} " "does not equal the number of lambda windows ") raise ValueError(errmsg) - + return lambdas def _add_restraints(self, system, topology, settings): @@ -822,7 +826,7 @@ def _get_states( constants['temperature'] = ensure_quantity(temperature, 'openmm') if solvent_comp is not None: constants['pressure'] = ensure_quantity(pressure, 'openmm') - + cmp_states = create_thermodynamic_state_protocol( alchemical_system, protocol=lambdas, consatnts=constants, composable_states=[alchemical_state], @@ -863,11 +867,14 @@ def _get_reporter( simulation_settings.output_indices ) + nc = self.shared_basepath / simulation_settings.output_filename + chk = self.shared_basepath / simulation_settings.checkpoint_storage + reporter = multistate.MultiStateReporter( - storage=self.shared_basepathbasepath / simulation_settings.output_filename, + storage=nc, analysis_particle_indices=selection_indices, - checkpoint_interval=simultation_settings.checkpoint_interval.m, - checkpoint_storage=basepath / simultation_settings.checkpoint_storage, + checkpoint_interval=simulation_settings.checkpoint_interval.m, + checkpoint_storage=chk, ) return reporter @@ -901,7 +908,7 @@ def _get_ctx_caches( sampler_context_cache = openmmtools.cache.ContextCache( capacity=None, time_to_line=None, platform=platform, ) - + return energy_context_cache, sampler_context_cache def _get_integrator( @@ -930,14 +937,14 @@ def _get_integrator( ) return integrator - + def _get_sampler( self, integrator: openmmtools.mcmc.LangevinDynamicsMove, reporter: openmmtools.multistate.MultiStateReporter, sampler_settings: AlchemicalSamplerSettings, cmp_states: list[ThermodynamicState], - sampler_states: list[SamplerState], + sampler_states: list[SamplerState], energy_context_cache: openmmtools.cache.ContextCache, sampler_context_cache: openmmtools.cache.ContextCache ) -> multistate.MultiStateSampler: @@ -1007,8 +1014,9 @@ def _run_simulation( self, sampler: multistate.MultiStateSampler, reporter: multistate.MultiStateReporter, - settings: dict[str, SettingsBaseModel] - dry: bool): + settings: dict[str, SettingsBaseModel], + dry: bool + ): """ Run the simulation. @@ -1039,7 +1047,7 @@ def _run_simulation( self.logger.info("minimizing systems") sampler.minimize( - max_iterations=sim_settings.minimization_steps + max_iterations=settings['sim_settings'].minimization_steps ) # equilibrate @@ -1057,8 +1065,8 @@ def _run_simulation( # close reporter when you're done reporter.close() - nc = basepath / sim_settings.output_filename - chk = basepath / sim_settings.checkpoint_storage + nc = self.shared_basepath / settings['simulation_settings'].output_filename + chk = self.shared_basepath / settings['simulation_settings'].checkpoint_storage return { 'nc': nc, 'last_checkpoint': chk, @@ -1068,8 +1076,8 @@ def _run_simulation( reporter.close() # clean up the reporter file - fns = [basepath / sim_settings.output_filename, - basepath / sim_settings.checkpoint_storage] + fns = [self.shared_basepath / settings['simulation_settings'].output_filename, + self.shared_basepath / settings['simulation_settings'].checkpoint_storage] for fn in fns: os.remove(fn) return {'debug': {'sampler': sampler}} @@ -1145,7 +1153,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # 10. Get compound and sampler states cmp_states, sampler_states = self._get_states( alchem_system, positions, settings, - lambdas, solvent_comp + lambdas, solv_comp ) # 11. Create the multistate reporter & create PDB From fbe89a4942037be465735064ac3e8f5c9790f467 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 6 Oct 2023 11:21:26 +0100 Subject: [PATCH 38/74] more changes --- openfe/protocols/openmm_afe/base.py | 962 ++++++++++++++++++ .../openmm_afe/equil_solvation_afe_method.py | 959 +++-------------- 2 files changed, 1083 insertions(+), 838 deletions(-) create mode 100644 openfe/protocols/openmm_afe/base.py diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py new file mode 100644 index 000000000..8beef798d --- /dev/null +++ b/openfe/protocols/openmm_afe/base.py @@ -0,0 +1,962 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +"""OpenMM Equilibrium AFE Protocol base classes +=============================================== + +Base classes for the equilibrium OpenMM absolute free energy classes. + +This module mostly implements a base unit class for AFE transformations. + +Current limitations +------------------- +* Disapearing molecules are only allowed in state A. Support for + appearing molecules will be added in due course. +* Only small molecules are allowed to act as alchemical molecules. + Alchemically changing protein or solvent components would induce + perturbations which are too large to be handled by this Protocol. + + +Acknowledgements +---------------- +* Originally based on hydration.py in + `espaloma `_ + + +TODO +---- +* Add in all the AlchemicalFactory and AlchemicalRegion kwargs + as settings. +* Allow for a more flexible setting of Lambda regions. +* Add support for restraints. +""" +from __future__ import annotations + +import os +import logging + +import gufe +from gufe.components import Component +import numpy as np +import numpy.typing as npt +import openmm +from openff.units import unit +from openff.units.openmm import from_openmm, to_openmm, ensure_quantity +from openmmtools import multistate +from openmmtools.states import (SamplerState, + ThermodynamicState, + create_thermodynamic_state_protocol,) +from openmmtools.alchemy import (AlchemicalRegion, AbsoluteAlchemicalFactory, + AlchemicalState,) +from typing import Dict, List, Optional +from openmm import app +from openmm import unit as omm_unit +from openmmforcefields.generators import SystemGenerator +import pathlib +from typing import Any +import openmmtools +import mdtraj as mdt + +from gufe import ( + settings, ChemicalSystem, SmallMoleculeComponent, + ProteinComponent, SolventComponent +) +from openfe.protocols.openmm_utils.omm_settings import ( + SettingsBaseModel, +) +from openfe.protocols.openmm_afe.equil_afe_settings import ( + SolvationSettings, + AlchemicalSamplerSettings, OpenMMEngineSettings, + IntegratorSettings, SimulationSettings, +) +from openfe.protocols.openmm_rfe._rfe_utils import compute +from ..openmm_utils import ( + settings_validation, system_creation, + multistate_analysis +) +from openfe.utils import without_oechem_backend, log_system_probe + +logger = logging.getLogger(__name__) + + +class BaseAbsoluteTransformUnit(gufe.ProtocolUnit): + """ + Base class for ligand absolute free energy transformations. + """ + def __init__(self, *, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + settings: settings.Settings, + alchemical_components: Dict[str, List[str]], + generation: int = 0, + repeat_id: int = 0, + name: Optional[str] = None,): + """ + Parameters + ---------- + stateA : ChemicalSystem + ChemicalSystem containing the components defining the state at + lambda 0. + stateB : ChemicalSystem + ChemicalSystem containing the components defining the state at + lambda 1. + settings : gufe.settings.Setings + Settings for the Absolute Tranformation Protocol. This can be + constructed by calling the + :class:`AbsoluteTransformProtocol.get_default_settings` method + to get a default set of settings. + name : str, optional + Human-readable identifier for this Unit + repeat_id : int, optional + Identifier for which repeat (aka replica/clone) this Unit is, + default 0 + generation : int, optional + Generation counter which keeps track of how many times this repeat + has been extended, default 0. + """ + super().__init__( + name=name, + stateA=stateA, + stateB=stateB, + settings=settings, + alchemical_components=alchemical_components, + repeat_id=repeat_id, + generation=generation, + ) + + @staticmethod + def _get_alchemical_indices(omm_top: openmm.Topology, + comp_resids: Dict[str, npt.NDArray], + alchem_comps: Dict[str, List[Component]] + ) -> List[int]: + """ + Get a list of atom indices for all the alchemical species + + Parameters + ---------- + omm_top : openmm.Topology + Topology of OpenMM System. + comp_resids : Dict[str, npt.NDArray] + A dictionary of residues for each component in the System. + alchem_comps : Dict[str, List[Component]] + A dictionary of alchemical components for each end state. + + Return + ------ + atom_ids : List[int] + A list of atom indices for the alchemical species + """ + + # concatenate a list of residue indexes for all alchemical components + residxs = np.concatenate( + [comp_resids[key] for key in alchem_comps['stateA']] + ) + + # get the alchemicical residues from the topology + alchres = [ + r for r in omm_top.residues() if r.index in residxs + ] + + atom_ids = [] + + for res in alchres: + atom_ids.extend([at.index for at in res.atoms()]) + + return atom_ids + + @staticmethod + def _pre_minimize(system: openmm.System, + positions: omm_unit.Quantity) -> npt.NDArray: + """ + Short CPU minization of System to avoid GPU NaNs + + Parameters + ---------- + system : openmm.System + An OpenMM System to minimize. + positionns : openmm.unit.Quantity + Initial positions for the system. + + Returns + ------- + minimized_positions : npt.NDArray + Minimized positions + """ + integrator = openmm.VerletIntegrator(0.001) + context = openmm.Context( + system, integrator, + openmm.Platform.getPlatformByName('CPU'), + ) + context.setPositions(positions) + # Do a quick 100 steps minimization, usually avoids NaNs + openmm.LocalEnergyMinimizer.minimize( + context, maxIterations=100 + ) + state = context.getState(getPositions=True) + minimized_positions = state.getPositions(asNumpy=True) + return minimized_positions + + def _prepare( + self, verbose: bool, + scratch_basepath: Optional[pathlib.Path], + shared_basepath: Optional[pathlib.Path], + ): + """ + Set basepaths and do some initial logging. + + Parameters + ---------- + verbose : bool + Verbose output of the simulation progress. Output is provided via + INFO level logging. + basepath : Optional[pathlib.Path] + Optional base path to write files to. + """ + self.verbose = verbose + + if self.verbose: + self.logger.info("setting up alchemical system") + + # set basepaths + def _set_optional_path(basepath): + if basepath is None: + return pathlib.Path('.') + return basepath + + self.scratch_basepath = _set_optional_path(scratch_basepath) + self.shared_basepath = _set_optional_path(shared_basepath) + + def _get_components(self): + """ + Get the relevant components to create the alchemical system with. + + Note + ---- + Must be implemented in child class. + + To move: + stateA = self._inputs['stateA'] + alchem_comps = self._inputs['alchemical_components'] + # Get the relevant solvent & protein components & openff molecules + solvent_comp, protein_comp, off_mols = self._parse_components(stateA) + """ + raise NotImplementedError + + def _handle_settings(self): + """ + Get a dictionary with the following entries: + * forcefield_settings : OpenMMSystemGeneratorFFSettings + * thermo_settings : ThermoSettings + * system_settings : SystemSettings + * solvation_settings : SolvationSettings + * alchemical_settings : AlchemicalSettings + * sampler_settings : AlchemicalSamplerSettings + * engine_settings : OpenMMEngineSettings + * integrator_settings : IntegratorSettings + * simulation_settings : SimulationSettings + + Settings may change depending on what type of simulation you are + running. Cherry pick them and return them to be available later on. + + This method should also add various validation checks as necessary. + + # a. Validation checks + settings_validation.validate_timestep( + settings.forcefield_settings.hydrogen_mass, + settings.integrator_settings.timestep + ) + """ + raise NotImplementedError + + def _get_system_generator( + self, settings: dict[str, SettingsBaseModel], + solvent_comp: Optional[SolventComponent] + ) -> SystemGenerator: + """ + Get a system generator through the system creation + utilities + + Parameters + ---------- + settings : dict[str, SettingsBaseModel] + A dictionary of settings object for the unit. + solvent_comp : Optional[SolventComponent] + The solvent component of this system, if there is one. + + Returns + ------- + system_generator : openmmforcefields.generator.SystemGenerator + System Generator to parameterise this unit. + """ + ffcache = settings['simulation_settings'].forcefield_cache + if ffcache is not None: + ffcache = self.shared_basepath / ffcache + + system_generator = system_creation.get_system_generator( + forcefield_settings=settings['forcefield_settings'], + thermo_settings=settings['thermo_settings'], + cache=ffcache, + has_solvent=solvent_comp is not None, + ) + return system_generator + + def _get_modeller( + self, + protein_component: Optional[ProteinComponent], + solvent_component: Optional[SolventComponent], + smc_components: list[SmallMoleculeComponent], + system_generator: SystemGenerator, + solvation_settings: SolvationSettings + ) -> tuple[app.Modeller, dict[Component, npt.NDArray]]: + """ + Get an OpenMM Modeller object and a list of residue indices + for each component in the system. + + Parameters + ---------- + protein_component : Optional[ProteinComponent] + Protein Component, if it exists. + solvent_component : Optional[ProteinCompoinent] + Solvent Component, if it exists. + smc_components : list[SmallMoleculeComponents] + List of SmallMoleculeComponents to add. + system_generator : openmmforcefields.generator.SystemGenerator + System Generator to parameterise this unit. + solvation_settings : SolvationSettings + Settings detailing how to solvate the system. + + Returns + ------- + system_modeller : app.Modeller + OpenMM Modeller object generated from ProteinComponent and + OpenFF Molecules. + comp_resids : dict[Component, npt.NDArray] + Dictionary of residue indices for each component in system. + """ + if self.verbose: + self.logger.info("Parameterizing molecules") + + # force the creation of parameters for the small molecules + # this is necessary because we need to have the FF generated ahead + # of solvating the system. + # Note by default this is cached to ctx.shared/db.json which should + # reduce some of the costs. + for comp in smc_components: + offmol = comp.to_openff() + system_generator.create_system( + offmol.to_topology().to_openmm(), molecules=[offmol] + ) + + # get OpenMM modeller + dictionary of resids for each component + system_modeller, comp_resids = system_creation.get_omm_modeller( + protein_comp=protein_component, + solvent_comp=solvent_component, + small_mols=smc_components, + omm_forcefield=system_generator.forcefield, + solvent_settings=solvation_settings, + ) + + return system_modeller, comp_resids + + def _get_omm_objects( + self, + system_modeller: app.Modeller, + system_generator: SystemGenerator, + smc_components: list[SmallMoleculeComponent], + ) -> tuple[app.Topology, openmm.unit.Quantity, openmm.System]: + """ + Get the OpenMM Topology, Positions and System of the + parameterised system. + + Parameters + ---------- + system_modeller : app.Modeller + OpenMM Modeller object representing the system to be + parametrized. + system_generator : SystemGenerator + SystemGenerator object to create a System with. + smc_components : list[SmallMoleculeComponent] + A list of SmallMoleculeComponents to add to the system. + + Returns + ------- + topology : app.Topology + Topology object describing the parameterized system + positionns : openmm.unit.Quantity + Positions of the system. + system : openmm.System + An OpenMM System of the alchemical system. + """ + topology = system_modeller.getTopology() + # roundtrip positions to remove vec3 issues + positions = to_openmm(from_openmm(system_modeller.getPositions())) + system = system_generator.create_system( + system_modeller.topology, + molecules=[s.to_openff() for s in smc_components] + ) + return topology, positions, system + + def _get_lambda_schedule( + self, settings: dict[str, SettingsBaseModel] + ) -> dict[str, npt.NDArray]: + """ + Create the lambda schedule + + Parameters + ---------- + settings : dict[str, SettingsBaseModel] + Settings for the unit. + + Returns + ------- + lambdas : dict[str, npt.NDArray] + + TODO + ---- + * Augment this by using something akin to the RFE protocol's + LambdaProtocol + """ + lambdas = dict() + n_elec = settings['alchemical_settings'].lambda_elec_windows + n_vdw = settings['alchemical_settings'].lambda_vdw_windows + 1 + lambdas['lambda_electrostatics'] = np.concatenate( + [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] + ) + lambdas['lambda_sterics'] = np.concatenate( + [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] + ) + + n_replicas = settings['sampler_settings'].n_replicas + + if n_replicas != (len(lambdas['lambda_sterics'])): + errmsg = (f"Number of replicas {n_replicas} " + "does not equal the number of lambda windows ") + raise ValueError(errmsg) + + return lambdas + + def _add_restraints(self, system, topology, settings): + """ + Placeholder method to add restraints if necessary + """ + return + + def _get_alchemical_system( + self, + topology: app.Topology, + system: openmm.System, + comp_resids: dict[Component, npt.NDArray], + alchem_comps: dict[str, list[Component]] + ) -> tuple[AbsoluteAlchemicalFactory, openmm.System, list[int]]: + """ + Get an alchemically modified system and its associated factory + + Parameters + ---------- + topology : openmm.Topology + Topology of OpenMM System. + system : openmm.System + System to alchemically modify. + comp_resids : dict[str, npt.NDArray] + A dictionary of residues for each component in the System. + alchem_comps : dict[str, list[Component]] + A dictionary of alchemical components for each end state. + + + Returns + ------- + alchemical_factory : AbsoluteAlchemicalFactory + Factory for creating an alchemically modified system. + alchemical_system : openmm.System + Alchemically modified system + alchemical_indices : list[int] + A list of atom indices for the alchemically modified + species in the system. + + TODO + ---- + * Add support for all alchemical factory options + """ + alchemical_indices = self._get_alchemical_indices( + topology, comp_resids, alchem_comps + ) + + alchemical_region = AlchemicalRegion( + alchemical_atoms=alchemical_indices, + ) + + alchemical_factory = AbsoluteAlchemicalFactory() + alchemical_system = alchemical_factory.create_alchemical_system( + system, alchemical_region + ) + + return alchemical_factory, alchemical_system, alchemical_indices + + def _get_states( + self, + alchemical_system: openmm.System, + positions: openmm.unit.Quantity, + settings: dict[str, SettingsBaseModel], + lambdas: dict[str, npt.NDArray], + solvent_comp: Optional[SolventComponent], + ) -> tuple[list[SamplerState], list[ThermodynamicState]]: + """ + Get a list of sampler and thermodynmic states from an + input alchemical system. + + Parameters + ---------- + alchemical_system : openmm.System + Alchemical system to get states for. + positions : openmm.unit.Quantity + Positions of the alchemical system. + settings : dict[str, SettingsBaseModel] + A dictionary of settings for the protocol unit. + lambdas : dict[str, npt.NDArray] + A dictionary of lambda scales. + solvent_comp : Optional[SolventComponent] + The solvent component of the system, if there is one. + + Returns + ------- + sampler_states : list[SamplerState] + A list of SamplerStates for each replica in the system. + cmp_states : list[ThermodynamicState] + A list of ThermodynamicState for each replica in the system. + """ + alchemical_state = AlchemicalState.from_system(alchemical_system) + # Set up the system constants + temperature = settings.thermo_settings.temperature + pressure = settings.thermo_settings.pressure + constants = dict() + constants['temperature'] = ensure_quantity(temperature, 'openmm') + if solvent_comp is not None: + constants['pressure'] = ensure_quantity(pressure, 'openmm') + + cmp_states = create_thermodynamic_state_protocol( + alchemical_system, protocol=lambdas, + consatnts=constants, composable_states=[alchemical_state], + ) + + sampler_state = SamplerState(positions=positions) + if alchemical_system.usesPeriodicBoundaryConditions(): + box = alchemical_system.getDefaultPeriodicBoxVectors() + sampler_state.box_vectors = box + + sampler_states = [sampler_state for _ in cmp_states] + + return sampler_states, cmp_states + + def _get_reporter( + self, + topology: app.Topology, + positions: openmm.unit.Quantity, + simulation_settings: SimulationSettings, + ) -> multistate.MultiStateReporter: + """ + Get a MultistateReporter for the simulation you are running. + + Parameters + ---------- + topology : app.Topology + A Topology of the system being created. + simulation_settings : SimulationSettings + Settings for the simulation. + + Returns + ------- + reporter : multistate.MultiStateReporter + The reporter for the simulation. + """ + mdt_top = mdt.Topology.from_openmm(topology) + + selection_indices = mdt_top.select( + simulation_settings.output_indices + ) + + nc = self.shared_basepath / simulation_settings.output_filename + chk = self.shared_basepath / simulation_settings.checkpoint_storage + + reporter = multistate.MultiStateReporter( + storage=nc, + analysis_particle_indices=selection_indices, + checkpoint_interval=simulation_settings.checkpoint_interval.m, + checkpoint_storage=chk, + ) + + # Write out the structure's PDB whilst we're here + if len(selection_indices) > 0: + traj = mdt.Trajectory( + positions[selection_indices, :], + mdt_top, + ) + traj.savepdb( + self.shared_basepath / simulation_settings.output_structure + ) + + return reporter + + def _get_ctx_caches( + self, + engine_settings: OpenMMEngineSettings + ) -> tuple[openmmtools.cache.ContextCache, openmmtools.cache.ContextCache]: + """ + Set the context caches based on the chosen platform + + Parameters + ---------- + engine_settings : OpenMMEngineSettings, + + Returns + ------- + energy_context_cache : openmmtools.cache.ContextCache + The energy state context cache. + sampler_context_cache : openmmtools.cache.ContextCache + The sampler state context cache. + """ + platform = compute.get_openmm_platform( + engine_settings.compute_platform, + ) + + energy_context_cache = openmmtools.cache.ContextCache( + capacity=None, time_to_line=None, platform=platform, + ) + + sampler_context_cache = openmmtools.cache.ContextCache( + capacity=None, time_to_line=None, platform=platform, + ) + + return energy_context_cache, sampler_context_cache + + def _get_integrator( + self, + integrator_settings: IntegratorSettings + ) -> openmmtools.mcmc.LangevinDynamicsMove: + """ + Return a LangevinDynamicsMove integrator + + Parameters + ---------- + integrator_settings : IntegratorSettings + + Returns + ------- + integrator : openmmtools.mcmc.LangevinDynamicsMove + A configured integrator object. + """ + integrator = openmmtools.mcmc.LangevinDynamicsMove( + timestep=to_openmm(integrator_settings.timestep), + collision_rate=to_openmm(integrator_settings.collision_rate), + n_steps=integrator_settings.n_steps.m, + reassign_velocities=integrator_settings.reassign_velocities, + n_restart_attempts=integrator_settings.n_restart_attempts, + constraint_tolerance=integrator_settings.constraint_tolerance, + ) + + return integrator + + def _get_sampler( + self, + integrator: openmmtools.mcmc.LangevinDynamicsMove, + reporter: openmmtools.multistate.MultiStateReporter, + sampler_settings: AlchemicalSamplerSettings, + cmp_states: list[ThermodynamicState], + sampler_states: list[SamplerState], + energy_context_cache: openmmtools.cache.ContextCache, + sampler_context_cache: openmmtools.cache.ContextCache + ) -> multistate.MultiStateSampler: + """ + Get a sampler based on the equilibrium sampling method requested. + + Parameters + ---------- + integrator : openmmtools.mcmc.LangevinDynamicsMove + The simulation integrator. + reporter : openmmtools.multistate.MultiStateReporter + The reporter to hook up to the sampler. + sampler_settings : AlchemicalSamplerSettings + Settings for the alchemical sampler. + cmp_states : list[ThermodynamicState] + A list of thermodynamic states to sample. + sampler_states : list[SamplerState] + A list of sampler states. + energy_context_cache : openmmtools.cache.ContextCache + Context cache for the energy states. + sampler_context_cache : openmmtool.cache.ContextCache + Context cache for the sampler states. + + Returns + ------- + sampler : multistate.MultistateSampler + A sampler configured for the chosen sampling method. + """ + + # Select the right sampler + # Note: doesn't need else, settings already validates choices + if sampler_settings.sampler_method.lower() == "repex": + sampler = multistate.ReplicaExchangeSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_target_error=sampler_settings.online_analysis_target_error.m, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations + ) + elif sampler_settings.sampler_method.lower() == "sams": + sampler = multistate.SAMSSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations, + flatness_criteria=sampler_settings.flatness_criteria, + gamma0=sampler_settings.gamma0, + ) + elif sampler_settings.sampler_method.lower() == 'independent': + sampler = multistate.MultiStateSampler( + mcmc_moves=integrator, + online_analysis_interval=sampler_settings.online_analysis_interval, + online_analysis_target_error=sampler_settings.online_analysis_target_error.m, + online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations + ) + + sampler.create( + thermodynamic_states=cmp_states, + sampler_states=sampler_states, + storage=reporter + ) + + sampler.energy_context_cache = energy_context_cache + sampler.sampler_context_cache = sampler_context_cache + + return sampler + + def _run_simulation( + self, + sampler: multistate.MultiStateSampler, + reporter: multistate.MultiStateReporter, + settings: dict[str, SettingsBaseModel], + dry: bool + ): + """ + Run the simulation. + + Parameters + ---------- + sampler : multistate.MultiStateSampler + The sampler associated with the simulation to run. + reporter : multistate.MultiStateReporter + The reporter associated with the sampler. + settings : dict[str, SettingsBaseModel] + The dictionary of settings for the protocol. + dry : bool + Whether or not to dry run the simulation + + Returns + ------- + unit_results_dict : Optional[dict] + A dictionary containing all the free energy results, + if not a dry run. + """ + # Get the relevant simulation steps + mc_steps = settings['integrator_settings'].n_steps.m + + equil_steps, prod_steps = settings_validation.get_simsteps( + equil_length=settings['simulation_settings'].equilibration_length, + prod_length=settings['simulation_settings'].production_length, + timestep=settings['integrator_settings'].timestep, + mc_steps=mc_steps, + ) + + if not dry: # pragma: no-cover + # minimize + if self.verbose: + self.logger.info("minimizing systems") + + sampler.minimize( + max_iterations=settings['sim_settings'].minimization_steps + ) + + # equilibrate + if self.verbose: + self.logger.info("equilibrating systems") + + sampler.equilibrate(int(equil_steps / mc_steps)) # type: ignore + + # production + if self.verbose: + self.logger.info("running production phase") + + sampler.extend(int(prod_steps / mc_steps)) # type: ignore + + if self.verbose: + self.logger.info("production phase complete") + + if self.verbose: + self.logger.info("post-simulation result analysis") + + analyzer = multistate_analysis.MultistateEquilFEAnalysis( + reporter, + sampling_method=settings['sampler_settings'].sampler_method.lower(), + result_units=unit.kilocalorie_per_mole + ) + analyzer.plot(filepath=self.shared_basepath, filename_prefix="") + analyzer.close() + + return analyzer.unit_results_dict + + else: + # close reporter when you're done, prevent file handle clashes + reporter.close() + + # clean up the reporter file + fns = [self.shared_basepath / settings['simulation_settings'].output_filename, + self.shared_basepath / settings['simulation_settings'].checkpoint_storage] + for fn in fns: + os.remove(fn) + + return None + + def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: + """Run the absolute free energy calculation. + + Parameters + ---------- + dry : bool + Do a dry run of the calculation, creating all necessary alchemical + system components (topology, system, sampler, etc...) but without + running the simulation. + verbose : bool + Verbose output of the simulation progress. Output is provided via + INFO level logging. + basepath : Pathlike, optional + Where to run the calculation, defaults to current working directory + + Returns + ------- + dict + Outputs created in the basepath directory or the debug objects + (i.e. sampler) if ``dry==True``. + + Attributes + ---------- + solvent : Optional[SolventComponent] + SolventComponent to be applied to the system + protein : Optional[ProteinComponent] + ProteinComponent for the system + openff_mols : List[openff.Molecule] + List of OpenFF Molecule objects for each SmallMoleculeComponent in + the stateA ChemicalSystem + """ + # 0. Generaly preparation tasks + self._prepare(verbose, basepath) + + # 1. Get components + alchem_comps, solv_comp, prot_comp, smc_comps = self._get_components() + + # 2. Get settings + settings = self._handle_settings() + + # 3. Get system generator + system_generator = self._get_system_generator(settings, solv_comp) + + # 4. Get modeller + system_modeller, comp_resids = self._get_modeller( + prot_comp, solv_comp, smc_comps, system_generator, + settings['solvation_settings'] + ) + + # 5. Get OpenMM topology, positions and system + omm_topology, omm_system, positions = self._get_omm_objects( + system_generator, system_modeller, smc_comps + ) + + # 6. Pre-minimize System (Test + Avoid NaNs) + positions = self._pre_minimize(omm_system, positions) + + # 7. Get lambdas + lambdas = self._get_lambda_schedule(settings) + + # 8. Add restraints + self._add_restraints(omm_system, omm_topology, settings) + + # 9. Get alchemical system + alchem_system, alchem_factory = self._get_alchemical_system( + omm_topology, omm_system, comp_resids, alchem_comps + ) + + # 10. Get compound and sampler states + cmp_states, sampler_states = self._get_states( + alchem_system, positions, settings, + lambdas, solv_comp + ) + + # 11. Create the multistate reporter & create PDB + reporter = self._get_reporter( + omm_topology, settings['simulation_setttings'], + positions, + ) + + # Wrap in try/finally to avoid memory leak issues + try: + # 12. Get context caches + energy_ctx_cache, sampler_ctx_cache = self._get_ctx_caches( + settings['engine_settings'] + ) + + # 13. Get integrator + integrator = self._get_integrator(settings['integrator_settings']) + + # 14. Get sampler + sampler = self._get_sampler( + integrator, reporter, settings['sampler_settings'], + cmp_states, sampler_states, + energy_ctx_cache, sampler_ctx_cache + ) + + # 15. Run simulation + unit_result_dict = self._run_simulation( + sampler, reporter, settings, dry + ) + + finally: + # close reporter when you're done to prevent file handle clashes + reporter.close() + + # clear GPU context + # Note: use cache.empty() when openmmtools #690 is resolved + for context in list(energy_ctx_cache._lru._data.keys()): + del energy_ctx_cache._lru._data[context] + for context in list(sampler_ctx_cache._lru._data.keys()): + del sampler_ctx_cache._lru._data[context] + # cautiously clear out the global context cache too + for context in list( + openmmtools.cache.global_context_cache._lru._data.keys()): + del openmmtools.cache.global_context_cache._lru._data[context] + + del sampler_ctx_cache, energy_ctx_cache + + # Keep these around in a dry run so we can inspect things + if not dry: + del integrator, sampler + + if not dry: + nc = self.shared_basepath / settings['simulation_settings'].output_filename + chk = self.shared_basepath / settings['simulation_settings'].checkpoint_storage + return { + 'nc': nc, + 'last_checkpoint': chk, + **unit_result_dict, + } + else: + return {'debug': {'sampler': sampler}} + + def _execute( + self, ctx: gufe.Context, **kwargs, + ) -> Dict[str, Any]: + log_system_probe(logging.INFO, paths=[ctx.scratch]) + + with without_oechem_backend(): + outputs = self.run(scratch_basepath=ctx.scratch, + shared_basepath=ctx.shared) + + return { + 'repeat_id': self._inputs['repeat_id'], + 'generation': self._inputs['generation'], + **outputs + } diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 84135c32c..27cb307b8 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -1,6 +1,6 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -"""OpenMM Equilibrium Solvation AFE Protocol --- :mod:`openfe.protocols.openmm_afe.equil_solvation_afe_methods` +"""OpenMM Equilibrium Solvation AFE Protocol --- :mod:`openfe.protocols.openmm_afe.equil_solvation_afe_method` =============================================================================================================== This module implements the necessary methodology tooling to run calculate an @@ -24,64 +24,34 @@ ---------------- * Originally based on hydration.py in `espaloma `_ - - -TODO ----- -* Add in all the AlchemicalFactory and AlchemicalRegion kwargs - as settings. -* Allow for a more flexible setting of Lambda regions. -* Add support for restraints. -* Improve this docstring by adding an example use case. - """ from __future__ import annotations -import os import logging from collections import defaultdict import gufe from gufe.components import Component import numpy as np -import numpy.typing as npt -import openmm from openff.units import unit -from openff.units.openmm import from_openmm, to_openmm, ensure_quantity from openmmtools import multistate -from openmmtools.states import (SamplerState, - ThermodynamicState, - create_thermodynamic_state_protocol,) -from openmmtools.alchemy import (AlchemicalRegion, AbsoluteAlchemicalFactory, - AlchemicalState,) -from typing import Dict, List, Optional -from openmm import app +from typing import Dict, Optional from openmm import unit as omm_unit -from openmmforcefields.generators import SystemGenerator -import pathlib from typing import Any, Iterable -import openmmtools -import uuid -import mdtraj as mdt from gufe import ( settings, ChemicalSystem, SmallMoleculeComponent, ProteinComponent, SolventComponent ) -from openfe.protocols.openmm_utils.omm_settings import ( - SettingsBaseModel, -) from openfe.protocols.openmm_afe.equil_afe_settings import ( AbsoluteTransformSettings, SystemSettings, SolvationSettings, AlchemicalSettings, AlchemicalSamplerSettings, OpenMMEngineSettings, IntegratorSettings, SimulationSettings, ) -from openfe.protocols.openmm_rfe._rfe_utils import compute -from ..openmm_utils import ( - system_validation, settings_validation, system_creation -) - +from ..openmm_utils import system_validation, settings_validation +from .base import BaseAbsoluteTransformUnit +from openfe.utils import without_oechem_backend, log_system_probe logger = logging.getLogger(__name__) @@ -337,24 +307,19 @@ def _create( return solvent_units + vacuum_units - # TODO: update to match new unit list def _gather( self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] ) -> Dict[str, Any]: # result units will have a repeat_id and generation # first group according to repeat_id - repeats = defaultdict(list) + unsorted_repeats = defaultdict(list) for d in protocol_dag_results: pu: gufe.ProtocolUnitResult for pu in d.protocol_unit_results: if not pu.ok(): continue rep = pu.outputs['repeat_id'] - gen = pu.outputs['generation'] - - repeats[rep].append(( - gen, pu.outputs['nc'], - pu.outputs['last_checkpoint'])) + unsorted_repeats[rep].append(pu) data = [] for rep_id, rep_data in sorted(repeats.items()): @@ -373,849 +338,167 @@ def _gather( } -class BaseAbsoluteTransformUnit(gufe.ProtocolUnit): - """ - Base class for ligand absolute free energy transformations. - """ - def __init__(self, *, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - settings: settings.Settings, - alchemical_components: Dict[str, List[str]], - generation: int = 0, - repeat_id: int = 0, - name: Optional[str] = None,): - """ - Parameters - ---------- - stateA : ChemicalSystem - ChemicalSystem containing the components defining the state at - lambda 0. - stateB : ChemicalSystem - ChemicalSystem containing the components defining the state at - lambda 1. - settings : gufe.settings.Setings - Settings for the Absolute Tranformation Protocol. This can be - constructed by calling the - :class:`AbsoluteTransformProtocol.get_default_settings` method - to get a default set of settings. - name : str, optional - Human-readable identifier for this Unit - repeat_id : int, optional - Identifier for which repeat (aka replica/clone) this Unit is, - default 0 - generation : int, optional - Generation counter which keeps track of how many times this repeat - has been extended, default 0. - """ - super().__init__( - name=name, - stateA=stateA, - stateB=stateB, - settings=settings, - alchemical_components=alchemical_components, - repeat_id=repeat_id, - generation=generation, - ) - - @staticmethod - def _get_alchemical_indices(omm_top: openmm.Topology, - comp_resids: Dict[str, npt.NDArray], - alchem_comps: Dict[str, List[Component]] - ) -> List[int]: - """ - Get a list of atom indices for all the alchemical species - - Parameters - ---------- - omm_top : openmm.Topology - Topology of OpenMM System. - comp_resids : Dict[str, npt.NDArray] - A dictionary of residues for each component in the System. - alchem_comps : Dict[str, List[Component]] - A dictionary of alchemical components for each end state. - - Return - ------ - atom_ids : List[int] - A list of atom indices for the alchemical species - """ - - # concatenate a list of residue indexes for all alchemical components - residxs = np.concatenate( - [comp_resids[key] for key in alchem_comps['stateA']] - ) - - # get the alchemicical residues from the topology - alchres = [ - r for r in omm_top.residues() if r.index in residxs - ] - - atom_ids = [] - - for res in alchres: - atom_ids.extend([at.index for at in res.atoms()]) - - return atom_ids - - @staticmethod - def _pre_minimize(system: openmm.System, - positions: omm_unit.Quantity) -> npt.NDArray: +class AbsoluteVacuumTransformUnit(BaseAbsoluteTransformUnit): + def _get_components(self): """ - Short CPU minization of System to avoid GPU NaNs - - Parameters - ---------- - system : openmm.System - An OpenMM System to minimize. - positionns : openmm.unit.Quantity - Initial positions for the system. + Get the relevant components for a vacuum transformation. Returns ------- - minimized_positions : npt.NDArray - Minimized positions - """ - integrator = openmm.VerletIntegrator(0.001) - context = openmm.Context( - system, integrator, - openmm.Platform.getPlatformByName('CPU'), - ) - context.setPositions(positions) - # Do a quick 100 steps minimization, usually avoids NaNs - openmm.LocalEnergyMinimizer.minimize( - context, maxIterations=100 - ) - state = context.getState(getPositions=True) - minimized_positions = state.getPositions(asNumpy=True) - return minimized_positions - - def _prepare( - self, verbose: bool, - scratch_basepath: Optional[pathlib.Path], - shared_basepath: Optional[pathlib.Path], - ): - """ - Set basepaths and do some initial logging. - - Parameters - ---------- - verbose : bool - Verbose output of the simulation progress. Output is provided via - INFO level logging. - basepath : Optional[pathlib.Path] - Optional base path to write files to. - """ - self.verbose = verbose - - if self.verbose: - self.logger.info("setting up alchemical system") - - # set basepaths - def _set_optional_path(basepath): - if basepath is None: - return pathlib.Path('.') - return basepath - - self.scratch_basepath = _set_optional_path(scratch_basepath) - self.shared_basepath = _set_optional_path(shared_basepath) - - def _get_components(self): + alchem_comps : list[Component] + A list of alchemical components + solv_comp : None + For the gas phase transformation, None will always be returned + for the solvent component of the chemical system. + prot_comp : Optional[ProteinComponent] + The protein component of the system, if it exists. + small_mols : list[SmallMoleculeComponent] + A list of SmallMoleculeComponents to add to the system. """ - Get the relevant components to create the alchemical system with. - - Note - ---- - Must be implemented in child class. - - To move: stateA = self._inputs['stateA'] alchem_comps = self._inputs['alchemical_components'] - # Get the relevant solvent & protein components & openff molecules - solvent_comp, protein_comp, off_mols = self._parse_components(stateA) - """ - raise NotImplementedError - def _handle_settings(self): - """ - Get a dictionary with the following entries: - * forcefield_settings : OpenMMSystemGeneratorFFSettings - * thermo_settings : ThermoSettings - * system_settings : SystemSettings - * solvation_settings : SolvationSettings - * alchemical_settings : AlchemicalSettings - * sampler_settings : AlchemicalSamplerSettings - * engine_settings : OpenMMEngineSettings - * integrator_settings : IntegratorSettings - * simulation_settings : SimulationSettings - - Settings may change depending on what type of simulation you are - running. Cherry pick them and return them to be available later on. - - This method should also add various validation checks as necessary. - - # a. Validation checks - settings_validation.validate_timestep( - settings.forcefield_settings.hydrogen_mass, - settings.integrator_settings.timestep - ) - """ - raise NotImplementedError + _, prot_comp, small_mols = system_validation.get_components(stateA) - def _get_system_generator( - self, settings: dict[str, SettingsBaseModel], - solvent_comp: Optional[SolventComponent] - ) -> SystemGenerator: - """ - Get a system generator through the system creation - utilities + # Should we validate prot_comp here? What if we want to do + # a gas phase simulation of a small peptide? + # Keeping it flexible for now! - Parameters - ---------- - settings : dict[str, SettingsBaseModel] - A dictionary of settings object for the unit. - solvent_comp : Optional[SolventComponent] - The solvent component of this system, if there is one. + # Note our input state will contain a solvent, we ``None`` that out + # since this is the gas phase unit. + return alchem_comps, None, prot_comp, small_mols - Returns - ------- - system_generator : openmmforcefields.generator.SystemGenerator - System Generator to parameterise this unit. - """ - ffcache = settings['simulation_settings'].forcefield_cache - if ffcache is not None: - ffcache = self.shared_basepath / ffcache - - system_generator = system_creation.get_system_generator( - forcefield_settings=settings['forcefield_settings'], - thermo_settings=settings['thermo_settings'], - cache=ffcache, - has_solvent=solvent_comp is not None, - ) - return system_generator - - def _get_modeller( - self, - protein_component: Optional[ProteinComponent], - solvent_component: Optional[SolventComponent], - smc_components: list[SmallMoleculeComponent], - system_generator: SystemGenerator, - solvation_settings: SolvationSettings - ) -> tuple[app.Modeller, dict[Component, npt.NDArray]]: - """ - Get an OpenMM Modeller object and a list of residue indices - for each component in the system. - - Parameters - ---------- - protein_component : Optional[ProteinComponent] - Protein Component, if it exists. - solvent_component : Optional[ProteinCompoinent] - Solvent Component, if it exists. - smc_components : list[SmallMoleculeComponents] - List of SmallMoleculeComponents to add. - system_generator : openmmforcefields.generator.SystemGenerator - System Generator to parameterise this unit. - solvation_settings : SolvationSettings - Settings detailing how to solvate the system. - - Returns - ------- - system_modeller : app.Modeller - OpenMM Modeller object generated from ProteinComponent and - OpenFF Molecules. - comp_resids : dict[Component, npt.NDArray] - Dictionary of residue indices for each component in system. - """ - if self.verbose: - self.logger.info("Parameterizing molecules") - - # force the creation of parameters for the small molecules - # this is necessary because we need to have the FF generated ahead - # of solvating the system. - # Note by default this is cached to ctx.shared/db.json which should - # reduce some of the costs. - for comp in smc_components: - offmol = comp.to_openff() - system_generator.create_system( - offmol.to_topology().to_openmm(), molecules=[offmol] - ) - - # get OpenMM modeller + dictionary of resids for each component - system_modeller, comp_resids = system_creation.get_omm_modeller( - protein_comp=protein_component, - solvent_comp=solvent_component, - small_mols=smc_components, - omm_forcefield=system_generator.forcefield, - solvent_settings=solvation_settings, - ) - - return system_modeller, comp_resids - - def _get_omm_objects( - self, - system_modeller: app.Modeller, - system_generator: SystemGenerator, - smc_components: list[SmallMoleculeComponent], - ) -> tuple[app.Topology, openmm.unit.Quantity, openmm.System]: + def _handle_settings(self): """ - Get the OpenMM Topology, Positions and System of the - parameterised system. - - Parameters - ---------- - system_modeller : app.Modeller - OpenMM Modeller object representing the system to be - parametrized. - system_generator : SystemGenerator - SystemGenerator object to create a System with. - smc_components : list[SmallMoleculeComponent] - A list of SmallMoleculeComponents to add to the system. + Extract the relevant settings for a vacuum transformation. Returns ------- - topology : app.Topology - Topology object describing the parameterized system - positionns : openmm.unit.Quantity - Positions of the system. - system : openmm.System - An OpenMM System of the alchemical system. - """ - topology = system_modeller.getTopology() - # roundtrip positions to remove vec3 issues - positions = to_openmm(from_openmm(system_modeller.getPositions())) - system = system_generator.create_system( - system_modeller.topology, - molecules=[s.to_openff() for s in smc_components] - ) - return topology, positions, system - - def _get_lambda_schedule( - self, settings: dict[str, SettingsBaseModel] - ) -> dict[str, npt.NDArray]: - """ - Create the lambda schedule - - Parameters - ---------- settings : dict[str, SettingsBaseModel] - Settings for the unit. - - Returns - ------- - lambdas : dict[str, npt.NDArray] - - TODO - ---- - * Augment this by using something akin to the RFE protocol's - LambdaProtocol - """ - lambdas = dict() - n_elec = settings['alchemical_settings'].lambda_elec_windows - n_vdw = settings['alchemical_settings'].lambda_vdw_windows + 1 - lambdas['lambda_electrostatics'] = np.concatenate( - [np.linspace(1, 0, n_elec), np.linspace(0, 0, n_vdw)[1:]] - ) - lambdas['lambda_sterics'] = np.concatenate( - [np.linspace(1, 1, n_elec), np.linspace(1, 0, n_vdw)[1:]] - ) - - n_replicas = settings['sampler_settings'].n_replicas - - if n_replicas != (len(lambdas['lambda_sterics'])): - errmsg = (f"Number of replicas {n_replicas} " - "does not equal the number of lambda windows ") - raise ValueError(errmsg) - - return lambdas - - def _add_restraints(self, system, topology, settings): - """ - Placeholder method to add restraints if necessary - """ - return - - def _get_alchemical_system( - self, - topology: app.Topology, - system: openmm.System, - comp_resids: dict[Component, npt.NDArray], - alchem_comps: dict[str, list[Component]] - ) -> tuple[AbsoluteAlchemicalFactory, openmm.System, list[int]]: - """ - Get an alchemically modified system and its associated factory - - Parameters - ---------- - topology : openmm.Topology - Topology of OpenMM System. - system : openmm.System - System to alchemically modify. - comp_resids : dict[str, npt.NDArray] - A dictionary of residues for each component in the System. - alchem_comps : dict[str, list[Component]] - A dictionary of alchemical components for each end state. - - - Returns - ------- - alchemical_factory : AbsoluteAlchemicalFactory - Factory for creating an alchemically modified system. - alchemical_system : openmm.System - Alchemically modified system - alchemical_indices : list[int] - A list of atom indices for the alchemically modified - species in the system. - - TODO - ---- - * Add support for all alchemical factory options - """ - alchemical_indices = self._get_alchemical_indices( - topology, comp_resids, alchem_comps - ) + A dictionary with the following entries: + * forcefield_settings : OpenMMSystemGeneratorFFSettings + * thermo_settings : ThermoSettings + * system_settings : SystemSettings + * solvation_settings : SolvationSettings + * alchemical_settings : AlchemicalSettings + * sampler_settings : AlchemicalSamplerSettings + * engine_settings : OpenMMEngineSettings + * integrator_settings : IntegratorSettings + * simulation_settings : SimulationSettings + """ + prot_settings = self._inputs['settings'] + + settings = {} + settings['forcefield_settings'] = prot_settings.forcefield_settings + settings['thermo_settings'] = prot_settings.thermo_settings + settings['system_settings'] = prot_settings.vacuum_system_settings + settings['solvation_settings'] = prot_settings.solvation_settings + settings['alchemical_settings'] = prot_settings.alchemical_settings + settings['sampler_settings'] = prot_settings.alchemsampler_settings + settings['engine_setttings'] = prot_settings.engine_settings + settings['integrator_settings'] = prot_settings.integrator_settings + settings['simulation_settings'] = prot_settings.vacuum_simulation_settings - alchemical_region = AlchemicalRegion( - alchemical_atoms=alchemical_indices, - ) - - alchemical_factory = AbsoluteAlchemicalFactory() - alchemical_system = alchemical_factory.create_alchemical_system( - system, alchemical_region - ) - - return alchemical_factory, alchemical_system, alchemical_indices - - def _get_states( - self, - alchemical_system: openmm.System, - positions: openmm.unit.Quantity, - settings: dict[str, SettingsBaseModel], - lambdas: dict[str, npt.NDArray], - solvent_comp: Optional[SolventComponent], - ) -> tuple[list[SamplerState], list[ThermodynamicState]]: - """ - Get a list of sampler and thermodynmic states from an - input alchemical system. - - Parameters - ---------- - alchemical_system : openmm.System - Alchemical system to get states for. - positions : openmm.unit.Quantity - Positions of the alchemical system. - settings : dict[str, SettingsBaseModel] - A dictionary of settings for the protocol unit. - lambdas : dict[str, npt.NDArray] - A dictionary of lambda scales. - solvent_comp : Optional[SolventComponent] - The solvent component of the system, if there is one. - - Returns - ------- - sampler_states : list[SamplerState] - A list of SamplerStates for each replica in the system. - cmp_states : list[ThermodynamicState] - A list of ThermodynamicState for each replica in the system. - """ - alchemical_state = AlchemicalState.from_system(alchemical_system) - # Set up the system constants - temperature = settings.thermo_settings.temperature - pressure = settings.thermo_settings.pressure - constants = dict() - constants['temperature'] = ensure_quantity(temperature, 'openmm') - if solvent_comp is not None: - constants['pressure'] = ensure_quantity(pressure, 'openmm') - - cmp_states = create_thermodynamic_state_protocol( - alchemical_system, protocol=lambdas, - consatnts=constants, composable_states=[alchemical_state], + settings_validation.validate_timestep( + settings['forcefield_settings'].hyrodgen_mass, + settings['integrator_settings'].timestep ) - sampler_state = SamplerState(positions=positions) - if alchemical_system.usesPeriodicBoundaryConditions(): - box = alchemical_system.getDefaultPeriodicBoxVectors() - sampler_state.box_vectors = box - - sampler_states = [sampler_state for _ in cmp_states] - - return sampler_states, cmp_states - - def _get_reporter( - self, - topology: app.Topology, - simulation_settings: SimulationSettings, - ) -> multistate.MultiStateReporter: - """ - Get a MultistateReporter for the simulation you are running. - - Parameters - ---------- - topology : app.Topology - A Topology of the system being created. - simulation_settings : SimulationSettings - Settings for the simulation. - - Returns - ------- - reporter : multistate.MultiStateReporter - The reporter for the simulation. - """ - mdt_top = mdt.Topology.from_openmm(topology) - - selection_indices = mdt_top.select( - simulation_settings.output_indices - ) + def _execute( + self, ctx: gufe.Context, **kwargs, + ) -> Dict[str, Any]: + log_system_probe(logging.INFO, paths=[ctx.scratch]) - nc = self.shared_basepath / simulation_settings.output_filename - chk = self.shared_basepath / simulation_settings.checkpoint_storage + with without_oechem_backend(): + outputs = self.run(scratch_basepath=ctx.scratch, + shared_basepath=ctx.shared) - reporter = multistate.MultiStateReporter( - storage=nc, - analysis_particle_indices=selection_indices, - checkpoint_interval=simulation_settings.checkpoint_interval.m, - checkpoint_storage=chk, - ) + return { + 'repeat_id': self._inputs['repeat_id'], + 'generation': self._inputs['generation'], + 'simtype': 'vacuum', + **outputs + } - return reporter - def _get_ctx_caches( - self, - engine_settings: OpenMMEngineSettings - ) -> tuple[openmmtools.cache.ContextCache, openmmtools.cache.ContextCache]: +class AbsoluteSolventTransformUnit(BaseAbsoluteTransformUnit): + def _get_components(self): """ - Set the context caches based on the chosen platform - - Parameters - ---------- - engine_settings : OpenMMEngineSettings, + Get the relevant components for a vacuum transformation. Returns ------- - energy_context_cache : openmmtools.cache.ContextCache - The energy state context cache. - sampler_context_cache : openmmtools.cache.ContextCache - The sampler state context cache. - """ - platform = compute.get_openmm_platform( - engine_settings.compute_platform, - ) - - energy_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_line=None, platform=platform, - ) - - sampler_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_line=None, platform=platform, - ) - - return energy_context_cache, sampler_context_cache - - def _get_integrator( - self, - integrator_settings: IntegratorSettings - ) -> openmmtools.mcmc.LangevinDynamicsMove: + alchem_comps : list[Component] + A list of alchemical components + solv_comp : SolventComponent + The SolventComponent of the system + prot_comp : Optional[ProteinComponent] + The protein component of the system, if it exists. + small_mols : list[SmallMoleculeComponent] + A list of SmallMoleculeComponents to add to the system. """ - Return a LangevinDynamicsMove integrator + stateA = self._inputs['stateA'] + alchem_comps = self._inputs['alchemical_components'] - Parameters - ---------- - integrator_settings : IntegratorSettings + solv_comp, prot_comp, small_mols = system_validation.get_components(stateA) - Returns - ------- - integrator : openmmtools.mcmc.LangevinDynamicsMove - A configured integrator object. - """ - integrator = openmmtools.mcmc.LangevinDynamicsMove( - timestep=to_openmm(integrator_settings.timestep), - collision_rate=to_openmm(integrator_settings.collision_rate), - n_steps=integrator_settings.n_steps.m, - reassign_velocities=integrator_settings.reassign_velocities, - n_restart_attempts=integrator_settings.n_restart_attempts, - constraint_tolerance=integrator_settings.constraint_tolerance, - ) + # Should we validate prot_comp here? What if we want to do + # a solvent phase simulation of a small peptide? + # Keeping it flexible for now! - return integrator + # We don't need to check that solv_comp is not None, otherwise + # an error will have been raised when calling `validate_solvent` + # in the Protocol's `_create`. + return alchem_comps, solv_comp, prot_comp, small_mols - def _get_sampler( - self, - integrator: openmmtools.mcmc.LangevinDynamicsMove, - reporter: openmmtools.multistate.MultiStateReporter, - sampler_settings: AlchemicalSamplerSettings, - cmp_states: list[ThermodynamicState], - sampler_states: list[SamplerState], - energy_context_cache: openmmtools.cache.ContextCache, - sampler_context_cache: openmmtools.cache.ContextCache - ) -> multistate.MultiStateSampler: + def _handle_settings(self): """ - Get a sampler based on the equilibrium sampling method requested. - - Parameters - ---------- - integrator : openmmtools.mcmc.LangevinDynamicsMove - The simulation integrator. - reporter : openmmtools.multistate.MultiStateReporter - The reporter to hook up to the sampler. - sampler_settings : AlchemicalSamplerSettings - Settings for the alchemical sampler. - cmp_states : list[ThermodynamicState] - A list of thermodynamic states to sample. - sampler_states : list[SamplerState] - A list of sampler states. - energy_context_cache : openmmtools.cache.ContextCache - Context cache for the energy states. - sampler_context_cache : openmmtool.cache.ContextCache - Context cache for the sampler states. + Extract the relevant settings for a vacuum transformation. Returns ------- - sampler : multistate.MultistateSampler - A sampler configured for the chosen sampling method. - """ - - # Select the right sampler - # Note: doesn't need else, settings already validates choices - if sampler_settings.sampler_method.lower() == "repex": - sampler = multistate.ReplicaExchangeSampler( - mcmc_moves=integrator, - online_analysis_interval=sampler_settings.online_analysis_interval, - online_analysis_target_error=sampler_settings.online_analysis_target_error.m, - online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations - ) - elif sampler_settings.sampler_method.lower() == "sams": - sampler = multistate.SAMSSampler( - mcmc_moves=integrator, - online_analysis_interval=sampler_settings.online_analysis_interval, - online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations, - flatness_criteria=sampler_settings.flatness_criteria, - gamma0=sampler_settings.gamma0, - ) - elif sampler_settings.sampler_method.lower() == 'independent': - sampler = multistate.MultiStateSampler( - mcmc_moves=integrator, - online_analysis_interval=sampler_settings.online_analysis_interval, - online_analysis_target_error=sampler_settings.online_analysis_target_error.m, - online_analysis_minimum_iterations=sampler_settings.online_analysis_minimum_iterations - ) - - sampler.create( - thermodynamic_states=cmp_states, - sampler_states=sampler_states, - storage=reporter - ) - - sampler.energy_context_cache = energy_context_cache - sampler.sampler_context_cache = sampler_context_cache - - return sampler - - def _run_simulation( - self, - sampler: multistate.MultiStateSampler, - reporter: multistate.MultiStateReporter, - settings: dict[str, SettingsBaseModel], - dry: bool - ): - """ - Run the simulation. - - Parameters - ---------- - sampler : multistate.MultiStateSampler - The sampler associated with the simulation to run. - reporter : multistate.MultiStateReporter - The reporter associated with the sampler. settings : dict[str, SettingsBaseModel] - The dictionary of settings for the protocol. - dry : bool - Whether or not to dry run the simulation - """ - # Get the relevant simulation steps - mc_steps = settings['integrator_settings'].n_steps.m - - equil_steps, prod_steps = settings_validation.get_simsteps( - equil_length=settings['simulation_settings'].equilibration_length, - prod_length=settings['simulation_settings'].production_length, - timestep=settings['integrator_settings'].timestep, - mc_steps=mc_steps, - ) - - if not dry: # pragma: no-cover - # minimize - if self.verbose: - self.logger.info("minimizing systems") - - sampler.minimize( - max_iterations=settings['sim_settings'].minimization_steps - ) - - # equilibrate - if self.verbose: - self.logger.info("equilibrating systems") - - sampler.equilibrate(int(equil_steps / mc_steps)) # type: ignore - - # production - if self.verbose: - self.logger.info("running production phase") - - sampler.extend(int(prod_steps / mc_steps)) # type: ignore - - # close reporter when you're done - reporter.close() - - nc = self.shared_basepath / settings['simulation_settings'].output_filename - chk = self.shared_basepath / settings['simulation_settings'].checkpoint_storage - return { - 'nc': nc, - 'last_checkpoint': chk, - } - else: - # close reporter when you're done, prevent file handle clashes - reporter.close() - - # clean up the reporter file - fns = [self.shared_basepath / settings['simulation_settings'].output_filename, - self.shared_basepath / settings['simulation_settings'].checkpoint_storage] - for fn in fns: - os.remove(fn) - return {'debug': {'sampler': sampler}} - - def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: - """Run the absolute free energy calculation. + A dictionary with the following entries: + * forcefield_settings : OpenMMSystemGeneratorFFSettings + * thermo_settings : ThermoSettings + * system_settings : SystemSettings + * solvation_settings : SolvationSettings + * alchemical_settings : AlchemicalSettings + * sampler_settings : AlchemicalSamplerSettings + * engine_settings : OpenMMEngineSettings + * integrator_settings : IntegratorSettings + * simulation_settings : SimulationSettings + """ + prot_settings = self._inputs['settings'] + + settings = {} + settings['forcefield_settings'] = prot_settings.forcefield_settings + settings['thermo_settings'] = prot_settings.thermo_settings + settings['system_settings'] = prot_settings.solvent_system_settings + settings['solvation_settings'] = prot_settings.solvation_settings + settings['alchemical_settings'] = prot_settings.alchemical_settings + settings['sampler_settings'] = prot_settings.alchemsampler_settings + settings['engine_setttings'] = prot_settings.engine_settings + settings['integrator_settings'] = prot_settings.integrator_settings + settings['simulation_settings'] = prot_settings.solvent_simulation_settings - Parameters - ---------- - dry : bool - Do a dry run of the calculation, creating all necessary alchemical - system components (topology, system, sampler, etc...) but without - running the simulation. - verbose : bool - Verbose output of the simulation progress. Output is provided via - INFO level logging. - basepath : Pathlike, optional - Where to run the calculation, defaults to current working directory - - Returns - ------- - dict - Outputs created in the basepath directory or the debug objects - (i.e. sampler) if ``dry==True``. - - Attributes - ---------- - solvent : Optional[SolventComponent] - SolventComponent to be applied to the system - protein : Optional[ProteinComponent] - ProteinComponent for the system - openff_mols : List[openff.Molecule] - List of OpenFF Molecule objects for each SmallMoleculeComponent in - the stateA ChemicalSystem - """ - # 0. Generaly preparation tasks - self._prepare(verbose, basepath) - - # 1. Get components - alchem_comps, solv_comp, prot_comp, smc_comps = self._get_components() - - # 2. Get settings - settings = self._handle_settings() - - # 3. Get system generator - system_generator = self._get_system_generator(settings, solv_comp) - - # 4. Get modeller - system_modeller, comp_resids = self._get_modeller( - prot_comp, solv_comp, smc_comps, system_generator, - settings['solvation_settings'] - ) - - # 5. Get OpenMM topology, positions and system - omm_topology, omm_system, positions = self._get_omm_objects( - system_generator, system_modeller, smc_comps - ) - - # 6. Pre-minimize System (Test + Avoid NaNs) - positions = self._pre_minimize(omm_system, positions) - - # 7. Get lambdas - lambdas = self._get_lambda_schedule(settings) - - # 8. Add restraints - self._add_restraints(omm_system, omm_topology, settings) - - # 9. Get alchemical system - alchem_system, alchem_factory = self._get_alchemical_system( - omm_topology, omm_system, comp_resids, alchem_comps - ) - - # 10. Get compound and sampler states - cmp_states, sampler_states = self._get_states( - alchem_system, positions, settings, - lambdas, solv_comp - ) - - # 11. Create the multistate reporter & create PDB - reporter = self._get_reporter( - omm_topology, settings['simulation_setttings'], + settings_validation.validate_timestep( + settings['forcefield_settings'].hyrodgen_mass, + settings['integrator_settings'].timestep ) - # Wrap in try/finally to avoid memory leak issues - try: - # 12. Get context caches - energy_ctx_cache, sampler_ctx_cache = self._get_ctx_caches( - settings['engine_settings'] - ) - - # 13. Get integrator - integrator = self._get_integrator(settings['integrator_settings']) - - # 14. Get sampler - sampler = self._get_sampler( - integrator, reporter, settings['sampler_settings'], - cmp_states, sampler_states, - energy_ctx_cache, sampler_ctx_cache - ) - - # 15. Run simulation - self._run_simulation( - sampler, reporter, settings, dry - ) - finally: - # close reporter when you're done to prevent file handle clashes - reporter.close() - - # clear GPU context - # Note: use cache.empty() when openmmtools #690 is resolved - for context in list(energy_ctx_cache._lru._data.keys()): - del energy_ctx_cache._lru._data[context] - for context in list(sampler_ctx_cache._lru._data.keys()): - del sampler_ctx_cache._lru._data[context] - # cautiously clear out the global context cache too - for context in list( - openmmtools.cache.global_context_cache._lru._data.keys()): - del openmmtools.cache.global_context_cache._lru._data[context] - - del sampler_ctx_cache, energy_ctx_cache - - # Keep these around in a dry run so we can inspect things - if not dry: - del integrator, sampler - def _execute( self, ctx: gufe.Context, **kwargs, ) -> Dict[str, Any]: - # create directory for *this* unit within the context of the *DAG* - # stops output files mashing into each other within a DAG - myid = uuid.uuid4() - mypath = pathlib.Path(os.path.join(ctx.shared, str(myid))) - mypath.mkdir(parents=True, exist_ok=False) + log_system_probe(logging.INFO, paths=[ctx.scratch]) - outputs = self.run(basepath=mypath) + with without_oechem_backend(): + outputs = self.run(scratch_basepath=ctx.scratch, + shared_basepath=ctx.shared) return { 'repeat_id': self._inputs['repeat_id'], 'generation': self._inputs['generation'], + 'simtype': 'vacuum', **outputs } From b587d3622378e6e0301acef206ec49011b8adcad Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 6 Oct 2023 11:52:17 +0100 Subject: [PATCH 39/74] Add estimate return --- .../openmm_afe/equil_solvation_afe_method.py | 153 ++++++++++-------- 1 file changed, 88 insertions(+), 65 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 27cb307b8..7ee95f3d8 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -34,9 +34,7 @@ from gufe.components import Component import numpy as np from openff.units import unit -from openmmtools import multistate from typing import Dict, Optional -from openmm import unit as omm_unit from typing import Any, Iterable from gufe import ( @@ -57,72 +55,109 @@ class AbsoluteTransformProtocolResult(gufe.ProtocolResult): - """Dict-like container for the output of a AbsoluteTransform""" + """Dict-like container for the output of a AbsoluteTransform + + TODO + ---- + * Add in methods to retreive forward/backwards analyses + * Add in methods to retreive the overlap matrices + * Add in method to get replica transition stats + * Add in method to get replica states + * Add in method to get equilibration and production iterations + """ def __init__(self, **data): super().__init__(**data) # TODO: Detect when we have extensions and stitch these together? - if any(len(files['nc_paths']) > 2 for files in self.data['nc_files']): + if any(len(pur_list) > 2 for pur_list in self.data.values()): raise NotImplementedError("Can't stitch together results yet") - self._analyzers = [] - for f in self.data['nc_files']: - nc = f['nc_paths'][0] - chk = f['checkpoint_paths'][0] - reporter = multistate.MultiStateReporter( - storage=nc, - checkpoint_storage=chk) - analyzer = multistate.MultiStateSamplerAnalyzer(reporter) - self._analyzers.append(analyzer) - - def get_estimate(self): - """Free energy difference of this transformation + def get_vacuum_individual_estimates(self) -> list[tuple[unit.Quantity, unit.Quantity]]: + """ + Return a list of tuples containing the individual free energy + estimates and associated MBAR errors for each repeat of the vacuum + calculation. Returns ------- - dG : unit.Quantity - The free energy difference between the first and last states. This is - a Quantity defined with units. + dGs : list[tuple[unit.Quantity, unit.Quantity]] + """ + dGs = [] + + for pus in self.data.values(): + if pus[0].outputs['simtype'] == 'vacuum': + dGs.append(( + pus[0].outputs['unit_estimate'], + pus[0].outputs['unit_estimate_error'] + )) - TODO - ---- - * Check this holds up completely for SAMS. + return dGs + + def get_solvent_individual_estimates(self) -> list[tuple[unit.Quantity, unit.Quantity]]: + """ + Return a list of tuples containing the individual free energy + estimates and associated MBAR errors for each repeat of the solvent + calculation. + + Returns + ------- + dGs : list[tuple[unit.Quantity, unit.Quantity]] """ dGs = [] - for analyzer in self._analyzers: - # this returns: - # (matrix of) estimated free energy difference - # (matrix of) estimated statistical uncertainty (one S.D.) - dG, _ = analyzer.get_free_energy() - dG = (dG[0, -1] * analyzer.kT).in_units_of( - omm_unit.kilocalories_per_mole) + for pus in self.data.values(): + if pus[0].outputs['simtype'] == 'solvent': + dGs.append(( + pus[0].outputs['unit_estimate'], + pus[0].outputs['unit_estimate_error'] + )) + + return dGs + + def get_estimate(self): + """Get the solvation free energy estimate for this calculation. - dGs.append(dG) + Returns + ------- + dG : unit.Quantity + The solvation free energy. This is a Quantity defined with units. + """ + def _get_average(estimates): + # Get the unit value of the first value in the estimates + u = estimates[0][0].u + # Loop through estimates and get the free energy values + # in the unit of the first estimate + dGs = [i[0].to(u).m for i in estimates] - avg_val = np.average([i.value_in_unit(dGs[0].unit) for i in dGs]) + return np.average(dGs) * u - return avg_val * dGs[0].unit + vac_dG = _get_average(self.get_vacuum_individual_estimates(self)) + solv_dG = _get_average(self.get_solvent_individual_estimates(self)) - def get_uncertainty(self): - """The uncertainty/error in the dG value""" - dGs = [] + return vac_dG - solv_dG - for analyzer in self._analyzers: - # this returns: - # (matrix of) estimated free energy difference - # (matrix of) estimated statistical uncertainty (one S.D.) - dG, _ = analyzer.get_free_energy() - dG = (dG[0, -1] * analyzer.kT).in_units_of( - omm_unit.kilocalories_per_mole) + def get_uncertainty(self): + """Get the solvation free energy error for this calculation. - dGs.append(dG) + Returns + ------- + err : unit.Quantity + The standard deviation between estimates of the solvation free + energy. This is a Quantity defined with units. + """ + def _get_stdev(estimates): + # Get the unit value of the first value in the estimates + u = estimates[0][0].u + # Loop through estimates and get the free energy values + # in the unit of the first estimate + dGs = [i[0].to(u).m for i in estimates] - std_val = np.std([i.value_in_unit(dGs[0].unit) for i in dGs]) + return np.std(dGs) * u - return std_val * dGs[0].unit + vac_err = _get_stdev(self.get_vacuum_individual_estimates(self)) + solv_err = _get_stdev(self.get_solvent_individual_estimates(self)) - def get_rate_of_convergence(self): # pragma: no-cover - raise NotImplementedError + # return the combined error + return np.sqrt(vac_err**2 + solv_err**2) class AbsoluteSolvationProtocol(gufe.Protocol): @@ -318,24 +353,12 @@ def _gather( for pu in d.protocol_unit_results: if not pu.ok(): continue - rep = pu.outputs['repeat_id'] - unsorted_repeats[rep].append(pu) - - data = [] - for rep_id, rep_data in sorted(repeats.items()): - # then sort within a repeat according to generation - nc_paths = [ - ncpath for gen, ncpath, nc_check in sorted(rep_data) - ] - chk_files = [ - nc_check for gen, ncpath, nc_check in sorted(rep_data) - ] - data.append({'nc_paths': nc_paths, - 'checkpoint_paths': chk_files}) + unsorted_repeats[pu.outputs['repeat_id']].append(pu) - return { - 'nc_files': data, - } + repeats: dict[str, list[gufe.ProtocolUnitResult]] = {} + for k, v in unsorted_repeats.items(): + repeats[str(k)] = sorted(v, key=lambda x: x.outputs['generation']) + return repeats class AbsoluteVacuumTransformUnit(BaseAbsoluteTransformUnit): @@ -499,6 +522,6 @@ def _execute( return { 'repeat_id': self._inputs['repeat_id'], 'generation': self._inputs['generation'], - 'simtype': 'vacuum', + 'simtype': 'solvent', **outputs } From 7142c7fca36bacd144ffdde6b1a7157aa5099227 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 6 Oct 2023 16:00:30 +0100 Subject: [PATCH 40/74] fix up tests --- openfe/protocols/openmm_afe/__init__.py | 13 +- openfe/protocols/openmm_afe/base.py | 78 ++-- .../openmm_afe/equil_afe_settings.py | 8 +- .../openmm_afe/equil_solvation_afe_method.py | 80 ++-- .../test_openmm_abfe_equil_protocols.py | 359 --------------- .../protocols/test_openmm_abfe_settings.py | 178 ------- .../protocols/test_openmm_afe_settings.py | 30 ++ .../test_openmm_afe_solvation_protocol.py | 435 ++++++++++++++++++ 8 files changed, 554 insertions(+), 627 deletions(-) delete mode 100644 openfe/tests/protocols/test_openmm_abfe_equil_protocols.py delete mode 100644 openfe/tests/protocols/test_openmm_abfe_settings.py create mode 100644 openfe/tests/protocols/test_openmm_afe_settings.py create mode 100644 openfe/tests/protocols/test_openmm_afe_solvation_protocol.py diff --git a/openfe/protocols/openmm_afe/__init__.py b/openfe/protocols/openmm_afe/__init__.py index 77bcfa71d..c5023d969 100644 --- a/openfe/protocols/openmm_afe/__init__.py +++ b/openfe/protocols/openmm_afe/__init__.py @@ -1,9 +1,10 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -from .solvation_afe_method import ( - AbsoluteTransformProtocol, - AbsoluteTransformSettings, - AbsoluteTransformProtocolResult, - AbsoluteTransformUnit, -) \ No newline at end of file +from .equil_solvation_afe_method import ( + AbsoluteSolvationProtocol, + AbsoluteSolvationSettings, + AbsoluteSolvationProtocolResult, + AbsoluteVacuumTransformUnit, + AbsoluteSolventTransformUnit, +) diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py index 8beef798d..5c983b06e 100644 --- a/openfe/protocols/openmm_afe/base.py +++ b/openfe/protocols/openmm_afe/base.py @@ -73,7 +73,6 @@ settings_validation, system_creation, multistate_analysis ) -from openfe.utils import without_oechem_backend, log_system_probe logger = logging.getLogger(__name__) @@ -183,13 +182,13 @@ def _pre_minimize(system: openmm.System, """ integrator = openmm.VerletIntegrator(0.001) context = openmm.Context( - system, integrator, - openmm.Platform.getPlatformByName('CPU'), + system, integrator, + openmm.Platform.getPlatformByName('CPU'), ) context.setPositions(positions) # Do a quick 100 steps minimization, usually avoids NaNs openmm.LocalEnergyMinimizer.minimize( - context, maxIterations=100 + context, maxIterations=100 ) state = context.getState(getPositions=True) minimized_positions = state.getPositions(asNumpy=True) @@ -231,13 +230,7 @@ def _get_components(self): Note ---- - Must be implemented in child class. - - To move: - stateA = self._inputs['stateA'] - alchem_comps = self._inputs['alchemical_components'] - # Get the relevant solvent & protein components & openff molecules - solvent_comp, protein_comp, off_mols = self._parse_components(stateA) + Must be implemented in the child class. """ raise NotImplementedError @@ -259,11 +252,9 @@ def _handle_settings(self): This method should also add various validation checks as necessary. - # a. Validation checks - settings_validation.validate_timestep( - settings.forcefield_settings.hydrogen_mass, - settings.integrator_settings.timestep - ) + Note + ---- + Must be implemented in the child class. """ raise NotImplementedError @@ -294,6 +285,7 @@ def _get_system_generator( system_generator = system_creation.get_system_generator( forcefield_settings=settings['forcefield_settings'], thermo_settings=settings['thermo_settings'], + system_settings=settings['system_settings'], cache=ffcache, has_solvent=solvent_comp is not None, ) @@ -381,10 +373,10 @@ def _get_omm_objects( ------- topology : app.Topology Topology object describing the parameterized system - positionns : openmm.unit.Quantity - Positions of the system. system : openmm.System An OpenMM System of the alchemical system. + positionns : openmm.unit.Quantity + Positions of the system. """ topology = system_modeller.getTopology() # roundtrip positions to remove vec3 issues @@ -393,7 +385,7 @@ def _get_omm_objects( system_modeller.topology, molecules=[s.to_openff() for s in smc_components] ) - return topology, positions, system + return topology, system, positions def _get_lambda_schedule( self, settings: dict[str, SettingsBaseModel] @@ -525,8 +517,8 @@ def _get_states( """ alchemical_state = AlchemicalState.from_system(alchemical_system) # Set up the system constants - temperature = settings.thermo_settings.temperature - pressure = settings.thermo_settings.pressure + temperature = settings['thermo_settings'].temperature + pressure = settings['thermo_settings'].pressure constants = dict() constants['temperature'] = ensure_quantity(temperature, 'openmm') if solvent_comp is not None: @@ -534,7 +526,7 @@ def _get_states( cmp_states = create_thermodynamic_state_protocol( alchemical_system, protocol=lambdas, - consatnts=constants, composable_states=[alchemical_state], + constants=constants, composable_states=[alchemical_state], ) sampler_state = SamplerState(positions=positions) @@ -587,9 +579,9 @@ def _get_reporter( if len(selection_indices) > 0: traj = mdt.Trajectory( positions[selection_indices, :], - mdt_top, + mdt_top.subset(selection_indices), ) - traj.savepdb( + traj.save_pdb( self.shared_basepath / simulation_settings.output_structure ) @@ -618,11 +610,11 @@ def _get_ctx_caches( ) energy_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_line=None, platform=platform, + capacity=None, time_to_live=None, platform=platform, ) sampler_context_cache = openmmtools.cache.ContextCache( - capacity=None, time_to_line=None, platform=platform, + capacity=None, time_to_live=None, platform=platform, ) return energy_context_cache, sampler_context_cache @@ -812,7 +804,8 @@ def _run_simulation( return None - def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: + def run(self, dry=False, verbose=True, + scratch_basepath=None, shared_basepath=None) -> Dict[str, Any]: """Run the absolute free energy calculation. Parameters @@ -844,7 +837,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: the stateA ChemicalSystem """ # 0. Generaly preparation tasks - self._prepare(verbose, basepath) + self._prepare(verbose, scratch_basepath, shared_basepath) # 1. Get components alchem_comps, solv_comp, prot_comp, smc_comps = self._get_components() @@ -863,7 +856,7 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: # 5. Get OpenMM topology, positions and system omm_topology, omm_system, positions = self._get_omm_objects( - system_generator, system_modeller, smc_comps + system_modeller, system_generator, smc_comps ) # 6. Pre-minimize System (Test + Avoid NaNs) @@ -876,27 +869,27 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: self._add_restraints(omm_system, omm_topology, settings) # 9. Get alchemical system - alchem_system, alchem_factory = self._get_alchemical_system( + alchem_factory, alchem_system, alchem_indices = self._get_alchemical_system( omm_topology, omm_system, comp_resids, alchem_comps ) # 10. Get compound and sampler states - cmp_states, sampler_states = self._get_states( + sampler_states, cmp_states = self._get_states( alchem_system, positions, settings, lambdas, solv_comp ) # 11. Create the multistate reporter & create PDB reporter = self._get_reporter( - omm_topology, settings['simulation_setttings'], - positions, + omm_topology, positions, + settings['simulation_settings'], ) # Wrap in try/finally to avoid memory leak issues try: # 12. Get context caches energy_ctx_cache, sampler_ctx_cache = self._get_ctx_caches( - settings['engine_settings'] + settings['engine_settings'] ) # 13. Get integrator @@ -944,19 +937,4 @@ def run(self, dry=False, verbose=True, basepath=None) -> Dict[str, Any]: **unit_result_dict, } else: - return {'debug': {'sampler': sampler}} - - def _execute( - self, ctx: gufe.Context, **kwargs, - ) -> Dict[str, Any]: - log_system_probe(logging.INFO, paths=[ctx.scratch]) - - with without_oechem_backend(): - outputs = self.run(scratch_basepath=ctx.scratch, - shared_basepath=ctx.shared) - - return { - 'repeat_id': self._inputs['repeat_id'], - 'generation': self._inputs['generation'], - **outputs - } + return {'debug': {'sampler': sampler}} \ No newline at end of file diff --git a/openfe/protocols/openmm_afe/equil_afe_settings.py b/openfe/protocols/openmm_afe/equil_afe_settings.py index 6f1149003..069875537 100644 --- a/openfe/protocols/openmm_afe/equil_afe_settings.py +++ b/openfe/protocols/openmm_afe/equil_afe_settings.py @@ -55,7 +55,7 @@ def must_be_positive(cls, v): return v -class AbsoluteTransformSettings(Settings): +class AbsoluteSolvationSettings(Settings): class Config: arbitrary_types_allowed = True @@ -66,7 +66,8 @@ class Config: """Settings for thermodynamic parameters""" # Things for creating the systems - system_settings: SystemSettings + vacuum_system_settings: SystemSettings + solvent_system_settings: SystemSettings """ Simulation system settings including the long-range non-bonded methods. @@ -99,7 +100,8 @@ class Config: """ # Simulation run settings - simulation_settings: SimulationSettings + vacuum_simulation_settings: SimulationSettings + solvent_simulation_settings: SimulationSettings """ Simulation control settings, including simulation lengths and record-keeping. diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 7ee95f3d8..f56e9b91c 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -32,6 +32,7 @@ from collections import defaultdict import gufe from gufe.components import Component +import itertools import numpy as np from openff.units import unit from typing import Dict, Optional @@ -42,7 +43,7 @@ ProteinComponent, SolventComponent ) from openfe.protocols.openmm_afe.equil_afe_settings import ( - AbsoluteTransformSettings, SystemSettings, + AbsoluteSolvationSettings, SystemSettings, SolvationSettings, AlchemicalSettings, AlchemicalSamplerSettings, OpenMMEngineSettings, IntegratorSettings, SimulationSettings, @@ -54,8 +55,8 @@ logger = logging.getLogger(__name__) -class AbsoluteTransformProtocolResult(gufe.ProtocolResult): - """Dict-like container for the output of a AbsoluteTransform +class AbsoluteSolvationProtocolResult(gufe.ProtocolResult): + """Dict-like container for the output of a AbsoluteSolventTransform TODO ---- @@ -161,8 +162,8 @@ def _get_stdev(estimates): class AbsoluteSolvationProtocol(gufe.Protocol): - result_cls = AbsoluteTransformProtocolResult - _settings: AbsoluteTransformSettings + result_cls = AbsoluteSolvationProtocolResult + _settings: AbsoluteSolvationSettings @classmethod def _default_settings(cls): @@ -177,7 +178,7 @@ def _default_settings(cls): Settings a set of default settings """ - return AbsoluteTransformSettings( + return AbsoluteSolvationSettings( forcefield_settings=settings.OpenMMSystemGeneratorFFSettings(), thermo_settings=settings.ThermoSettings( temperature=298.15 * unit.kelvin, @@ -186,7 +187,9 @@ def _default_settings(cls): solvent_system_settings=SystemSettings(), vacuum_system_settings=SystemSettings(nonbonded_method='nocutoff'), alchemical_settings=AlchemicalSettings(), - alchemsampler_settings=AlchemicalSamplerSettings(), + alchemsampler_settings=AlchemicalSamplerSettings( + n_replicas=24, + ), solvation_settings=SolvationSettings(), engine_settings=OpenMMEngineSettings(), integrator_settings=IntegratorSettings(), @@ -210,7 +213,10 @@ def _validate_solvent_endstates( ) -> None: """ A solvent transformation is defined (in terms of gufe components) - as starting from a ligand in solvent and ending up just in solvent. + as starting from one or more ligands in solvent and + ending up in a state with one less ligand. + + No protein components are allowed. Parameters ---------- @@ -222,20 +228,29 @@ def _validate_solvent_endstates( Raises ------ ValueError - If stateB contains anything else but a SolventComponent. - If stateA contains a ProteinComponent + If stateA or stateB contains a ProteinComponent + If there is no SolventComponent in either stateA or stateB """ - if ((len(stateB) != 1) or - (not isinstance(stateB.values()[0], SolventComponent))): - errmsg = "Only a single SolventComponent is allowed in stateB" - raise ValueError(errmsg) - - for comp in stateA.values(): + # Check that there are no protein components + for comp in itertools.chain(stateA.values(), stateB.values()): if isinstance(comp, ProteinComponent): - errmsg = ("Protein components are not allow for " + errmsg = ("Protein components are not allowed for " "absolute solvation free energies") raise ValueError(errmsg) + # check that there is a solvent component + if not any( + isinstance(comp, SolventComponent) for comp in stateA.values() + ): + errmsg = "No SolventComponent found in stateA" + raise ValueError(errmsg) + + if not any( + isinstance(comp, SolventComponent) for comp in stateB.values() + ): + errmsg = "No SolventComponent found in stateB" + raise ValueError(errmsg) + @staticmethod def _validate_alchemical_components( alchemical_components: dict[str, list[Component]] @@ -274,6 +289,7 @@ def _validate_alchemical_components( if len(alchemical_components['stateA']) > 1: errmsg = ("More than one alchemical components is not supported " "for absolute solvation free energies") + raise ValueError(errmsg) # Crash out if any of the alchemical components are not # SmallMoleculeComponent @@ -295,7 +311,7 @@ def _create( raise NotImplementedError("Can't extend simulations yet") # Validate components and get alchemical components - self._validate_solvation_endstates(stateA, stateB) + self._validate_solvent_endstates(stateA, stateB) alchem_comps = system_validation.get_alchemical_components( stateA, stateB, ) @@ -307,7 +323,11 @@ def _create( # Use the more complete system validation solvent checks system_validation.validate_solvent(stateA, solv_nonbonded_method) # Gas phase is always gas phase - assert vac_nonbonded_method.lower() != 'pme' + if vac_nonbonded_method.lower() != 'nocutoff': + errmsg = ("Only the nocutoff nonbonded_method is supported for " + f"vacuum calculations, {vac_nonbonded_method} was " + "passed") + raise ValueError(errmsg) # Get the name of the alchemical species alchname = alchem_comps['stateA'][0].name @@ -383,10 +403,6 @@ def _get_components(self): _, prot_comp, small_mols = system_validation.get_components(stateA) - # Should we validate prot_comp here? What if we want to do - # a gas phase simulation of a small peptide? - # Keeping it flexible for now! - # Note our input state will contain a solvent, we ``None`` that out # since this is the gas phase unit. return alchem_comps, None, prot_comp, small_mols @@ -418,15 +434,17 @@ def _handle_settings(self): settings['solvation_settings'] = prot_settings.solvation_settings settings['alchemical_settings'] = prot_settings.alchemical_settings settings['sampler_settings'] = prot_settings.alchemsampler_settings - settings['engine_setttings'] = prot_settings.engine_settings + settings['engine_settings'] = prot_settings.engine_settings settings['integrator_settings'] = prot_settings.integrator_settings settings['simulation_settings'] = prot_settings.vacuum_simulation_settings settings_validation.validate_timestep( - settings['forcefield_settings'].hyrodgen_mass, + settings['forcefield_settings'].hydrogen_mass, settings['integrator_settings'].timestep ) + return settings + def _execute( self, ctx: gufe.Context, **kwargs, ) -> Dict[str, Any]: @@ -465,13 +483,11 @@ def _get_components(self): solv_comp, prot_comp, small_mols = system_validation.get_components(stateA) - # Should we validate prot_comp here? What if we want to do - # a solvent phase simulation of a small peptide? - # Keeping it flexible for now! - # We don't need to check that solv_comp is not None, otherwise # an error will have been raised when calling `validate_solvent` # in the Protocol's `_create`. + # Similarly we don't need to check prot_comp since that's also + # disallowed on create return alchem_comps, solv_comp, prot_comp, small_mols def _handle_settings(self): @@ -501,15 +517,17 @@ def _handle_settings(self): settings['solvation_settings'] = prot_settings.solvation_settings settings['alchemical_settings'] = prot_settings.alchemical_settings settings['sampler_settings'] = prot_settings.alchemsampler_settings - settings['engine_setttings'] = prot_settings.engine_settings + settings['engine_settings'] = prot_settings.engine_settings settings['integrator_settings'] = prot_settings.integrator_settings settings['simulation_settings'] = prot_settings.solvent_simulation_settings settings_validation.validate_timestep( - settings['forcefield_settings'].hyrodgen_mass, + settings['forcefield_settings'].hydrogen_mass, settings['integrator_settings'].timestep ) + return settings + def _execute( self, ctx: gufe.Context, **kwargs, ) -> Dict[str, Any]: diff --git a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py b/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py deleted file mode 100644 index 254d9788c..000000000 --- a/openfe/tests/protocols/test_openmm_abfe_equil_protocols.py +++ /dev/null @@ -1,359 +0,0 @@ -# This code is part of OpenFE and is licensed under the MIT license. -# For details, see https://github.com/OpenFreeEnergy/openfe -import pytest -from unittest import mock -from openmmtools.multistate.multistatesampler import MultiStateSampler -from openff.units import unit as offunit -import gufe -from openfe import ChemicalSystem, SolventComponent -from openfe.protocols import openmm_afe - - -@pytest.fixture -def vacuum_system(): - return ChemicalSystem({}) - - -@pytest.fixture -def benzene_vacuum_system(benzene_modifications): - return ChemicalSystem( - {'ligand': benzene_modifications['benzene']}, - ) - - -@pytest.fixture -def solvent_system(): - return ChemicalSystem( - {'solvent': SolventComponent(), } - ) - - -@pytest.fixture -def benzene_solvent_system(benzene_modifications): - return ChemicalSystem( - {'ligand': benzene_modifications['benzene'], - 'solvent': SolventComponent(), - }, - ) - - -@pytest.fixture -def protein_system(benzene_modifications, T4_protein_component): - return ChemicalSystem( - {'solvent': SolventComponent(), - 'protein': T4_protein_component, } - ) - - -@pytest.fixture -def benzene_complex_system(benzene_modifications, T4_protein_component): - return ChemicalSystem( - {'ligand': benzene_modifications['benzene'], - 'solvent': SolventComponent(), - 'protein': T4_protein_component, } - ) - - -@pytest.fixture -def vacuum_protocol_dag(benzene_vacuum_system, vacuum_system): - settings = openmm_afe.AbsoluteTransformProtocol.default_settings() - protocol = openmm_afe.AbsoluteTransformProtocol(settings=settings) - return protocol.create(stateA=benzene_vacuum_system, stateB=vacuum_system, - mapping=None) - - -def test_create_default_protocol(): - # this is roughly how it should be created - protocol = openmm_afe.AbsoluteTransformProtocol( - settings=openmm_afe.AbsoluteTransformProtocol.default_settings(), - ) - assert protocol - - -def test_serialize_protocol(): - protocol = openmm_afe.AbsoluteTransformProtocol( - settings=openmm_afe.AbsoluteTransformProtocol.default_settings(), - ) - - ser = protocol.to_dict() - ret = openmm_afe.AbsoluteTransformProtocol.from_dict(ser) - assert protocol == ret - - -def test_get_alchemical_components(benzene_modifications, - T4_protein_component): - stateA = ChemicalSystem({ - 'benzene': benzene_modifications['benzene'], - 'toluene': benzene_modifications['toluene'], - 'protein': T4_protein_component, - 'solvent': SolventComponent(neutralize=False) - }) - - stateB = ChemicalSystem({ - 'benzene': benzene_modifications['benzene'], - 'phenol': benzene_modifications['phenol'], - 'solvent': SolventComponent(), - }) - - func = openmm_afe.AbsoluteTransformProtocol._get_alchemical_components - - comps = func(stateA, stateB) - - assert len(comps['stateA']) == 3 - assert len(comps['stateB']) == 2 - - for i in ['toluene', 'protein', 'solvent']: - assert i in comps['stateA'] - - for i in ['phenol', 'solvent']: - assert i in comps['stateB'] - - -def test_validate_alchem_comps_stateB(): - - func = openmm_afe.AbsoluteTransformProtocol._validate_alchemical_components - - stateA = ChemicalSystem({}) - alchem_comps = {'stateA': [], 'stateB': ['foo', 'bar']} - with pytest.raises(ValueError, match="Components appearing in state B"): - func(stateA, alchem_comps) - - -@pytest.mark.parametrize('key', ['protein', 'solvent']) -def test_validate_alchem_comps_non_small(key, T4_protein_component): - - func = openmm_afe.AbsoluteTransformProtocol._validate_alchemical_components - - stateA = ChemicalSystem({ - 'protein': T4_protein_component, - 'solvent': SolventComponent(), - }) - - alchem_comps = {'stateA': [key,], 'stateB': []} - with pytest.raises(ValueError, match='Non SmallMoleculeComponent'): - func(stateA, alchem_comps) - - -def test_validate_solvent_vacuum(): - - state = ChemicalSystem({'solvent': SolventComponent()}) - - func = openmm_afe.AbsoluteTransformProtocol._validate_solvent - - with pytest.raises(ValueError, match="cannot be used for solvent"): - func(state, 'NoCutoff') - - -def test_validate_solvent_double_solvent(): - - state = ChemicalSystem({ - 'solvent': SolventComponent(), - 'solvent-two': SolventComponent(neutralize=False) - }) - - func = openmm_afe.AbsoluteTransformProtocol._validate_solvent - - with pytest.raises(ValueError, match="only one is supported"): - func(state, 'pme') - - -def test_parse_components_expected(T4_protein_component, - benzene_modifications): - - func = openmm_afe.AbsoluteTransformUnit._parse_components - - chem = ChemicalSystem({ - 'protein': T4_protein_component, - 'benzene': benzene_modifications['benzene'], - 'solvent': SolventComponent(), - 'toluene': benzene_modifications['toluene'], - 'phenol': benzene_modifications['phenol'], - }) - - solvent_comp, protein_comp, off_small_mols = func(chem) - - assert len(off_small_mols.keys()) == 3 - assert protein_comp == T4_protein_component - assert solvent_comp == SolventComponent() - - for i in ['benzene', 'toluene', 'phenol']: - off_small_mols[i] == benzene_modifications[i].to_openff() - - -def test_parse_components_multi_protein(T4_protein_component): - - func = openmm_afe.AbsoluteTransformUnit._parse_components - - chem = ChemicalSystem({ - 'protein': T4_protein_component, - 'protien2': T4_protein_component, # should this even be allowed? - }) - - with pytest.raises(ValueError, match="Multiple proteins"): - func(chem) - - -def test_simstep_return(): - - func = openmm_afe.AbsoluteTransformUnit._get_sim_steps - - steps = func(time=250000 * offunit.femtoseconds, - timestep=4 * offunit.femtoseconds, - mc_steps=250) - - # check the number of steps for a 250 ps simulations - assert steps == 62500 - - -def test_simstep_undivisible_mcsteps(): - - func = openmm_afe.AbsoluteTransformUnit._get_sim_steps - - with pytest.raises(ValueError, match="divisible by the number"): - func(time=780 * offunit.femtoseconds, - timestep=4 * offunit.femtoseconds, - mc_steps=100) - - -@pytest.mark.parametrize('method', [ - 'repex', 'sams', 'independent', 'InDePeNdENT' -]) -def test_dry_run_default_vacuum(benzene_vacuum_system, vacuum_system, - method, tmpdir): - vac_settings = openmm_afe.AbsoluteTransformProtocol.default_settings() - vac_settings.system_settings.nonbonded_method = 'nocutoff' - vac_settings.alchemsampler_settings.sampler_method = method - vac_settings.alchemsampler_settings.n_repeats = 1 - - protocol = openmm_afe.AbsoluteTransformProtocol( - settings=vac_settings, - ) - - # create DAG from protocol and take first (and only) work unit from within - dag = protocol.create( - stateA=benzene_vacuum_system, - stateB=vacuum_system, - mapping=None, - ) - unit = list(dag.protocol_units)[0] - - with tmpdir.as_cwd(): - sampler = unit.run(dry=True)['debug']['sampler'] - assert isinstance(sampler, MultiStateSampler) - assert not sampler.is_periodic - - -@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) -def test_dry_run_solvent(benzene_solvent_system, solvent_system, method, tmpdir): - # this might be a bit time consuming - settings = openmm_afe.AbsoluteTransformProtocol.default_settings() - settings.alchemsampler_settings.sampler_method = method - settings.alchemsampler_settings.n_repeats = 1 - - protocol = openmm_afe.AbsoluteTransformProtocol( - settings=settings, - ) - dag = protocol.create( - stateA=benzene_solvent_system, - stateB=solvent_system, - mapping=None, - ) - unit = list(dag.protocol_units)[0] - - with tmpdir.as_cwd(): - sampler = unit.run(dry=True)['debug']['sampler'] - assert isinstance(sampler, MultiStateSampler) - assert sampler.is_periodic - - -@pytest.mark.parametrize('method', ['repex', 'sams', 'independent']) -def test_dry_run_complex(benzene_complex_system, protein_system, - method, tmpdir): - # this will be very time consuming - settings = openmm_afe.AbsoluteTransformProtocol.default_settings() - settings.alchemsampler_settings.sampler_method = method - settings.alchemsampler_settings.n_repeats = 1 - - protocol = openmm_afe.AbsoluteTransformProtocol( - settings=settings, - ) - dag = protocol.create( - stateA=benzene_complex_system, - stateB=protein_system, - mapping=None, - ) - unit = list(dag.protocol_units)[0] - - with tmpdir.as_cwd(): - sampler = unit.run(dry=True)['debug']['sampler'] - assert isinstance(sampler, MultiStateSampler) - assert sampler.is_periodic - - -def test_nreplicas_lambda_mismatch(benzene_vacuum_system, - vacuum_system, tmpdir): - """ - For now, should trigger failure if there are not as many replicas - as there are summed lambda windows. - """ - settings = openmm_afe.AbsoluteTransformProtocol.default_settings() - settings.alchemsampler_settings.n_replicas = 12 - settings.alchemical_settings.lambda_elec_windows = 12 - settings.alchemical_settings.lambda_vdw_windows = 12 - - protocol = openmm_afe.AbsoluteTransformProtocol( - settings=settings, - ) - - dag = protocol.create( - stateA=benzene_vacuum_system, - stateB=vacuum_system, - mapping=None, - ) - unit = list(dag.protocol_units)[0] - - with tmpdir.as_cwd(): - errmsg = ("Number of replicas 12 does not equal the " - "number of lambda windows") - with pytest.raises(ValueError, match=errmsg): - unit.run(dry=True) - - -def test_unit_tagging(vacuum_protocol_dag, tmpdir): - units = vacuum_protocol_dag.protocol_units - with mock.patch('openfe.protocols.openmm_afe.equil_afe_methods.AbsoluteTransformUnit.run', - return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}): - results = [] - for u in units: - ret = u.execute(shared=tmpdir) - results.append(ret) - - repeats = set() - for ret in results: - assert isinstance(ret, gufe.ProtocolUnitResult) - assert ret.outputs['generation'] == 0 - repeats.add(ret.outputs['repeat_id']) - assert repeats == {0, 1, 2} - - -def test_gather(vacuum_protocol_dag, tmpdir): - base_import = 'openfe.protocols.openmm_afe.equil_afe_methods.' - with mock.patch(f"{base_import}AbsoluteTransformUnit.run", - return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}): - dagres = gufe.protocols.execute_DAG(vacuum_protocol_dag, shared=tmpdir) - - prot = openmm_afe.AbsoluteTransformProtocol( - settings=openmm_afe.AbsoluteTransformProtocol.default_settings(), - ) - - with mock.patch(f"{base_import}multistate") as m: - res = prot.gather([dagres]) - - # check we created the expected number of Reporters and Analyzers - assert m.MultiStateReporter.call_count == 3 - m.MultiStateReporter.assert_called_with( - storage='file.nc', checkpoint_storage='chck.nc', - ) - assert m.MultiStateSamplerAnalyzer.call_count == 3 - - assert isinstance(res, openmm_afe.AbsoluteTransformProtocolResult) diff --git a/openfe/tests/protocols/test_openmm_abfe_settings.py b/openfe/tests/protocols/test_openmm_abfe_settings.py deleted file mode 100644 index 8b8f44c2d..000000000 --- a/openfe/tests/protocols/test_openmm_abfe_settings.py +++ /dev/null @@ -1,178 +0,0 @@ -# This code is part of OpenFE and is licensed under the MIT license. -# For details, see https://github.com/OpenFreeEnergy/openfe -import pytest -from openff.units import unit as offunit -from openfe.protocols import openmm_afe - - -@pytest.fixture() -def default_settings(): - return openmm_afe.AbsoluteTransformProtocol.default_settings() - - -def test_create_default_settings(): - settings = openmm_afe.AbsoluteTransformProtocol.default_settings() - assert settings - - -@pytest.mark.parametrize('method, fail', [ - ['Pme', False], - ['noCutoff', False], - ['Ewald', True], - ['CutoffNonPeriodic', True], - ['CutoffPeriodic', True], -]) -def test_systemsettings_nonbonded(method, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="Only PME"): - default_settings.system_settings.nonbonded_method = method - else: - default_settings.system_settings.nonbonded_method = method - - -@pytest.mark.parametrize('val, match', [ - [-1.0 * offunit.nanometer, 'must be a positive'], - [2.5 * offunit.picoseconds, 'distance units'], -]) -def test_systemsettings_cutoff_errors(val, match, default_settings): - with pytest.raises(ValueError, match=match): - default_settings.system_settings.nonbonded_cutoff = val - - -@pytest.mark.parametrize('val, fail', [ - ['TiP3p', False], - ['SPCE', False], - ['tip4pEw', False], - ['Tip5p', False], - ['opc', True], - ['tips', True], - ['tip3p-fb', True], -]) -def test_solvent_model_setting(val, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="allowed solvent_model"): - default_settings.solvent_settings.solvent_model = val - else: - default_settings.solvent_settings.solvent_model = val - - -@pytest.mark.parametrize('val, match', [ - [-1.0 * offunit.nanometer, 'must be a positive'], - [2.5 * offunit.picoseconds, 'distance units'], -]) -def test_incorrect_padding(val, match, default_settings): - with pytest.raises(ValueError, match=match): - default_settings.solvent_settings.solvent_padding = val - - -@pytest.mark.parametrize('val', [ - {'elec': 0, 'vdw': 5}, - {'elec': -2, 'vdw': 5}, - {'elec': 5, 'vdw': -2}, - {'elec': 5, 'vdw': 0}, -]) -def test_incorrect_window_settings(val, default_settings): - errmsg = "lambda steps must be positive" - alchem_settings = default_settings.alchemical_settings - with pytest.raises(ValueError, match=errmsg): - alchem_settings.lambda_elec_windows = val['elec'] - alchem_settings.lambda_vdw_windows = val['vdw'] - - -@pytest.mark.parametrize('val, fail', [ - ['LOGZ-FLATNESS', False], - ['MiniMum-VisiTs', False], - ['histogram-flatness', False], - ['roundrobin', True], - ['parsnips', True] -]) -def test_supported_flatness_settings(val, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="following flatness"): - default_settings.alchemsampler_settings.flatness_criteria = val - else: - default_settings.alchemsampler_settings.flatness_criteria = val - - -@pytest.mark.parametrize('var, val', [ - ['online_analysis_target_error', - -0.05 * offunit.boltzmann_constant * offunit.kelvin], - ['n_repeats', -1], - ['n_repeats', 0], - ['online_analysis_minimum_iterations', -2], - ['gamma0', -2], - ['n_replicas', -2], - ['n_replicas', 0] -]) -def test_nonnegative_alchem_settings(var, val, default_settings): - alchem_settings = default_settings.alchemsampler_settings - with pytest.raises(ValueError, match="positive values"): - setattr(alchem_settings, var, val) - - -@pytest.mark.parametrize('val, fail', [ - ['REPEX', False], - ['SaMs', False], - ['independent', False], - ['noneq', True], - ['AWH', True] -]) -def test_supported_sampler(val, fail, default_settings): - if fail: - with pytest.raises(ValueError, match="sampler_method values"): - default_settings.alchemsampler_settings.sampler_method = val - else: - default_settings.alchemsampler_settings.sampler_method = val - - -@pytest.mark.parametrize('var, val', [ - ['collision_rate', -1 / offunit.picosecond], - ['n_restart_attempts', -2], - ['timestep', 0 * offunit.femtosecond], - ['timestep', -2 * offunit.femtosecond], - ['n_steps', 0 * offunit.timestep], - ['n_steps', -1 * offunit.timestep], - ['constraint_tolerance', -2e-06], - ['constraint_tolerance', 0] -]) -def test_nonnegative_integrator_settings(var, val, default_settings): - int_settings = default_settings.integrator_settings - with pytest.raises(ValueError, match="positive values"): - setattr(int_settings, var, val) - - -def test_timestep_is_not_time(default_settings): - with pytest.raises(ValueError, match="time units"): - default_settings.integrator_settings.timestep = 1 * offunit.nanometer - - -def test_collision_is_not_inverse_time(default_settings): - with pytest.raises(ValueError, match="inverse time"): - int_settings = default_settings.integrator_settings - int_settings.collision_rate = 1 * offunit.picosecond - - -@pytest.mark.parametrize( - 'var', ['equilibration_length', 'production_length'] -) -def test_sim_lengths_not_time(var, default_settings): - settings = default_settings.simulation_settings - with pytest.raises(ValueError, match="must be in time units"): - setattr(settings, var, 1 * offunit.nanometer) - - -@pytest.mark.parametrize('var, val', [ - ['minimization_steps', -1], - ['minimization_steps', 0], - ['equilibration_length', -1 * offunit.picosecond], - ['equilibration_length', 0 * offunit.picosecond], - ['production_length', -1 * offunit.picosecond], - ['production_length', 0 * offunit.picosecond], - ['checkpoint_interval', -1 * offunit.timestep], - ['checkpoint_interval', 0 * offunit.timestep], -]) -def test_nonnegative_sim_settings(var, val, default_settings): - settings = default_settings.simulation_settings - with pytest.raises(ValueError, match="must be positive"): - setattr(settings, var, val) - diff --git a/openfe/tests/protocols/test_openmm_afe_settings.py b/openfe/tests/protocols/test_openmm_afe_settings.py new file mode 100644 index 000000000..11df4731c --- /dev/null +++ b/openfe/tests/protocols/test_openmm_afe_settings.py @@ -0,0 +1,30 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import pytest +from openff.units import unit as offunit +from openfe.protocols import openmm_afe + + +@pytest.fixture() +def default_settings(): + return openmm_afe.AbsoluteSolvationProtocol.default_settings() + + +def test_create_default_settings(): + settings = openmm_afe.AbsoluteSolvationProtocol.default_settings() + assert settings + + +@pytest.mark.parametrize('val', [ + {'elec': 0, 'vdw': 5}, + {'elec': -2, 'vdw': 5}, + {'elec': 5, 'vdw': -2}, + {'elec': 5, 'vdw': 0}, +]) +def test_incorrect_window_settings(val, default_settings): + errmsg = "lambda steps must be positive" + alchem_settings = default_settings.alchemical_settings + with pytest.raises(ValueError, match=errmsg): + alchem_settings.lambda_elec_windows = val['elec'] + alchem_settings.lambda_vdw_windows = val['vdw'] + diff --git a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py new file mode 100644 index 000000000..2eaa6db25 --- /dev/null +++ b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py @@ -0,0 +1,435 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import pytest +from unittest import mock +from openmmtools.multistate.multistatesampler import MultiStateSampler +from openff.units import unit as offunit +import mdtraj as mdt +import gufe +from openfe import ChemicalSystem, SolventComponent +from openfe.protocols import openmm_afe +from openfe.protocols.openmm_afe import ( + AbsoluteSolventTransformUnit, AbsoluteVacuumTransformUnit +) +from openfe.protocols.openmm_utils import system_validation + + +def test_create_default_protocol(): + # this is roughly how it should be created + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=openmm_afe.AbsoluteSolvationProtocol.default_settings(), + ) + assert protocol + + +def test_serialize_protocol(): + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=openmm_afe.AbsoluteSolvationProtocol.default_settings(), + ) + + ser = protocol.to_dict() + ret = openmm_afe.AbsoluteSolvationProtocol.from_dict(ser) + assert protocol == ret + + +def test_validate_solvent_endstates_protcomp( + benzene_modifications,T4_protein_component +): + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'protein': T4_protein_component, + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'phenol': benzene_modifications['phenol'], + 'solvent': SolventComponent(), + }) + + func = openmm_afe.AbsoluteSolvationProtocol._validate_solvent_endstates + + with pytest.raises(ValueError, match="Protein components are not allowed"): + comps = func(stateA, stateB) + + +def test_validate_solvent_endstates_nosolvcomp_stateA( + benzene_modifications, T4_protein_component +): + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + }) + + stateB = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'phenol': benzene_modifications['phenol'], + 'solvent': SolventComponent(), + }) + + func = openmm_afe.AbsoluteSolvationProtocol._validate_solvent_endstates + + with pytest.raises( + ValueError, match="No SolventComponent found in stateA" + ): + comps = func(stateA, stateB) + + +def test_validate_solvent_endstates_nosolvcomp_stateB( + benzene_modifications, T4_protein_component +): + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent(), + }) + + stateB = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'phenol': benzene_modifications['phenol'], + }) + + func = openmm_afe.AbsoluteSolvationProtocol._validate_solvent_endstates + + with pytest.raises( + ValueError, match="No SolventComponent found in stateB" + ): + comps = func(stateA, stateB) + +def test_validate_alchem_comps_appearingB(benzene_modifications): + stateA = ChemicalSystem({ + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + + func = openmm_afe.AbsoluteSolvationProtocol._validate_alchemical_components + + with pytest.raises(ValueError, match='Components appearing in state B'): + func(alchem_comps) + + +def test_validate_alchem_comps_multi(benzene_modifications): + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'toluene': benzene_modifications['toluene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent() + }) + + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + + assert len(alchem_comps['stateA']) == 2 + + func = openmm_afe.AbsoluteSolvationProtocol._validate_alchemical_components + + with pytest.raises(ValueError, match='More than one alchemical'): + func(alchem_comps) + + +def test_validate_alchem_nonsmc(benzene_modifications): + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + }) + + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + + func = openmm_afe.AbsoluteSolvationProtocol._validate_alchemical_components + + with pytest.raises(ValueError, match='Non SmallMoleculeComponent'): + func(alchem_comps) + + +def test_vac_bad_nonbonded(benzene_modifications): + settings = openmm_afe.AbsoluteSolvationProtocol.default_settings() + settings.vacuum_system_settings.nonbonded_method = 'pme' + protocol = openmm_afe.AbsoluteSolvationProtocol(settings=settings) + + + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent(), + }) + + + with pytest.raises(ValueError, match='Only the nocutoff'): + protocol.create(stateA=stateA, stateB=stateB, mapping=None) + + +@pytest.mark.parametrize('method', [ + 'repex', 'sams', 'independent', 'InDePeNdENT' +]) +def test_dry_run_vac_benzene(benzene_modifications, + method, tmpdir): + s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s.alchemsampler_settings.n_repeats = 1 + s.alchemsampler_settings.sampler_method = method + + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=s, + ) + + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent(), + }) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=stateA, + stateB=stateB, + mapping=None, + ) + prot_units = list(dag.protocol_units) + + assert len(prot_units) == 2 + + vac_unit = [u for u in prot_units + if isinstance(u, AbsoluteVacuumTransformUnit)] + sol_unit = [u for u in prot_units + if isinstance(u, AbsoluteSolventTransformUnit)] + + assert len(vac_unit) == 1 + assert len(sol_unit) == 1 + + with tmpdir.as_cwd(): + vac_sampler = vac_unit[0].run(dry=True)['debug']['sampler'] + assert not vac_sampler.is_periodic + + +def test_dry_run_solv_benzene(benzene_modifications, tmpdir): + s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s.alchemsampler_settings.n_repeats = 1 + s.solvent_simulation_settings.output_indices = "resname UNK" + + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=s, + ) + + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent(), + }) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=stateA, + stateB=stateB, + mapping=None, + ) + prot_units = list(dag.protocol_units) + + assert len(prot_units) == 2 + + vac_unit = [u for u in prot_units + if isinstance(u, AbsoluteVacuumTransformUnit)] + sol_unit = [u for u in prot_units + if isinstance(u, AbsoluteSolventTransformUnit)] + + assert len(vac_unit) == 1 + assert len(sol_unit) == 1 + + with tmpdir.as_cwd(): + sol_sampler = sol_unit[0].run(dry=True)['debug']['sampler'] + assert sol_sampler.is_periodic + + pdb = mdt.load_pdb('hybrid_system.pdb') + assert pdb.n_atoms == 12 + + +def test_dry_run_solv_benzene_tip4p(benzene_modifications, tmpdir): + s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s.alchemsampler_settings.n_repeats = 1 + s.forcefield_settings.forcefields = [ + "amber/ff14SB.xml", # ff14SB protein force field + "amber/tip4pew_standard.xml", # FF we are testsing with the fun VS + "amber/phosaa10.xml", # Handles THE TPO + ] + s.solvation_settings.solvent_model = 'tip4pew' + s.integrator_settings.reassign_velocities = True + + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=s, + ) + + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent(), + }) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=stateA, + stateB=stateB, + mapping=None, + ) + prot_units = list(dag.protocol_units) + + sol_unit = [u for u in prot_units + if isinstance(u, AbsoluteSolventTransformUnit)] + + with tmpdir.as_cwd(): + sol_sampler = sol_unit[0].run(dry=True)['debug']['sampler'] + assert sol_sampler.is_periodic + + +def test_nreplicas_lambda_mismatch(benzene_modifications, tmpdir): + s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s.alchemsampler_settings.n_repeats = 1 + s.alchemsampler_settings.n_replicas = 12 + + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=s, + ) + + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent(), + }) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=stateA, + stateB=stateB, + mapping=None, + ) + prot_units = list(dag.protocol_units) + + with tmpdir.as_cwd(): + errmsg = "Number of replicas 12" + with pytest.raises(ValueError, match=errmsg): + prot_units[0].run(dry=True) + + +def test_high_timestep(benzene_modifications, tmpdir): + s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s.alchemsampler_settings.n_repeats = 1 + s.forcefield_settings.hydrogen_mass = 1.0 + + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=s, + ) + + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent(), + }) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=stateA, + stateB=stateB, + mapping=None, + ) + prot_units = list(dag.protocol_units) + + with tmpdir.as_cwd(): + errmsg = "too large for hydrogen mass" + with pytest.raises(ValueError, match=errmsg): + prot_units[0].run(dry=True) + + +@pytest.fixture +def benzene_solvation_dag(benzene_modifications): + s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=s, + ) + + stateA = ChemicalSystem({ + 'benzene': benzene_modifications['benzene'], + 'solvent': SolventComponent() + }) + + stateB = ChemicalSystem({ + 'solvent': SolventComponent(), + }) + + return protocol.create(stateA=stateA, stateB=stateB, mapping=None) + + +def test_unit_tagging(benzene_solvation_dag, tmpdir): + # test that executing the units includes correct gen and repeat info + + dag_units = benzene_solvation_dag.protocol_units + + with ( + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolventTransformUnit.run', + return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteVacuumTransformUnit.run', + return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), + ): + results = [] + for u in dag_units: + ret = u.execute(context=gufe.Context(tmpdir, tmpdir)) + results.append(ret) + + solv_repeats = set() + vac_repeats = set() + for ret in results: + assert isinstance(ret, gufe.ProtocolUnitResult) + assert ret.outputs['generation'] == 0 + if ret.outputs['simtype'] == 'vacuum': + vac_repeats.add(ret.outputs['repeat_id']) + else: + solv_repeats.add(ret.outputs['repeat_id']) + assert vac_repeats == {0, 1, 2} + assert solv_repeats == {0, 1, 2} + + +def test_gather(benzene_solvation_dag, tmpdir): + # check that .gather behaves as expected + with ( + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolventTransformUnit.run', + return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteVacuumTransformUnit.run', + return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), + ): + dagres = gufe.protocols.execute_DAG(benzene_solvation_dag, + shared_basedir=tmpdir, + scratch_basedir=tmpdir, + keep_shared=True) + + protocol = openmm_afe.AbsoluteSolvationProtocol( + settings=openmm_afe.AbsoluteSolvationProtocol.default_settings(), + ) + + res = protocol.gather([dagres]) + + assert isinstance(res, openmm_afe.AbsoluteSolvationProtocolResult) From 4f1431c5e7d173367bb90bdabda7b1e6c7af7e69 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 6 Oct 2023 16:56:16 +0100 Subject: [PATCH 41/74] couple of fixes --- openfe/protocols/openmm_afe/base.py | 4 ++-- .../openmm_afe/equil_afe_settings.py | 22 +++++++++++++++---- .../openmm_afe/equil_solvation_afe_method.py | 7 +++--- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py index 5c983b06e..7bb670303 100644 --- a/openfe/protocols/openmm_afe/base.py +++ b/openfe/protocols/openmm_afe/base.py @@ -761,7 +761,7 @@ def _run_simulation( self.logger.info("minimizing systems") sampler.minimize( - max_iterations=settings['sim_settings'].minimization_steps + max_iterations=settings['simulation_settings'].minimization_steps ) # equilibrate @@ -937,4 +937,4 @@ def run(self, dry=False, verbose=True, **unit_result_dict, } else: - return {'debug': {'sampler': sampler}} \ No newline at end of file + return {'debug': {'sampler': sampler}} diff --git a/openfe/protocols/openmm_afe/equil_afe_settings.py b/openfe/protocols/openmm_afe/equil_afe_settings.py index 069875537..7e54b9dfd 100644 --- a/openfe/protocols/openmm_afe/equil_afe_settings.py +++ b/openfe/protocols/openmm_afe/equil_afe_settings.py @@ -67,10 +67,14 @@ class Config: # Things for creating the systems vacuum_system_settings: SystemSettings + """ + Simulation system settings including the + long-range non-bonded methods for the vacuum transformation. + """ solvent_system_settings: SystemSettings """ Simulation system settings including the - long-range non-bonded methods. + long-range non-bonded methods for the solvent transformation. """ solvation_settings: SolvationSettings """Settings for solvating the system.""" @@ -87,9 +91,15 @@ class Config: """ # MD Engine things - engine_settings: OpenMMEngineSettings + vacuum_engine_settings: OpenMMEngineSettings + """ + Settings specific to the OpenMM engine, such as the compute platform + for the vacuum transformation. """ - Settings specific to the OpenMM engine, such as the compute platform. + solvent_engine_settings: OpenMMEngineSettings + """ + Settings specific to the OpenMM engine, such as the compute platform + for the solvent transformation. """ # Sampling State defining things @@ -101,8 +111,12 @@ class Config: # Simulation run settings vacuum_simulation_settings: SimulationSettings + """ + Simulation control settings, including simulation lengths and + record-keeping for the vacuum transformation. + """ solvent_simulation_settings: SimulationSettings """ Simulation control settings, including simulation lengths and - record-keeping. + record-keeping for the solvent transformation. """ diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index f56e9b91c..663a63dcd 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -191,7 +191,8 @@ def _default_settings(cls): n_replicas=24, ), solvation_settings=SolvationSettings(), - engine_settings=OpenMMEngineSettings(), + vacuum_engine_settings=OpenMMEngineSettings(), + solvent_engine_settings=OpenMMEngineSettings(), integrator_settings=IntegratorSettings(), solvent_simulation_settings=SimulationSettings( equilibration_length=1.0 * unit.nanosecond, @@ -434,7 +435,7 @@ def _handle_settings(self): settings['solvation_settings'] = prot_settings.solvation_settings settings['alchemical_settings'] = prot_settings.alchemical_settings settings['sampler_settings'] = prot_settings.alchemsampler_settings - settings['engine_settings'] = prot_settings.engine_settings + settings['engine_settings'] = prot_settings.vacuum_engine_settings settings['integrator_settings'] = prot_settings.integrator_settings settings['simulation_settings'] = prot_settings.vacuum_simulation_settings @@ -517,7 +518,7 @@ def _handle_settings(self): settings['solvation_settings'] = prot_settings.solvation_settings settings['alchemical_settings'] = prot_settings.alchemical_settings settings['sampler_settings'] = prot_settings.alchemsampler_settings - settings['engine_settings'] = prot_settings.engine_settings + settings['engine_settings'] = prot_settings.solvent_engine_settings settings['integrator_settings'] = prot_settings.integrator_settings settings['simulation_settings'] = prot_settings.solvent_simulation_settings From f50ffe635b7d33e9f1c1ea1d46eb428c7a1ed125 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 9 Oct 2023 00:14:17 +0100 Subject: [PATCH 42/74] fix gather issues --- .../openmm_afe/equil_solvation_afe_method.py | 54 +++++++++++-------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 663a63dcd..935051d0d 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -69,7 +69,8 @@ class AbsoluteSolvationProtocolResult(gufe.ProtocolResult): def __init__(self, **data): super().__init__(**data) # TODO: Detect when we have extensions and stitch these together? - if any(len(pur_list) > 2 for pur_list in self.data.values()): + if any(len(pur_list) > 2 for pur_list + in itertools.chain(self.data['solvent'].values(), self.data['vacuum'].values())): raise NotImplementedError("Can't stitch together results yet") def get_vacuum_individual_estimates(self) -> list[tuple[unit.Quantity, unit.Quantity]]: @@ -84,12 +85,11 @@ def get_vacuum_individual_estimates(self) -> list[tuple[unit.Quantity, unit.Quan """ dGs = [] - for pus in self.data.values(): - if pus[0].outputs['simtype'] == 'vacuum': - dGs.append(( - pus[0].outputs['unit_estimate'], - pus[0].outputs['unit_estimate_error'] - )) + for pus in self.data['vacuum'].values(): + dGs.append(( + pus[0].outputs['unit_estimate'], + pus[0].outputs['unit_estimate_error'] + )) return dGs @@ -105,12 +105,11 @@ def get_solvent_individual_estimates(self) -> list[tuple[unit.Quantity, unit.Qua """ dGs = [] - for pus in self.data.values(): - if pus[0].outputs['simtype'] == 'solvent': - dGs.append(( - pus[0].outputs['unit_estimate'], - pus[0].outputs['unit_estimate_error'] - )) + for pus in self.data['solvent'].values(): + dGs.append(( + pus[0].outputs['unit_estimate'], + pus[0].outputs['unit_estimate_error'] + )) return dGs @@ -131,8 +130,8 @@ def _get_average(estimates): return np.average(dGs) * u - vac_dG = _get_average(self.get_vacuum_individual_estimates(self)) - solv_dG = _get_average(self.get_solvent_individual_estimates(self)) + vac_dG = _get_average(self.get_vacuum_individual_estimates()) + solv_dG = _get_average(self.get_solvent_individual_estimates()) return vac_dG - solv_dG @@ -154,8 +153,8 @@ def _get_stdev(estimates): return np.std(dGs) * u - vac_err = _get_stdev(self.get_vacuum_individual_estimates(self)) - solv_err = _get_stdev(self.get_solvent_individual_estimates(self)) + vac_err = _get_stdev(self.get_vacuum_individual_estimates()) + solv_err = _get_stdev(self.get_solvent_individual_estimates()) # return the combined error return np.sqrt(vac_err**2 + solv_err**2) @@ -365,20 +364,29 @@ def _create( def _gather( self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult] - ) -> Dict[str, Any]: + ) -> Dict[str, Dict[str, Any]]: # result units will have a repeat_id and generation # first group according to repeat_id - unsorted_repeats = defaultdict(list) + unsorted_solvent_repeats = defaultdict(list) + unsorted_vacuum_repeats = defaultdict(list) for d in protocol_dag_results: pu: gufe.ProtocolUnitResult for pu in d.protocol_unit_results: if not pu.ok(): continue - unsorted_repeats[pu.outputs['repeat_id']].append(pu) + if pu.outputs['simtype'] == 'solvent': + unsorted_solvent_repeats[pu.outputs['repeat_id']].append(pu) + else: + unsorted_vacuum_repeats[pu.outputs['repeat_id']].append(pu) + + repeats: dict[str, list[gufe.ProtocolUnitResult]] = { + 'solvent': {}, 'vacuum': {}, + } + for k, v in unsorted_solvent_repeats.items(): + repeats['solvent'][str(k)] = sorted(v, key=lambda x: x.outputs['generation']) - repeats: dict[str, list[gufe.ProtocolUnitResult]] = {} - for k, v in unsorted_repeats.items(): - repeats[str(k)] = sorted(v, key=lambda x: x.outputs['generation']) + for k, v in unsorted_vacuum_repeats.items(): + repeats['vacuum'][str(k)] = sorted(v, key=lambda x: x.outputs['generation']) return repeats From 73165431a32a63d9183729b030fad0a0d6664ead Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 9 Oct 2023 00:24:45 +0100 Subject: [PATCH 43/74] try to fix the docs --- docs/protocols/index.rst | 6 - docs/protocols/openmm_afe.rst | 4 - docs/reference/api/openmm_solvation_afe.rst | 136 ++++++++++++++++++++ 3 files changed, 136 insertions(+), 10 deletions(-) delete mode 100644 docs/protocols/index.rst delete mode 100644 docs/protocols/openmm_afe.rst create mode 100644 docs/reference/api/openmm_solvation_afe.rst diff --git a/docs/protocols/index.rst b/docs/protocols/index.rst deleted file mode 100644 index 3b5926100..000000000 --- a/docs/protocols/index.rst +++ /dev/null @@ -1,6 +0,0 @@ -Alchemical free energy protocols in ``openfe`` -============================================== - -.. toctree:: - - openmm_afe diff --git a/docs/protocols/openmm_afe.rst b/docs/protocols/openmm_afe.rst deleted file mode 100644 index 05f1a9995..000000000 --- a/docs/protocols/openmm_afe.rst +++ /dev/null @@ -1,4 +0,0 @@ -OpenMM AFE Protocol -=================== - -.. automodule:: openfe.protocols.openmm_afe.equil_afe_methods diff --git a/docs/reference/api/openmm_solvation_afe.rst b/docs/reference/api/openmm_solvation_afe.rst new file mode 100644 index 000000000..d747a9f89 --- /dev/null +++ b/docs/reference/api/openmm_solvation_afe.rst @@ -0,0 +1,136 @@ +OpenMM Absolute Solvation Free Energy Protocol +============================================== + +This section provides details about the OpenMM Absolute Solvation Free Energy Protocol +implemented in OpenFE. + +Protocol API specification +-------------------------- + +.. module:: openfe.protocols.openmm_afe.equil_solvation_afe_method + +.. autosummary:: + :nosignatures: + :toctree: generated/ + + AbsoluteSolvationProtocol + AbsoluteSolvationProtocolResult + +Protocol Settings +----------------- + + +Below are the settings which can be tweaked in the protocol. The default settings (accessed using :meth:`AbsoluteSolvationProtocol.default_settings`) will automatically populate settings which we have found to be useful for running solvation free energy calculations. There will however be some cases (such as when calculating difficult to converge systems) where you will need to tweak some of the following settings. + +.. autopydantic_model:: AbsoluteSolvationSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :exclude-members: get_defaults + :member-order: bysource + +.. module:: openfe.protocols.openmm_afe.equil_afe_settings + +.. autopydantic_model:: OpenMMSystemGeneratorFFSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: ThermoSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: AlchemicalSamplerSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: AlchemicalSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: OpenMMEngineSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: IntegratorSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: SimulationSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: SolvationSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource + +.. autopydantic_model:: SystemSettings + :model-show-json: False + :model-show-field-summary: False + :model-show-config-member: False + :model-show-config-summary: False + :model-show-validator-members: False + :model-show-validator-summary: False + :field-list-validators: False + :inherited-members: SettingsBaseModel + :member-order: bysource From 67d9556ad3f3d118d49556954c95061b29177010 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 9 Oct 2023 05:01:42 +0100 Subject: [PATCH 44/74] fix mypy complaint --- openfe/protocols/openmm_afe/equil_solvation_afe_method.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 935051d0d..221628faf 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -379,7 +379,7 @@ def _gather( else: unsorted_vacuum_repeats[pu.outputs['repeat_id']].append(pu) - repeats: dict[str, list[gufe.ProtocolUnitResult]] = { + repeats: dict[str, dict[str, list[gufe.ProtocolUnitResult]]] = { 'solvent': {}, 'vacuum': {}, } for k, v in unsorted_solvent_repeats.items(): From 70976832bb888a3ebd95888c2b41036a1dc436f5 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 9 Oct 2023 05:10:56 +0100 Subject: [PATCH 45/74] avoid using todo extensions for now --- .../openmm_afe/equil_solvation_afe_method.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 221628faf..0c70c1086 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -58,13 +58,14 @@ class AbsoluteSolvationProtocolResult(gufe.ProtocolResult): """Dict-like container for the output of a AbsoluteSolventTransform - TODO + NOTE ---- - * Add in methods to retreive forward/backwards analyses - * Add in methods to retreive the overlap matrices - * Add in method to get replica transition stats - * Add in method to get replica states - * Add in method to get equilibration and production iterations + The following items have yet to be implemented: + * Methods to retreive forward/backwards analyses + * Methods to retreive the overlap matrices + * Method to get replica transition stats + * Method to get replica states + * Method to get equilibration and production iterations """ def __init__(self, **data): super().__init__(**data) From ae4c0c5f01b64949672181de7b85050d25dd3534 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 11 Oct 2023 05:12:08 +0100 Subject: [PATCH 46/74] towards all the results --- .../openmm_afe/equil_solvation_afe_method.py | 80 ++++++++++++------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 0c70c1086..5e76e97d5 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -61,7 +61,6 @@ class AbsoluteSolvationProtocolResult(gufe.ProtocolResult): NOTE ---- The following items have yet to be implemented: - * Methods to retreive forward/backwards analyses * Methods to retreive the overlap matrices * Method to get replica transition stats * Method to get replica states @@ -74,45 +73,34 @@ def __init__(self, **data): in itertools.chain(self.data['solvent'].values(), self.data['vacuum'].values())): raise NotImplementedError("Can't stitch together results yet") - def get_vacuum_individual_estimates(self) -> list[tuple[unit.Quantity, unit.Quantity]]: + def get_individual_estimates(self) -> dict[str, list[tuple[unit.Quantity, unit.Quantity]]]: """ - Return a list of tuples containing the individual free energy - estimates and associated MBAR errors for each repeat of the vacuum - calculation. + Return a dictionary (keyed as `solvent` and `vacuum`) with list + of tuples containing the individual free energy estimates and + associated MBAR errors for each repeat of the vacuum and solvent + calculations. Returns ------- - dGs : list[tuple[unit.Quantity, unit.Quantity]] + dGs : dict[str, list[tuple[unit.Quantity, unit.Quantity]]] """ - dGs = [] + vac_dGs = [] + solv_dGs = [] for pus in self.data['vacuum'].values(): - dGs.append(( + vac_dGs.append(( pus[0].outputs['unit_estimate'], pus[0].outputs['unit_estimate_error'] )) - - return dGs - - def get_solvent_individual_estimates(self) -> list[tuple[unit.Quantity, unit.Quantity]]: - """ - Return a list of tuples containing the individual free energy - estimates and associated MBAR errors for each repeat of the solvent - calculation. - - Returns - ------- - dGs : list[tuple[unit.Quantity, unit.Quantity]] - """ - dGs = [] - + for pus in self.data['solvent'].values(): - dGs.append(( - pus[0].outputs['unit_estimate'], + solv_dGs.append(( + pus[0].outputs['unit_esitmate'], pus[0].outputs['unit_estimate_error'] )) - return dGs + + return {'solvent': solv_dGs, 'vacuum': vac_dGs} def get_estimate(self): """Get the solvation free energy estimate for this calculation. @@ -131,8 +119,9 @@ def _get_average(estimates): return np.average(dGs) * u - vac_dG = _get_average(self.get_vacuum_individual_estimates()) - solv_dG = _get_average(self.get_solvent_individual_estimates()) + individual_estimates = self.get_individual_estimates() + vac_dG = _get_average(individual_estimates['vacuum']) + solv_dG = _get_average(individual_estimates['solvent']) return vac_dG - solv_dG @@ -154,12 +143,43 @@ def _get_stdev(estimates): return np.std(dGs) * u - vac_err = _get_stdev(self.get_vacuum_individual_estimates()) - solv_err = _get_stdev(self.get_solvent_individual_estimates()) + individual_estimates = self.get_individual_estimates() + vac_err = _get_stdev(individual_estimates['vacuum']) + solv_err = _get_stdev(individual_estimates['solvent']) # return the combined error return np.sqrt(vac_err**2 + solv_err**2) + def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt.NDArray, units.Quantity]]]]: + """ + Get a dictionary (keyed `solvent` and `vacuum`) with lists of the + reverse and forward analysis of the free energies for each repeat + of the vacuum and solvent calculations using uncorrelated production + samples. + + The returned forward and reverse analysis dictionaries have keys: + 'fractions' - the fraction of data used for this estimate + 'forward_DGs', 'reverse_DGs' - for each fraction of data, the estimate + 'forward_dDGs', 'reverse_dDGs' - for each estimate, the uncertainty + + The 'fractions' values are a numpy array, while the other arrays are + Quantity arrays, with units attached. + + Returns + ------- + forward_reverse : dict[str, list[dict[str, Union[npt.NDArray, unit.Quantity]]]] + """ + + forward_reverse = {} + + for key in ['solvent', 'vacuum']: + forward_reverse[key] = [ + pus[0].outputs['forward_and_reverse_energies'] + for pus in self.data[key].values() + ] + + return forward_reverse + class AbsoluteSolvationProtocol(gufe.Protocol): result_cls = AbsoluteSolvationProtocolResult From 51600637e30c5c98a7e40e801bae6c49257a0df0 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 11 Oct 2023 05:15:40 +0100 Subject: [PATCH 47/74] noindex on settings --- docs/reference/api/openmm_solvation_afe.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/reference/api/openmm_solvation_afe.rst b/docs/reference/api/openmm_solvation_afe.rst index d747a9f89..f3bc78eb4 100644 --- a/docs/reference/api/openmm_solvation_afe.rst +++ b/docs/reference/api/openmm_solvation_afe.rst @@ -33,6 +33,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :inherited-members: SettingsBaseModel :exclude-members: get_defaults :member-order: bysource + :noindex: .. module:: openfe.protocols.openmm_afe.equil_afe_settings @@ -46,6 +47,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: ThermoSettings :model-show-json: False @@ -57,6 +59,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: AlchemicalSamplerSettings :model-show-json: False @@ -68,6 +71,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: AlchemicalSettings :model-show-json: False @@ -79,6 +83,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: OpenMMEngineSettings :model-show-json: False @@ -90,6 +95,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: IntegratorSettings :model-show-json: False @@ -101,6 +107,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: SimulationSettings :model-show-json: False @@ -112,6 +119,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: SolvationSettings :model-show-json: False @@ -123,6 +131,7 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: .. autopydantic_model:: SystemSettings :model-show-json: False @@ -134,3 +143,4 @@ Below are the settings which can be tweaked in the protocol. The default setting :field-list-validators: False :inherited-members: SettingsBaseModel :member-order: bysource + :noindex: From a920ddf6686d4072241ea98d16778421f1fa97da Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 11 Oct 2023 05:31:17 +0100 Subject: [PATCH 48/74] remove redundant settings test file --- .../protocols/test_openmm_afe_settings.py | 30 ----------------- .../test_openmm_afe_solvation_protocol.py | 32 ++++++++++++++++--- 2 files changed, 28 insertions(+), 34 deletions(-) delete mode 100644 openfe/tests/protocols/test_openmm_afe_settings.py diff --git a/openfe/tests/protocols/test_openmm_afe_settings.py b/openfe/tests/protocols/test_openmm_afe_settings.py deleted file mode 100644 index 11df4731c..000000000 --- a/openfe/tests/protocols/test_openmm_afe_settings.py +++ /dev/null @@ -1,30 +0,0 @@ -# This code is part of OpenFE and is licensed under the MIT license. -# For details, see https://github.com/OpenFreeEnergy/openfe -import pytest -from openff.units import unit as offunit -from openfe.protocols import openmm_afe - - -@pytest.fixture() -def default_settings(): - return openmm_afe.AbsoluteSolvationProtocol.default_settings() - - -def test_create_default_settings(): - settings = openmm_afe.AbsoluteSolvationProtocol.default_settings() - assert settings - - -@pytest.mark.parametrize('val', [ - {'elec': 0, 'vdw': 5}, - {'elec': -2, 'vdw': 5}, - {'elec': 5, 'vdw': -2}, - {'elec': 5, 'vdw': 0}, -]) -def test_incorrect_window_settings(val, default_settings): - errmsg = "lambda steps must be positive" - alchem_settings = default_settings.alchemical_settings - with pytest.raises(ValueError, match=errmsg): - alchem_settings.lambda_elec_windows = val['elec'] - alchem_settings.lambda_vdw_windows = val['vdw'] - diff --git a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py index 2eaa6db25..508994b5f 100644 --- a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py +++ b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py @@ -14,17 +14,41 @@ from openfe.protocols.openmm_utils import system_validation -def test_create_default_protocol(): +@pytest.fixture() +def default_settings(): + return openmm_afe.AbsoluteSolvationProtocol.default_settings() + + +def test_create_default_settings(): + settings = openmm_afe.AbsoluteSolvationProtocol.default_settings() + assert settings + + +@pytest.mark.parametrize('val', [ + {'elec': 0, 'vdw': 5}, + {'elec': -2, 'vdw': 5}, + {'elec': 5, 'vdw': -2}, + {'elec': 5, 'vdw': 0}, +]) +def test_incorrect_window_settings(val, default_settings): + errmsg = "lambda steps must be positive" + alchem_settings = default_settings.alchemical_settings + with pytest.raises(ValueError, match=errmsg): + alchem_settings.lambda_elec_windows = val['elec'] + alchem_settings.lambda_vdw_windows = val['vdw'] + + +def test_create_default_protocol(default_settings): # this is roughly how it should be created protocol = openmm_afe.AbsoluteSolvationProtocol( - settings=openmm_afe.AbsoluteSolvationProtocol.default_settings(), + settings=default_settings, ) assert protocol -def test_serialize_protocol(): +def test_serialize_protocol(default_settings): protocol = openmm_afe.AbsoluteSolvationProtocol( - settings=openmm_afe.AbsoluteSolvationProtocol.default_settings(), + settings=default_settings, ) ser = protocol.to_dict() From f819573dc339aec14fa3bedd66e35d5dbf57b445 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 11 Oct 2023 05:31:39 +0100 Subject: [PATCH 49/74] fix pep8 issues --- openfe/protocols/openmm_afe/equil_solvation_afe_method.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 5e76e97d5..357221fa3 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -92,14 +92,13 @@ def get_individual_estimates(self) -> dict[str, list[tuple[unit.Quantity, unit.Q pus[0].outputs['unit_estimate'], pus[0].outputs['unit_estimate_error'] )) - + for pus in self.data['solvent'].values(): solv_dGs.append(( pus[0].outputs['unit_esitmate'], pus[0].outputs['unit_estimate_error'] )) - return {'solvent': solv_dGs, 'vacuum': vac_dGs} def get_estimate(self): From 7d554ac4e07afe88d851bc364113acd168c0c311 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 11 Oct 2023 05:36:21 +0100 Subject: [PATCH 50/74] fix typing issues --- openfe/protocols/openmm_afe/equil_solvation_afe_method.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 357221fa3..e033c817c 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -34,8 +34,9 @@ from gufe.components import Component import itertools import numpy as np +import numpy.typing as npt from openff.units import unit -from typing import Dict, Optional +from typing import Dict, Optional, Union from typing import Any, Iterable from gufe import ( @@ -149,7 +150,7 @@ def _get_stdev(estimates): # return the combined error return np.sqrt(vac_err**2 + solv_err**2) - def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt.NDArray, units.Quantity]]]]: + def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt.NDArray, unit.Quantity]]]]: """ Get a dictionary (keyed `solvent` and `vacuum`) with lists of the reverse and forward analysis of the free energies for each repeat From 34d533ed25d8a204eff2b7d320c4cd83a9a30172 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 06:53:56 +0100 Subject: [PATCH 51/74] fix solvent->vacuum typo --- openfe/protocols/openmm_afe/equil_solvation_afe_method.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index e033c817c..e02ece7ef 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -375,7 +375,7 @@ def _create( settings=self.settings, alchemical_components=alchem_comps, generation=0, repeat_id=i, - name=(f"Absolute Solvation, {alchname} solvent leg: " + name=(f"Absolute Solvation, {alchname} vacuum leg: " f"repeat {i} generation 0"), ) for i in range(self.settings.alchemsampler_settings.n_repeats) From 35b7735859e60a2a2ed46d9f98579f0514cd89fa Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 06:55:05 +0100 Subject: [PATCH 52/74] Add solvation_afe to doc index --- docs/reference/api/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 5d0df4f7e..2ed7668ff 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -12,3 +12,4 @@ OpenFE API Reference alchemical_network_planning defining_and_executing_simulations openmm_rfe + openmm_solvation_afe From 0dea343a4a315ce285c893cde99950d366ec7165 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 11:54:54 +0100 Subject: [PATCH 53/74] Make it so that gas phase is only stateA alchemical components --- .../openmm_afe/equil_solvation_afe_method.py | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index e02ece7ef..636a5a869 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -40,7 +40,8 @@ from typing import Any, Iterable from gufe import ( - settings, ChemicalSystem, SmallMoleculeComponent, + settings, SettingsBaseModel, + ChemicalSystem, SmallMoleculeComponent, ProteinComponent, SolventComponent ) from openfe.protocols.openmm_afe.equil_afe_settings import ( @@ -412,13 +413,15 @@ def _gather( class AbsoluteVacuumTransformUnit(BaseAbsoluteTransformUnit): - def _get_components(self): + def _get_components(self) -> tuple[dict[str, list[Component]], None, + Optional[ProteinComponent], + list[SmallMoleculeComponent]]: """ Get the relevant components for a vacuum transformation. Returns ------- - alchem_comps : list[Component] + alchem_comps : dict[str, list[Component]] A list of alchemical components solv_comp : None For the gas phase transformation, None will always be returned @@ -426,18 +429,23 @@ def _get_components(self): prot_comp : Optional[ProteinComponent] The protein component of the system, if it exists. small_mols : list[SmallMoleculeComponent] - A list of SmallMoleculeComponents to add to the system. + A list of SmallMoleculeComponents to add to the system. This + is equivalent to the alchemical components in stateA (since + we only allow for disappearing ligands). """ stateA = self._inputs['stateA'] alchem_comps = self._inputs['alchemical_components'] - _, prot_comp, small_mols = system_validation.get_components(stateA) + _, prot_comp, _ = system_validation.get_components(stateA) - # Note our input state will contain a solvent, we ``None`` that out + # Notes: + # 1. Our input state will contain a solvent, we ``None`` that out # since this is the gas phase unit. - return alchem_comps, None, prot_comp, small_mols + # 2. Our small molecules will always just be the alchemical components + # (of stateA since we enforce only one disappearing ligand) + return alchem_comps, None, prot_comp, alchem_comps['stateA'] - def _handle_settings(self): + def _handle_settings(self) -> dict[str, SettingsBaseModel]: """ Extract the relevant settings for a vacuum transformation. @@ -493,7 +501,9 @@ def _execute( class AbsoluteSolventTransformUnit(BaseAbsoluteTransformUnit): - def _get_components(self): + def _get_components(self) -> tuple[list[Components], SolventComponent, + Optional[ProteinComponent], + list[SmallMoleculeComponent]]: """ Get the relevant components for a vacuum transformation. @@ -520,7 +530,7 @@ def _get_components(self): # disallowed on create return alchem_comps, solv_comp, prot_comp, small_mols - def _handle_settings(self): + def _handle_settings(self) -> dict[str, SettingsBaseModel]: """ Extract the relevant settings for a vacuum transformation. From 192a35db38669bddb7ff1165e594ebb6ddf68acc Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 11:56:29 +0100 Subject: [PATCH 54/74] Add tokenization tests --- .../tests/protocols/test_afe_tokenization.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 openfe/tests/protocols/test_afe_tokenization.py diff --git a/openfe/tests/protocols/test_afe_tokenization.py b/openfe/tests/protocols/test_afe_tokenization.py new file mode 100644 index 000000000..fadb691b6 --- /dev/null +++ b/openfe/tests/protocols/test_afe_tokenization.py @@ -0,0 +1,52 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +import openfe +from openfe.protocols import openmm_afe +from gufe.tests.test_tokenization import GufeTokenizableTestsMixin +import pytest + +""" +todo: +- AbsoluteSolvationProtocolResult +- AbsoluteSolvationProtocol +- AbsoluteSolvationProtocolUnit +""" + +@pytest.fixture +def protocol(): + return openmm_afe.AbsoluteSolvationProtocol( + openmm_afe.AbsoluteSolvationProtocol.default_settings() + ) + + +@pytest.fixture +def protocol_unit(protocol, benzene_system): + pus = protocol.create( + stateA=benzene_system, stateB=openfe.SolventComponent(), + mapping=None, + ) + return list(pus.protocol_units)[0] + + +class TestAbsoluteSolvationProtocol(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteSolvationProtocol + key = "RelativeHybridTopologyProtocol-8fe3eb4c318673db7d57c1bffca602df" + repr = f"<{key}>" + + @pytest.fixture() + def instance(self, protocol): + return protocol + + +class TestAbsoluteSolventProtocolUnit(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteSolvationProtocolUnit + repr = "RelativeHybridTopologyProtocolUnit(benzene to toluene repeat 2 generation 0)" + key = None + + @pytest.fixture() + def instance(self, protocol_unit): + return protocol_unit + + def test_key_stable(self): + pytest.skip() From 966de42f21382229d3b29e9e41dca7eddec54909 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 13:02:56 +0100 Subject: [PATCH 55/74] Add tokenization tests --- .../openmm_afe/equil_solvation_afe_method.py | 5 +- .../tests/protocols/test_afe_tokenization.py | 52 ------------ .../test_solvation_afe_tokenization.py | 79 +++++++++++++++++++ 3 files changed, 82 insertions(+), 54 deletions(-) delete mode 100644 openfe/tests/protocols/test_afe_tokenization.py create mode 100644 openfe/tests/protocols/test_solvation_afe_tokenization.py diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 636a5a869..ad2ad4c7b 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -40,7 +40,7 @@ from typing import Any, Iterable from gufe import ( - settings, SettingsBaseModel, + settings, ChemicalSystem, SmallMoleculeComponent, ProteinComponent, SolventComponent ) @@ -49,6 +49,7 @@ SolvationSettings, AlchemicalSettings, AlchemicalSamplerSettings, OpenMMEngineSettings, IntegratorSettings, SimulationSettings, + SettingsBaseModel, ) from ..openmm_utils import system_validation, settings_validation from .base import BaseAbsoluteTransformUnit @@ -501,7 +502,7 @@ def _execute( class AbsoluteSolventTransformUnit(BaseAbsoluteTransformUnit): - def _get_components(self) -> tuple[list[Components], SolventComponent, + def _get_components(self) -> tuple[list[Component], SolventComponent, Optional[ProteinComponent], list[SmallMoleculeComponent]]: """ diff --git a/openfe/tests/protocols/test_afe_tokenization.py b/openfe/tests/protocols/test_afe_tokenization.py deleted file mode 100644 index fadb691b6..000000000 --- a/openfe/tests/protocols/test_afe_tokenization.py +++ /dev/null @@ -1,52 +0,0 @@ -# This code is part of OpenFE and is licensed under the MIT license. -# For details, see https://github.com/OpenFreeEnergy/openfe - -import openfe -from openfe.protocols import openmm_afe -from gufe.tests.test_tokenization import GufeTokenizableTestsMixin -import pytest - -""" -todo: -- AbsoluteSolvationProtocolResult -- AbsoluteSolvationProtocol -- AbsoluteSolvationProtocolUnit -""" - -@pytest.fixture -def protocol(): - return openmm_afe.AbsoluteSolvationProtocol( - openmm_afe.AbsoluteSolvationProtocol.default_settings() - ) - - -@pytest.fixture -def protocol_unit(protocol, benzene_system): - pus = protocol.create( - stateA=benzene_system, stateB=openfe.SolventComponent(), - mapping=None, - ) - return list(pus.protocol_units)[0] - - -class TestAbsoluteSolvationProtocol(GufeTokenizableTestsMixin): - cls = openmm_afe.AbsoluteSolvationProtocol - key = "RelativeHybridTopologyProtocol-8fe3eb4c318673db7d57c1bffca602df" - repr = f"<{key}>" - - @pytest.fixture() - def instance(self, protocol): - return protocol - - -class TestAbsoluteSolventProtocolUnit(GufeTokenizableTestsMixin): - cls = openmm_afe.AbsoluteSolvationProtocolUnit - repr = "RelativeHybridTopologyProtocolUnit(benzene to toluene repeat 2 generation 0)" - key = None - - @pytest.fixture() - def instance(self, protocol_unit): - return protocol_unit - - def test_key_stable(self): - pytest.skip() diff --git a/openfe/tests/protocols/test_solvation_afe_tokenization.py b/openfe/tests/protocols/test_solvation_afe_tokenization.py new file mode 100644 index 000000000..010e7dee4 --- /dev/null +++ b/openfe/tests/protocols/test_solvation_afe_tokenization.py @@ -0,0 +1,79 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe + +import openfe +from openfe.protocols import openmm_afe +from gufe.tests.test_tokenization import GufeTokenizableTestsMixin +import pytest + +""" +todo: +- AbsoluteSolvationProtocolResult +- AbsoluteSolvationProtocol +- AbsoluteSolvationProtocolUnit +""" + +@pytest.fixture +def protocol(): + return openmm_afe.AbsoluteSolvationProtocol( + openmm_afe.AbsoluteSolvationProtocol.default_settings() + ) + + +@pytest.fixture +def protocol_units(protocol, benzene_system): + pus = protocol.create( + stateA=benzene_system, + stateB=openfe.ChemicalSystem({'solvent': openfe.SolventComponent()}), + mapping=None, + ) + return list(pus.protocol_units) + + +@pytest.fixture +def solvent_protocol_unit(protocol_units): + for pu in protocol_units: + if isinstance(pu, openmm_afe.AbsoluteSolventTransformUnit): + return pu + + +@pytest.fixture +def vacuum_protocol_unit(protocol_units): + for pu in protocol_units: + if isinstance(pu, openmm_afe.AbsoluteVacuumTransformUnit): + return pu + +class TestAbsoluteSolvationProtocol(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteSolvationProtocol + key = "AbsoluteSolvationProtocol-fd22076bcea777207beb86ef7a6ded81" + repr = f"<{key}>" + + @pytest.fixture() + def instance(self, protocol): + return protocol + + +class TestAbsoluteSolventTransformUnit(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteSolventTransformUnit + repr = "AbsoluteSolventTransformUnit(Absolute Solvation, benzene solvent leg: repeat 2 generation 0)" + key = None + + @pytest.fixture() + def instance(self, solvent_protocol_unit): + return solvent_protocol_unit + + def test_key_stable(self): + pytest.skip() + + +class TestAbsoluteVacuumTransformUnit(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteVacuumTransformUnit + repr = "AbsoluteVacuumTransformUnit(Absolute Solvation, benzene vacuum leg: repeat 2 generation 0)" + key = None + + @pytest.fixture() + def instance(self, vacuum_protocol_unit): + return vacuum_protocol_unit + + def test_key_stable(self): + pytest.skip() From 1c2cbff9c20abb21835587e69573bb09b6d708dc Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 13:14:59 +0100 Subject: [PATCH 56/74] simplify getting alchemical atom ids --- openfe/protocols/openmm_afe/base.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py index 7bb670303..8381cdc52 100644 --- a/openfe/protocols/openmm_afe/base.py +++ b/openfe/protocols/openmm_afe/base.py @@ -150,15 +150,12 @@ def _get_alchemical_indices(omm_top: openmm.Topology, [comp_resids[key] for key in alchem_comps['stateA']] ) - # get the alchemicical residues from the topology - alchres = [ - r for r in omm_top.residues() if r.index in residxs - ] - + # get the alchemicical atom ids atom_ids = [] - for res in alchres: - atom_ids.extend([at.index for at in res.atoms()]) + for r in omm_top.residues(): + if r.index in residxs: + atom_ids.extend([at.index for at in r.atoms()]) return atom_ids From c6af8af441406acc19e9d61ad5982c6a987f50cb Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 13:23:55 +0100 Subject: [PATCH 57/74] remove bad func pattern --- .../test_openmm_afe_solvation_protocol.py | 55 ++++++++----------- .../test_solvation_afe_tokenization.py | 2 - 2 files changed, 22 insertions(+), 35 deletions(-) diff --git a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py index 508994b5f..f4f96ed09 100644 --- a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py +++ b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py @@ -9,18 +9,19 @@ from openfe import ChemicalSystem, SolventComponent from openfe.protocols import openmm_afe from openfe.protocols.openmm_afe import ( - AbsoluteSolventTransformUnit, AbsoluteVacuumTransformUnit + AbsoluteSolventTransformUnit, AbsoluteVacuumTransformUnit, + AbsoluteSolvationProtocol, ) from openfe.protocols.openmm_utils import system_validation @pytest.fixture() def default_settings(): - return openmm_afe.AbsoluteSolvationProtocol.default_settings() + return AbsoluteSolvationProtocol.default_settings() def test_create_default_settings(): - settings = openmm_afe.AbsoluteSolvationProtocol.default_settings() + settings = AbsoluteSolvationProtocol.default_settings() assert settings @@ -40,19 +41,19 @@ def test_incorrect_window_settings(val, default_settings): def test_create_default_protocol(default_settings): # this is roughly how it should be created - protocol = openmm_afe.AbsoluteSolvationProtocol( + protocol = AbsoluteSolvationProtocol( settings=default_settings, ) assert protocol def test_serialize_protocol(default_settings): - protocol = openmm_afe.AbsoluteSolvationProtocol( + protocol = AbsoluteSolvationProtocol( settings=default_settings, ) ser = protocol.to_dict() - ret = openmm_afe.AbsoluteSolvationProtocol.from_dict(ser) + ret = AbsoluteSolvationProtocol.from_dict(ser) assert protocol == ret @@ -71,10 +72,8 @@ def test_validate_solvent_endstates_protcomp( 'solvent': SolventComponent(), }) - func = openmm_afe.AbsoluteSolvationProtocol._validate_solvent_endstates - with pytest.raises(ValueError, match="Protein components are not allowed"): - comps = func(stateA, stateB) + comps = AbsoluteSolvationProtocol._validate_solvent_endstates(stateA, stateB) def test_validate_solvent_endstates_nosolvcomp_stateA( @@ -90,12 +89,10 @@ def test_validate_solvent_endstates_nosolvcomp_stateA( 'solvent': SolventComponent(), }) - func = openmm_afe.AbsoluteSolvationProtocol._validate_solvent_endstates - with pytest.raises( ValueError, match="No SolventComponent found in stateA" ): - comps = func(stateA, stateB) + comps = AbsoluteSolvationProtocol._validate_solvent_endstates(stateA, stateB) def test_validate_solvent_endstates_nosolvcomp_stateB( @@ -111,12 +108,10 @@ def test_validate_solvent_endstates_nosolvcomp_stateB( 'phenol': benzene_modifications['phenol'], }) - func = openmm_afe.AbsoluteSolvationProtocol._validate_solvent_endstates - with pytest.raises( ValueError, match="No SolventComponent found in stateB" ): - comps = func(stateA, stateB) + comps = AbsoluteSolvationProtocol._validate_solvent_endstates(stateA, stateB) def test_validate_alchem_comps_appearingB(benzene_modifications): stateA = ChemicalSystem({ @@ -130,10 +125,8 @@ def test_validate_alchem_comps_appearingB(benzene_modifications): alchem_comps = system_validation.get_alchemical_components(stateA, stateB) - func = openmm_afe.AbsoluteSolvationProtocol._validate_alchemical_components - with pytest.raises(ValueError, match='Components appearing in state B'): - func(alchem_comps) + AbsoluteSolvationProtocol._validate_alchemical_components(alchem_comps) def test_validate_alchem_comps_multi(benzene_modifications): @@ -151,10 +144,8 @@ def test_validate_alchem_comps_multi(benzene_modifications): assert len(alchem_comps['stateA']) == 2 - func = openmm_afe.AbsoluteSolvationProtocol._validate_alchemical_components - with pytest.raises(ValueError, match='More than one alchemical'): - func(alchem_comps) + AbsoluteSolvationProtocol._validate_alchemical_components(alchem_comps) def test_validate_alchem_nonsmc(benzene_modifications): @@ -169,10 +160,8 @@ def test_validate_alchem_nonsmc(benzene_modifications): alchem_comps = system_validation.get_alchemical_components(stateA, stateB) - func = openmm_afe.AbsoluteSolvationProtocol._validate_alchemical_components - with pytest.raises(ValueError, match='Non SmallMoleculeComponent'): - func(alchem_comps) + AbsoluteSolvationProtocol._validate_alchemical_components(alchem_comps) def test_vac_bad_nonbonded(benzene_modifications): @@ -285,7 +274,7 @@ def test_dry_run_solv_benzene(benzene_modifications, tmpdir): def test_dry_run_solv_benzene_tip4p(benzene_modifications, tmpdir): - s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s = AbsoluteSolvationProtocol.default_settings() s.alchemsampler_settings.n_repeats = 1 s.forcefield_settings.forcefields = [ "amber/ff14SB.xml", # ff14SB protein force field @@ -295,7 +284,7 @@ def test_dry_run_solv_benzene_tip4p(benzene_modifications, tmpdir): s.solvation_settings.solvent_model = 'tip4pew' s.integrator_settings.reassign_velocities = True - protocol = openmm_afe.AbsoluteSolvationProtocol( + protocol = AbsoluteSolvationProtocol( settings=s, ) @@ -325,11 +314,11 @@ def test_dry_run_solv_benzene_tip4p(benzene_modifications, tmpdir): def test_nreplicas_lambda_mismatch(benzene_modifications, tmpdir): - s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s = AbsoluteSolvationProtocol.default_settings() s.alchemsampler_settings.n_repeats = 1 s.alchemsampler_settings.n_replicas = 12 - protocol = openmm_afe.AbsoluteSolvationProtocol( + protocol = AbsoluteSolvationProtocol( settings=s, ) @@ -357,11 +346,11 @@ def test_nreplicas_lambda_mismatch(benzene_modifications, tmpdir): def test_high_timestep(benzene_modifications, tmpdir): - s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s = AbsoluteSolvationProtocol.default_settings() s.alchemsampler_settings.n_repeats = 1 s.forcefield_settings.hydrogen_mass = 1.0 - protocol = openmm_afe.AbsoluteSolvationProtocol( + protocol = AbsoluteSolvationProtocol( settings=s, ) @@ -390,7 +379,7 @@ def test_high_timestep(benzene_modifications, tmpdir): @pytest.fixture def benzene_solvation_dag(benzene_modifications): - s = openmm_afe.AbsoluteSolvationProtocol.default_settings() + s = AbsoluteSolvationProtocol.default_settings() protocol = openmm_afe.AbsoluteSolvationProtocol( settings=s, @@ -450,8 +439,8 @@ def test_gather(benzene_solvation_dag, tmpdir): scratch_basedir=tmpdir, keep_shared=True) - protocol = openmm_afe.AbsoluteSolvationProtocol( - settings=openmm_afe.AbsoluteSolvationProtocol.default_settings(), + protocol = AbsoluteSolvationProtocol( + settings=AbsoluteSolvationProtocol.default_settings(), ) res = protocol.gather([dagres]) diff --git a/openfe/tests/protocols/test_solvation_afe_tokenization.py b/openfe/tests/protocols/test_solvation_afe_tokenization.py index 010e7dee4..a34604e59 100644 --- a/openfe/tests/protocols/test_solvation_afe_tokenization.py +++ b/openfe/tests/protocols/test_solvation_afe_tokenization.py @@ -9,8 +9,6 @@ """ todo: - AbsoluteSolvationProtocolResult -- AbsoluteSolvationProtocol -- AbsoluteSolvationProtocolUnit """ @pytest.fixture From 0404be709fcf641846d077fe7b312e6e36ff7a06 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 13:30:48 +0100 Subject: [PATCH 58/74] cleanup comments --- .../protocols/test_openmm_afe_solvation_protocol.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py index f4f96ed09..5f1b3f3d5 100644 --- a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py +++ b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py @@ -206,7 +206,8 @@ def test_dry_run_vac_benzene(benzene_modifications, 'solvent': SolventComponent(), }) - # create DAG from protocol and take first (and only) work unit from within + # Create DAG from protocol, get the vacuum and solvent units + # and eventually dry run the first vacuum unit dag = protocol.create( stateA=stateA, stateB=stateB, @@ -247,7 +248,8 @@ def test_dry_run_solv_benzene(benzene_modifications, tmpdir): 'solvent': SolventComponent(), }) - # create DAG from protocol and take first (and only) work unit from within + # Create DAG from protocol, get the vacuum and solvent units + # and eventually dry run the first solvent unit dag = protocol.create( stateA=stateA, stateB=stateB, @@ -297,7 +299,8 @@ def test_dry_run_solv_benzene_tip4p(benzene_modifications, tmpdir): 'solvent': SolventComponent(), }) - # create DAG from protocol and take first (and only) work unit from within + # Create DAG from protocol, get the vacuum and solvent units + # and eventually dry run the first solvent unit dag = protocol.create( stateA=stateA, stateB=stateB, @@ -331,7 +334,6 @@ def test_nreplicas_lambda_mismatch(benzene_modifications, tmpdir): 'solvent': SolventComponent(), }) - # create DAG from protocol and take first (and only) work unit from within dag = protocol.create( stateA=stateA, stateB=stateB, @@ -363,7 +365,6 @@ def test_high_timestep(benzene_modifications, tmpdir): 'solvent': SolventComponent(), }) - # create DAG from protocol and take first (and only) work unit from within dag = protocol.create( stateA=stateA, stateB=stateB, From 1aac5ecd257f44e412fed15dbae3cd68333ca70a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 14:25:28 +0100 Subject: [PATCH 59/74] Use abstractmethod instead of NotImplemented --- openfe/protocols/openmm_afe/base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py index 8381cdc52..b36540c49 100644 --- a/openfe/protocols/openmm_afe/base.py +++ b/openfe/protocols/openmm_afe/base.py @@ -31,6 +31,7 @@ """ from __future__ import annotations +import abc import os import logging @@ -221,6 +222,7 @@ def _set_optional_path(basepath): self.scratch_basepath = _set_optional_path(scratch_basepath) self.shared_basepath = _set_optional_path(shared_basepath) + @abc.abstractmethod def _get_components(self): """ Get the relevant components to create the alchemical system with. @@ -229,8 +231,9 @@ def _get_components(self): ---- Must be implemented in the child class. """ - raise NotImplementedError + ... + @abc.abstractmethod def _handle_settings(self): """ Get a dictionary with the following entries: @@ -253,7 +256,7 @@ def _handle_settings(self): ---- Must be implemented in the child class. """ - raise NotImplementedError + ... def _get_system_generator( self, settings: dict[str, SettingsBaseModel], From 03aeaa1274723ada60400d3b206b396b6efabe99 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 15:03:25 +0100 Subject: [PATCH 60/74] Fixing up some docstrings --- openfe/protocols/openmm_afe/base.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py index b36540c49..0a8a2fcf4 100644 --- a/openfe/protocols/openmm_afe/base.py +++ b/openfe/protocols/openmm_afe/base.py @@ -7,27 +7,11 @@ This module mostly implements a base unit class for AFE transformations. -Current limitations -------------------- -* Disapearing molecules are only allowed in state A. Support for - appearing molecules will be added in due course. -* Only small molecules are allowed to act as alchemical molecules. - Alchemically changing protein or solvent components would induce - perturbations which are too large to be handled by this Protocol. - - -Acknowledgements ----------------- -* Originally based on hydration.py in - `espaloma `_ - - TODO ---- * Add in all the AlchemicalFactory and AlchemicalRegion kwargs as settings. * Allow for a more flexible setting of Lambda regions. -* Add support for restraints. """ from __future__ import annotations From 85eee9b322b262a3ca9276cf71eaf6ada3b30d36 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 15:43:26 +0100 Subject: [PATCH 61/74] Updates docs ready for PR 570 --- openfe/protocols/openmm_afe/__init__.py | 12 ++++++++++++ openfe/protocols/openmm_afe/base.py | 5 +++-- openfe/protocols/openmm_afe/equil_afe_settings.py | 7 +++++++ .../openmm_afe/equil_solvation_afe_method.py | 12 ++++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/__init__.py b/openfe/protocols/openmm_afe/__init__.py index c5023d969..def779d7b 100644 --- a/openfe/protocols/openmm_afe/__init__.py +++ b/openfe/protocols/openmm_afe/__init__.py @@ -1,5 +1,9 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe +""" +Run absolute free energy calculations using OpenMM and OpenMMTools. + +""" from .equil_solvation_afe_method import ( AbsoluteSolvationProtocol, @@ -8,3 +12,11 @@ AbsoluteVacuumTransformUnit, AbsoluteSolventTransformUnit, ) + +__all__ = [ + "AbsoluteSolvationProtocol", + "AbsoluteSolvationSettings", + "AbsoluteSolvationProtocolResult", + "AbsoluteVacuumTransformUnit", + "AbsoluteSolventTransformUnit", +] diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py index 0a8a2fcf4..c551be008 100644 --- a/openfe/protocols/openmm_afe/base.py +++ b/openfe/protocols/openmm_afe/base.py @@ -3,9 +3,10 @@ """OpenMM Equilibrium AFE Protocol base classes =============================================== -Base classes for the equilibrium OpenMM absolute free energy classes. +Base classes for the equilibrium OpenMM absolute free energy ProtocolUnits. -This module mostly implements a base unit class for AFE transformations. +Thist mostly implements BaseAbsoluteTransformUnit whose methods can be +overriden to define different types of alchemical transformations. TODO ---- diff --git a/openfe/protocols/openmm_afe/equil_afe_settings.py b/openfe/protocols/openmm_afe/equil_afe_settings.py index 7e54b9dfd..b77ef892b 100644 --- a/openfe/protocols/openmm_afe/equil_afe_settings.py +++ b/openfe/protocols/openmm_afe/equil_afe_settings.py @@ -56,6 +56,13 @@ def must_be_positive(cls, v): class AbsoluteSolvationSettings(Settings): + """ + Configuration object for ``AbsoluteSolvationProtocol``. + + See Also + -------- + openfe.protocols.openmm_afe.AbsoluteSolvationProtocol + """ class Config: arbitrary_types_allowed = True diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index ad2ad4c7b..e547c2c32 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -24,6 +24,7 @@ ---------------- * Originally based on hydration.py in `espaloma `_ + """ from __future__ import annotations @@ -184,6 +185,17 @@ def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt class AbsoluteSolvationProtocol(gufe.Protocol): + """ + Absolute solvation free energy calculations using OpenMM and OpenMMTools. + + See Also + -------- + openfe.protocols + openfe.protocols.openmm_afe.AbsoluteSolvationSettings + openfe.protocols.openmm_afe.AbsoluteSolvationProtocolResult + openfe.protocols.openmm_afe.AbsoluteVacuumTransformUnit + openfe.protocols.openmm_afe.AbsoluteSolventTransformUnit + """ result_cls = AbsoluteSolvationProtocolResult _settings: AbsoluteSolvationSettings From c50bf90f3875b86964a752c34fd485104dee0e3d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 20:19:22 +0100 Subject: [PATCH 62/74] Add in the remaining protocol unit result methods --- .../openmm_afe/equil_solvation_afe_method.py | 169 ++++++++++++++++-- 1 file changed, 153 insertions(+), 16 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index e547c2c32..d929f1d12 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -79,14 +79,15 @@ def __init__(self, **data): def get_individual_estimates(self) -> dict[str, list[tuple[unit.Quantity, unit.Quantity]]]: """ - Return a dictionary (keyed as `solvent` and `vacuum`) with list - of tuples containing the individual free energy estimates and - associated MBAR errors for each repeat of the vacuum and solvent - calculations. + Get the individual estimate of the free energies. Returns ------- dGs : dict[str, list[tuple[unit.Quantity, unit.Quantity]]] + A dictionary, keyed `solvent` and `vacuum` for each leg + of the thermodynamic cycle, with lists of tuples containing + the individual free energy estimates and associated MBAR + uncertainties for each repeat of that simulation type. """ vac_dGs = [] solv_dGs = [] @@ -155,22 +156,24 @@ def _get_stdev(estimates): def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt.NDArray, unit.Quantity]]]]: """ - Get a dictionary (keyed `solvent` and `vacuum`) with lists of the - reverse and forward analysis of the free energies for each repeat - of the vacuum and solvent calculations using uncorrelated production - samples. - - The returned forward and reverse analysis dictionaries have keys: - 'fractions' - the fraction of data used for this estimate - 'forward_DGs', 'reverse_DGs' - for each fraction of data, the estimate - 'forward_dDGs', 'reverse_dDGs' - for each estimate, the uncertainty - - The 'fractions' values are a numpy array, while the other arrays are - Quantity arrays, with units attached. + Get the reverse and forward analysis of the free energies. Returns ------- forward_reverse : dict[str, list[dict[str, Union[npt.NDArray, unit.Quantity]]]] + A dictionary, keyed `solvent` and `vacuum` for each leg of the + thermodynamic cycle which each contain a list of dictionaries + containing the forward and reverse analysis of each repeat + of that simulation type. + + The forward and reverse analysis dictionaries contain: + - `fractions`: npt.NDArray + The fractions of data used for the estimates + - `forward_DGs`, `reverse_DGs`: unit.Quantity + The forward and reverse estimates for each fraction of data + - `forward_dDGs`, `reverse_dDGs`: unit.Quantity + The forward and reverse estimate uncertainty for each + fraction of data. """ forward_reverse = {} @@ -183,6 +186,140 @@ def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt return forward_reverse + def get_overlap_matrices(self) -> dict[str, list[dict[str, npt.NDArray]]]: + """ + Get a the MBAR overlap estimates for all legs of the simulation. + + Returns + ------- + overlap_stats : dict[str, list[dict[str, npt.NDArray]]] + A dictionary with keys `solvent` and `vacuum` for each + leg of the thermodynamic cycle, which each containing a + list of dictionaries with the MBAR overlap estimates of + each repeat of that simulation type. + + The underlying MBAR dictionaries contain the following keys: + * ``scalar``: One minus the largest nontrivial eigenvalue + * ``eigenvalues``: The sorted (descending) eigenvalues of the + overlap matrix + * ``matrix``: Estimated overlap matrix of observing a sample from + state i in state j + """ + # Loop through and get the repeats and get the matrices + overlap_stats = {} + + for key in ['solvent', 'vacuum']: + overlap_stats[key] = [ + pus[0].outputs['unit_mbar_overlap'] + for pus in self.data[key].values() + ] + + return overlap_stats + + def get_replica_transition_statistics(self) -> dict[str, list[dict[str, npt.NDArray]]]: + """ + Get the replica exchange transition statistics for all + legs of the simulation. + + Note + ---- + This is currently only available in cases where a replica exchange + simulation was run. + + Returns + ------- + repex_stats : dict[str, list[dict[str, npt.NDArray]]] + A dictionary with keys `solvent` and `vacuum` for each + leg of the thermodynamic cycle, which each containing + a list of dictionaries containing the replica transition + statistics for each repeat of that simulation type. + + The replica transition statistics dictionaries contain the following: + * ``eigenvalues``: The sorted (descending) eigenvalues of the + lambda state transition matrix + * ``matrix``: The transition matrix estimate of a replica switching + from state i to state j. + """ + repex_stats = {} + try: + for key in ['solvent', 'vacuum']: + repex_stats[key] = [ + pus[0].outputs['replica_exchange_statistics'] + for pus in self.data[key].values() + ] + except KeyError: + errmsg = ("Replica exchange statistics were not found, " + "did you run a repex calculation?") + raise ValueError(errmsg) + + return repex_stats + + def get_replica_states(self) -> dict[str, list[npt.NDArray]]: + """ + Get the timeseries of replica states for all simulation legs. + + Returns + ------- + replica_states : dict[str, list[npt.NDArray]] + Dictionary keyed `solvent` and `vacuum` for each leg of + the thermodynamic cycle, with lists of replica states + timeseries for each repeat of that simulation type. + """ + replica_states = {} + + for key in ['solvent', 'vacuum']: + replicate_states[key] = [ + pus[0].output['replica_states'] + for pus in self.data[key].values() + ] + return replica_states + + def equilibration_iterations(self) -> dict[str, list[float]]: + """ + Get the number of equilibration iterations for each simulation. + + Returns + ------- + equilibration_lengths : dict[str, list[float]] + Dictionary keyed `solvent` and `vacuum` for each leg + of the thermodynamic cycle, with lists containing the + number of equilibration iterations for each repeat + of that simulation type. + """ + equilibration_lengths = {} + + for key in ['solvent', 'vacuum']: + equilibration_lengths[key] = [ + pus[0].output['equilibration_iterations'] + for pus in self.data[key].values() + ] + + return equilibration_lengths + + def production_iterations(self) -> dict[str, list[float]]: + """ + Get the number of production iterations for each simulation. + Returns the number of uncorrelated production samples for each + repeat of the calculation. + + Returns + ------- + production_lengths : dict[str, list[float]] + Dictionary keyed `solvent` and `vacuum` for each leg of the + thermodynamic cycle, with lists with the number + of production iterations for each repeat of that simulation + type. + """a + production_lengths = {} + + for key in ['solvent', 'vacuum']: + equilibration_lengths[key] = [ + pus[0].output['production_iterations'] + for pus in self.data[key].values() + ] + + return production_lengths + class AbsoluteSolvationProtocol(gufe.Protocol): """ From 32af083e446ce665b96b3842b014041d770b63c4 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 20:23:10 +0100 Subject: [PATCH 63/74] Fix typo --- openfe/protocols/openmm_afe/equil_solvation_afe_method.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index d929f1d12..be2971ad5 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -309,7 +309,7 @@ def production_iterations(self) -> dict[str, list[float]]: thermodynamic cycle, with lists with the number of production iterations for each repeat of that simulation type. - """a + """ production_lengths = {} for key in ['solvent', 'vacuum']: From 91d32b923ad3e4ec934e8caef9972c3b97da109d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 20:25:39 +0100 Subject: [PATCH 64/74] bad indentation --- openfe/protocols/openmm_afe/equil_solvation_afe_method.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index be2971ad5..05812041d 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -312,7 +312,7 @@ def production_iterations(self) -> dict[str, list[float]]: """ production_lengths = {} - for key in ['solvent', 'vacuum']: + for key in ['solvent', 'vacuum']: equilibration_lengths[key] = [ pus[0].output['production_iterations'] for pus in self.data[key].values() From b6da3f1cbf6e70143cf6c59a0cefb0f808a06ad3 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Oct 2023 23:49:38 +0100 Subject: [PATCH 65/74] fix mypy issues --- .../openmm_afe/equil_solvation_afe_method.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 05812041d..9eba05f8a 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -176,7 +176,7 @@ def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt fraction of data. """ - forward_reverse = {} + forward_reverse: dict[str, list[dict[str, Union[npt.NDArray, unit.Quantity]]]] = {} for key in ['solvent', 'vacuum']: forward_reverse[key] = [ @@ -206,7 +206,7 @@ def get_overlap_matrices(self) -> dict[str, list[dict[str, npt.NDArray]]]: state i in state j """ # Loop through and get the repeats and get the matrices - overlap_stats = {} + overlap_stats: dict[str, list[dict[str, npt.NDArray]]] = {} for key in ['solvent', 'vacuum']: overlap_stats[key] = [ @@ -240,7 +240,7 @@ def get_replica_transition_statistics(self) -> dict[str, list[dict[str, npt.NDAr * ``matrix``: The transition matrix estimate of a replica switching from state i to state j. """ - repex_stats = {} + repex_stats: dict[str, list[dict[str, npt.NDArray]]] = {} try: for key in ['solvent', 'vacuum']: repex_stats[key] = [ @@ -265,7 +265,7 @@ def get_replica_states(self) -> dict[str, list[npt.NDArray]]: the thermodynamic cycle, with lists of replica states timeseries for each repeat of that simulation type. """ - replica_states = {} + replica_states: dict[str, list[npt.NDArray]] = {} for key in ['solvent', 'vacuum']: replicate_states[key] = [ @@ -286,7 +286,7 @@ def equilibration_iterations(self) -> dict[str, list[float]]: number of equilibration iterations for each repeat of that simulation type. """ - equilibration_lengths = {} + equilibration_lengths: dict[str, list[float]] = {} for key in ['solvent', 'vacuum']: equilibration_lengths[key] = [ @@ -310,7 +310,7 @@ def production_iterations(self) -> dict[str, list[float]]: of production iterations for each repeat of that simulation type. """ - production_lengths = {} + production_lengths: dict[str, list[float]] = {} for key in ['solvent', 'vacuum']: equilibration_lengths[key] = [ From e1b44e5dc636f0a7cd81d321304d16d67fde7255 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 17 Oct 2023 00:29:59 +0100 Subject: [PATCH 66/74] first stab at results testing for solvation afe --- .../openmm_afe/equil_solvation_afe_method.py | 4 +- ..._absolute_solvation_transformation.json.gz | Bin 0 -> 42839 bytes openfe/tests/data/openmm_afe/__init__.py | 1 + openfe/tests/protocols/conftest.py | 16 ++- .../test_openmm_afe_solvation_protocol.py | 115 ++++++++++++++++++ .../test_openmm_equil_rfe_protocols.py | 8 +- 6 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 openfe/tests/data/openmm_afe/CN_absolute_solvation_transformation.json.gz create mode 100644 openfe/tests/data/openmm_afe/__init__.py diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 9eba05f8a..e9bbf5224 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -268,7 +268,7 @@ def get_replica_states(self) -> dict[str, list[npt.NDArray]]: replica_states: dict[str, list[npt.NDArray]] = {} for key in ['solvent', 'vacuum']: - replicate_states[key] = [ + replica_states[key] = [ pus[0].output['replica_states'] for pus in self.data[key].values() ] @@ -313,7 +313,7 @@ def production_iterations(self) -> dict[str, list[float]]: production_lengths: dict[str, list[float]] = {} for key in ['solvent', 'vacuum']: - equilibration_lengths[key] = [ + production_lengths[key] = [ pus[0].output['production_iterations'] for pus in self.data[key].values() ] diff --git a/openfe/tests/data/openmm_afe/CN_absolute_solvation_transformation.json.gz b/openfe/tests/data/openmm_afe/CN_absolute_solvation_transformation.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..c4f3a161974d8eb8153937bf7a0f9284941c9bad GIT binary patch literal 42839 zcmbT6Q;;q^u&&2mW2~`l+qT!(`o^|x+qP}nwr$({Kl|p?xjj{>>ZH1F(n-4BBoBTh z6jWUgtqBmQu$-QOp`)#pvy+M5f5yeY$=udP&&k2S#?jQ)!TNuPbQX@bHb7UJe>@jQ z7QU%R<{;Wg=A{~F6ze9~lVHJ=qzEDE6aIxq@I}!;DoOZ$c4jor(2=w(N}peQ;=8%w zXnk@vnwic_T?p&RY<3-0pXuFrpQU=WPHbvyd~3F{gY3G3j(sYkv5uZrKXy+wxmP9+a;)3FGWXT)o03Yp5$c~y@&>frhIL^&(*rE zTUYtgrbIXXdulPk-#Kx1E_XdNkdHn?GkkTZMMCM{%vs(oQshNvx8*-Y3h zJm*_$r5~EMFpq6<*dO>~Y}OmChqK;&4PBN@R*uV91cKR+lGMzv7SWMAlah-_3+8HH zw0(9r9_n0ZJn=s>0YGF^m1iLQK%V)RWKDli!re zAHDY1ljqzQX|a`8-uQ_u(r)9=7Ugpy!7~M;T*J^9Nbza7-JPBSO{u{5wT9+WY}&_0_J~`-x(Z zO+G@^(9u#nnjDMYoXfslINq zyJvOCMu(66EL46uS)UjH204DH=lPN^VmW1iu?gr!U-aX>`Td%L6U-YpA+EMQt|kT{ z0&RXwS8Y@SJ#nbh7fM?T7s{n$tIr{!XfTBb_o3)3Lqx~uCMkUcB-0j2qtI5Grq~zV+HnDzN+~5*d`gF((|3{IldNy)LM~vlYa_U!fP(VPDFTVinqa^6 zR3}zeu1L~qs(itz&{H7=DHUOOQ$yEjx1gNyM?{PaRE?-X8`ZqYO)K_j2?0-FiaSr% z+41w`U^@Qj@tw^$k^h0C$yS>dGHzEQD-#dhsk9Osmk7y`YK|aKt!n7Qz4Y z3cpa25=2LJoOA3LN>Py#{U(x|S`T1rjB4LpW#35^ZH35bejiE~L0(mz$b2el>1?$X zEj|i(=c6a#;Jap8uPn`Cj?8hXiPDx~d1Z{>2`lMLSdb=?bpyPLD6dFA{W3ehMK00K z+AU=7RdME+t@+c_P8+`#tj$)^pFSFL@tX5Wm$2&UbGMxSNjGEUW?0F=7a!Ve$dN08 z@8PLmON_15P(+Md`0!>a*K*I|q*oDMwag`N)WGaFOvQnPJDANptD5e_H)-GCsqDCz z5-d`r-004@Q(hjgs&1_MmF)C*X%AT^AJ-hN)RZI1pjRS!XKS*TKOIoU20g9J zcXml#x^DZVRcF2|y5=?kC`-DsPzyM-3C=!Mb|oacXZA0pbcPscWQhIjN(WcYSf z)79K5x1Dz?9OTH?`-ngk?phM^$)z%CSZ`q@z6*A|>-lWY*%~J&6STy8{7_ZK9xqPn zb%+VVELyeP(d0Wsw|g!qT}f0Ih5$svXBFr}Vy!tZva?^YJJ&o*E_Yj`Tcx66#_iKYxh{kSoaHB<=EjEI(>o;cKLy1mDhUB{ zv5&p}?I-aj|dXCg;IDfrW^?nd@=!i;|pT``#nW)0Qy$bn_aDjoYw z)K{p)E3~!Ls1GGKO7j$!MU5D$4qYnN2tO9~GCmaBa`j9j_|6{{YsG-nQ9Hl*DFv6! z?!&N#sm1ThITH4qnS|ew39*+9J519~IrFaj&889t;e5hho$Pp_F1SD2rWYF2*-A;r z4wF=)qwO{-l6tL^#l?Uz9pem4b|zx*)9tjP`gz5MKpw&6!ir72h86fBPa=2ba&Gr1 z^)J`7?D~eRy#3CQxOkhZqwk=3-@xZz^s43lpV&hW_}y8*Z;hUN_ihKD=9b56tm{qI zjaJJh+f~4B&3w-jR`kX0BX&V{ssvC+gm9AUaWEaxnHqfuWT<#Qgs1LKXC^QGW?=aF zbNNy#PAR3MJe2z;lw_S+({K6iMSX7m9Q+at&F^Jfn8 z&3Cj$Dt$rhxd#9fLp7vAH;BCvcf4L>xmknp28<21EgC*nY%a0GibBrtbaPR(4>?+| zG(9oiKX6^*I5`oK(V8Red_ec%GGf&^L<7H(h(2^^G&!8mFP+d^^+A=ns$|I)6-u?FWRqLiUd;JbrG!9HsrkdJZ)&uZ!*B9+lix%L0avg2S!p0co1B!vZ%HY^ z6NKUkKZhSUOd0-NO*K9FW5aIFd84P^vXyP;=CO+LZL-wCX-4aw^hu2!&oTXc)c zLEIg+(Jl-B_zbK>>$0lMVTL- zJsRhQV+KWlY0V)EEj`sQGdsY2yb=56^UNt5XDE(W03SvDekfX2j{$ch+kLAWR69W1 zEgu3J;|r~GeCV%y`GOt*VV6{c50dgU*%|XFxKos%p$R2upkQsN;|XHQ3du6$od35h$ADv~Sqn6AMDZ+Z!RIenK_} zzN|j=Fw51BgxN7)H*z$#ej(A=WD5_6E-1~ z__0k$97K|MkgyC0lI&=g#2_?w-T-K;tGk9^rI}M-x88F<`4h`VAPo)tmFAC79k36x zYmu57u*6pZEvf2r%6xtc(-KL;KKwfQX(2P9Fqh((Ag)4|OW-3xqGutHPKaDKtXZX; zGVSKCHDg#>9g;xRs>Mv}6FWF5djAG>mM(w-MY^aPQhx~^HQj%g#8&qkW?z_zEr>_8 z33lYqRC4gCACpp!UCtgsd1dGxs1+Fehh?TpA+ao|ln;?M#qBazWWb-KiOt?`kz6+U( zl>em&=t^936e`%<1)3t;;3nR{TKbG+x{D@--aaKVF02ij{NnSJ3G`{o5#S58m33^< zp~b!*z$CK1hUf-G0wyOd%z%eS_0I~!MB9#HlD9QqzlvOtBCxZ;aETsC8a1WCUMxoy z?T(pNpbu@`>qXmAas4#QdyW{43fm92X%5a=fz?p%A6zKGl^O>i13O%}#Lq58yAgbh z7%zXKB7mi?jN)>5ivjx0C%LCMG&R51n ztbWRqPr&d)#Lje|S12P^!*B}3WQZy@-$;Q^Ut)**AHkpAP)H`0;3jb-+T=45=0ThW zO`an|C`{7p-G*nUskYKP zRoyIx0%blopbPUG5}wBLV!5~$4ca#DD+!&3KBIgR(8WI>Xw^6UI1b&MB(yT;fH3L; zr?OS3IQfYFv2J_9g-AiyaBd|$&V8dcHY@O)&muTHyG6^~EQaieVmcx)>F+x#lE%O7 z4a&cY*s7|S*BtKSf3K@e(-uaX8l2XGb-0$1$A0}__Bcq`4t-FV^V*%?$iQq zjeM*qUAi$y)hpmo6?j7N2v^%O_Ou5d5k)Q0P6BTC=(iO6@fbXRTp*t`*CA&prCaKd zs>Md&j?69=7#D@pL`gM=f8&8Gp8Rq34K^G>Fc5O|DY@*1fm%K{@{;XXBkp0=WFHFS z$19B3=fFEbS&ahk{_qB>T+pQ3kBmWhyP#ue zZw;YGbl9ED;p7UH`jJbi_JeO}BRp3r&A37ucTU{UMk|^#u1X$Dd?9Kf-)Ao1Hor-2 zq!}_MFuFa@FzK6AGzIIaL*yZP*q#KU;^92+9ngEoxLwr2TJ}^tBBk$ zWXJadP;7QKxsDj2t;C|4US(mEHywjbA1=pvaF{Asft3$T2zjhj;?S>M7=?P_ zBam!peiM5Y&B&+>hpsPFjG8;~cfKZ~Q6KcGWh6DqNlj5pkscsGg3q(WG(km0DU$EG zrl8^X$+5fk7k1^MltdxV2E@+3AcjOZ1!^`=3FJ@|I)BVhtg+Y-_hewFD6W1)l4lL} zS9CB5Ay5AGxVdv*v~Z~()EKW~DWn+mct0W?ok&;%R0bi{KMJ!Jy8H^Yn~Bcn z6jo?v6uLGs4aG1Wl{alAyFT#7bOM|$nkXM`x%#yqhTl7QIILVmBH9SIKmtiVTTlm< zVL?U4o+zDja2bqz=mr&{GUAd+?PuIKQt^Ju1$Bwc5LGo;BJ`S(72XNl zXr2wt!5y(NIRvtR`&|juuaZ-)^&m-&^0`1gPL5xy-N&Gn15=OD9Y&n7GnzF8spF2m zZQGJy-k&P1*_%+vE&|A@?6au&sL>tZ2}eA}cp0=DdncjHH3m+?&a(lQAe>8r9`CiJcd8k&cm(t(RE{pk5{KGorX1%*w- zkxFc&9~DsAf6lsVmR-dLu+(b%S(Ovaszk=RN!69~%n^!<={K;iNhHM!{(n;7wbT@0 zP%%j1*_TR73xLy#D$#B$rIC*i`|mteC&+Rn$>r5Il~AE32m z;;OGnI(mznSD<>V^QEDr!mVn3&?o{ABDjNMPN&ckz*FVYTpD;weCEu<=jMRGJva|> z6Rfvy2Vw$`f1AMYmX0VF({R&?^(Ty_&XTc+I{NPu(58e6()%*CK=_CNS)25XE&!LB zpUrn0BXUJxf&TP#|Fw`$T*W1haJE-I={-9*F=*1hf!0ck6ksE{!)HG$=tvV9AE4sq z_KqruK`zuhmGx=gS^{9!+ZqCW4mWc+m624@{bkKrn0)ueY25X!H)!K&DrdJG&~Lefmt;ZlUkZwU!hh^aJ=3 zA0(ZHJF1B`?Ze|jy*}`HJ0p>b=?{y%PavF0>H8m|3T&qnpkv0y9w~VW`s=h0%lRJ3 zb6z^q!X`f>lMAoV=sI(F<&NVfYq@R8>ME%^ime&dWx#W3#o-G~5A94<^!MqNf>5}& z0AitX;3-u}ll+*-Wt1dQB$i~weohKIw>^{J)t%(JQCX7fUc;#y2de`KM=4+?0SP}7 ze*q4Z!MsI0rcmZkl);SMA4yf-cpO*B?-&o9L|Zqvqo^f|=X;lJo$8l;ip%te`jjo& z%d*I{Ny_`7M=?@j40+r}CVt$nK;mE(L_71!sf=?mvnyKTFDG9&9(}T<7?9iOm%ZF7 z!snZB^{IkQI>}WT^#Hp1-!@dO%zZ5w22`TvG6+f#u`uc~=S?X-`VNg-Zic4Xe;Sn) zEFR3X<^O<|@7(>BRtgdfDU-BMC{%jXU8YP5&^MzClKxpvHANDZ8~}J)VCw3|wAuef z($hswbrq?GmHlGx9`d!Svl%F+xqFrVg%I@?Dxu&Juu^bk z92^km@xf=<>2XWV&YGahN99V5Y+y739YGe;9wqtN%RLL=;xTNudVxO8xCbl~qOyR`BW`2(7fYo7Z!c_cu1E zW>7F+^7+tC4jJ|?Sv9s_7GNqX7%=51=D!}Kv^Bf%R$W4+d=FZ!d|k$08hB%FhI7j~ zuMlFgiG%Ia@hm+X-2O$Mj8X+o=~!Ky*)@~aUazb5vY{d1AWe+lc%Nv>8CTvgjX|dD zuK|F^X5ao^dfLctw*+tW*-=is7)lt;%Mwym5fzm%!Q~HVQFpXONL_k?CKNZXgpsbn zx>Y?>VSJY-Sf0bGGKt{&C)<5~dk&{LXl=un6UIAuiW_^~C0kWMI5)fSiRUEf!W2=Q zs=VrvCRLO{>{O2!W#i@Yt7U%*q|2(yt2IC%$a17Tzo@a3wN`z0V~h0=YYR&&Hwgtszpyu+qQh{ipvT%{7= zc4f?^`cpZHWs@o0h<6E3cY9GX(Cldz%|^Cj16R3atlUg52(wXQ5!9!qOQcdjq#374 z9;~}OQa7iCb`P9vvtHmIkXX4At#6#x4QMeeoA>VS(JK~}G^`wZurxPE``12Z#-sQZ zFuSw8Kyn#Ftpu6t5XA!*RqdpI^hmqKp6}O1H+>R z_woNOvfRoL5w-qQ4Mr6-YtQ2nUx<{E{KTkx9WPBq;p>G^=$s@817D;=+G;wAQhvYx zpc$wun==z1+L)K-Pt&5#ka_L2K$y@~q`HRFK)Xb5GuYo$9e=~aVdzQH*L4_gi=f;w z)*1MYph31D*TUpvkIK_i*#P!^(JG&`AtAKhSTyqcBlazVRQ*p629 zAg>Vy2M;;s`pdI9<=z5e`jtVHC^$lxg*WMlBUFUmwdy-T_vpa0=@DFs$l1XJZ0AE2 z57*4vje~McCC|vK;84+Ub{_+hB9-)NS#hSX8OOO&#zByNEjQ(N=#V8r#)KThG@Nih z#4?K;*UzNsS$R&}ZR>^vHv@BV9xT7!HwjA?`vhex!%vZs!nk=YY&#vOr*_2nvI5NW z-mAsF)S*}Xe8Aw{$H|elUMHGrO>fgmfR>dKU3|v(`ESrgy+k|*rgHDL+^c?HLy%%H zX`E@YXa=(DH+k5iC85r4It2qQ{3KT{!jmx33H?{)NlOyrbj*@esc3jY0XV0N#BIMV zg0EJWV`{Nrlxo;EbugLdR&u_@QiOeV&pZ3v^rH6@G7(zHUrKoGG_5U<_XJZZGsewy zc?Tz_iRA0RZsv*wFV61dgMT|MftK#(&yOes_zI265E!#ziK+F!rLDNfKQEGC3Ym&q z{fhMjNspP&<3)Xz+H$xFNj$-FznzYv$5L7*?Vc94Xj+)-MX6mN4{u60pRlJFPFsO4 zv$t*ADXTV|)~WDoZE3~7Lhh;ZjEdwJaFq0S=qg!NYS$~$f1O)%A?y9}PA*_xwFbUh z?0Si}Zed7uY07_^C3b~?ZVlTBMHAI|$ z&B_B2OL9Rw5^nK;$RPxzjexVG1rLEEX9cyuCsXK1 z&VWcr!WTW`9jCU0BP{4$s2Ea@YHaeQ8R~uPq-O;kwWdE3Z9-RE3)*XalHl;s^&kg$ zDaB4kF?U``B@*4K0GNtWf~J!H-pAdAojsiBiXM<#5If#~8Td*MmxMh@Yngp_xx#+ywH74#8ki!+sPuKNbx}*jV+q zi#W5);}K7T8a4_Wxhq!XeK0n_EAk>AHP+qEsn82Q8P3&2UEuz+vMk&3-{|S@1|Rt( zraD26j`ep|B5^@Ww;XB-8+syWP+s1WP+MxDnZ^z{{el@|w1XqXvW5Mai8lIB=!TC9 z4>2S~;wPlq46t7&5<{Aj^Z;q2mmFf2n(gQ17-7OjK8#tzm|H_Dx0!2!Ixj@l5j&hH zdNv&wkNU79)LgfthdN*mb&^XmD_|7NvqgDXlui@4u0H!ANP;B%PCi){yK4C3y})uK zWkKJ@F4Ps^0#6arZ9!*k0A9~4@!z7pTYsbr`YIcB!$*6HeZvf(Js1W44KUGC_D0@X z1mBFeI(t%e^xxCaq(+Wj-4-`N5wR6IR8y$R^Hh;NPKcnU!7t>BJTD zhaHANCPvuM(a-HQ&thJ4RfiONbvgeqvcyMB$OKyEEut1u6Uau0!4NSv-cBq0jF{*B z8QWOem%`#_IKiC`2O9M)xaa7&Uv9=dlD7LngRO5M`se43S>P2mbmGrM*3k>oT$}eo zPeG>M8iMGXz+%?&1Z#}n8zc`+z2H{Jht_u+_(5HLoD1aLGt$6L#Ga=vc=!|7?Wu%M zJyO9Kn>3=8z)Um8q>e8Bj)0YGLEVEp?jCex3t}DF@}-KHo%jV);(_}2ld=ZI+`Hhj zppSz+3|`tXbc{@Y2hI(FwnCnuNqYdMpfdjny8hgslS$-*WIFf>^*C4d^-OgSecRJ` z7=mOvv6Do_8?}2lArhppA$ThwaYqqVK;QeE1uuPLExm3FxX@!WuHAH^7^( zZB0f%-@^9W(A&=<(WfbSp_&FIvdsI)jK=Y*{A2aV_L4}zP3jaAi-zADqM2Ps<;p{va(w5jBAhEVbGx-+mRiT(+*};VG}U|SL}t65=Qi8wfJp8 z(hEBw6gcol!bi1;1U-i#GK;tbYaS)wk+?Wzb0m>)B-~#Si}6}24Vgn{Q6mu2B8e(| z*R(#8s>+bZjfs0h(_$0=z-8NX&9$KjxFDWF=2PouVRx`2&s-B3fUoa@T;+?7BE}?( zokfeNW!%h8*8--vCu-xjdi3E@)?P!$awT*#sJaMvYRdHlLrs=!{6EBL*Wn2{3t_0s zs-h1x)N~650oqySIIfFcB-94ic`pi1+4(P|EM%ZjdVa@Z#4pGDmE$Z4%xMNNP0uP*dbP6p+YRmf;6rQ|6$zIwDUtLoa_ubOjuh zS!1B&sOo4IR7H9$JIs0yEQovgvbZ65VW(Lj;GW#W)dgQS*rTDV{!-gp#dD}6-2|?Q zJJC4Vb~JZahT)?O!wStJ=CWG|iyVY1e)w-Jlfqfg-xBNgbJ&kT1g+5u(hPF`Ron$b zmJV4%x6X2JL|!3G+XWhd$rv16f~Phpw%EzoxJJ||HLS|xP*eF4dSxZNM_tGeJx1O} z+y1n&{Qb9>1)3lebJHk(sHu<n0+nI$}hKop-bo zq_s>3hqqTCl5;Uj@T|v4>r}R|h3GuuidkS4%Ywcs2?KcmHZ4#n677jb(wr{i$i8|2 z28-EGU(_P<4|Z}88bb{J!+O4#aGPeNYn>e#a+MjuyYhUuF+ffL+mqRk2U>Hhp#HIJ zCTh$?{|n`6F!?U(%p&EXbl}r`E(=jYcN}Jl&$bU2S0M6IOT;c&jTW9 z8A>nYMOUh6cF8Po!&>c+5d69KvZib+a0n=$O^)OjgvxVstP<#>RRo8)g~O>9 z=73n(QE-i`74!K!twsxR(ryMjJch8|P)W<`3RdJXhR_E=OI`4boa?HvX)%Eg<~qNs zj}MiYR(qc}I!vbglPrxH^I5gu{(LOQjzk^_kSlnt4WRjheCwAZXiHm5(GUurEJ=Ia z>h~5A-G)vw3OunJOif}I@JX7_AwKlairN(+;}AKTljS%OKxZTE1ioCjkCRcBjlaFR z_OxX&q!)X`Ep%`l-%XJ6KvnRgNHNm|Lk;*5U($=b=vtuJ7~>8%6vJXx#sPBg5y=R? zb6x5+8)pkR0>&7{;`|Rw0u2XmZK{Qx!Hpb>A#R|hk|s3UKTV2;0F#sGseg4 zd)(KqYjJ=pe4*F+EVR0#MWu$)sZV0YKVcs0F%r?OcbZ8-r-#zfP#r@p%ke+5NCof! z9D|*NhjwDtYWy7g_Pt|L3?28ogaI~Y=C}bp!iOCE&}vG0#qR>%MQk&h;BVYrctH4Y zN8EykX^L5X^@l`%gpRR|dl8T^l$OO7)105Ch>dwA)CFH?1K!#99fjt%xXQ z^4h%xGvTieL1vjkkChkG#7O9|kohfnB3Ug#hGD4KrPUaAu%hez-Z0~QA&b2M@We+n zz(%@}6Dlb)7$UD(1#A!$;!)MzvI0MJ>Z1!f@$X3hJcpa`YR61P_id%0x#|&a)%{_X z9JKYts2t9UOy8lIsFOR9*Oh!uU#syM{VljF1dv$(QY{dK)sepct3AQ#KI@b>RBmYC zh`M@#76X0aM(ju%`!uEo>+b?u_@}s`vCL~>fjaPObl*dCHO>v7i_=4_DEhxnjGQ!Q zTGT6=#+~^Z2ld|u(ELw;^|iEsql~|c%~~n^4Wy+LIxDicS}@!6&?+Zs3LZU3yZ7OF#n<&S}_C#3l#m-+VN`ZLEgMfBP^) zFvzYgHc#8V5ofa-kbhYJ$#-{y?B46Vcy$-tyo)VrPhQTH}ihPNbsf;Y(TG( z8scxeKoH{iG3q-D`6xul1rLe(*I3WGdjFfmqI2+uy8Ys0cA$dSKne711oy~Z76G}-LnNdv%a(UDP)`YER{WNfzMfiO*F3&B*R{T zmjORB=z)6a|1`Bj-#S+y^pp<7utLG7grd%$F%6h|0tP;$`Q?|{S=+1Y*~G+q3Yd`b z$1{#JJkr5oCiNKfNyVnM-tN~^A0{rTlG1*+H7f7&<1c?x-aiLf4xJnKdRj?TaovQqQ~3ehPd9`?-sz~>1GZ4c{fW82HPuZPv>1K zbmCgo9AJ8$J!i%;uNj1;hWi50@;XgX@cObI)4E>yy|Z7TCK8w1`RgA6$5hEqd<43k zBc`6p>hVkIR@uz9&YK$F)XMb-)^f@f`Y8%lLLdXCvJy4}1!V##I`1D1Qpvg09(c!w z|KeI|o}<*%o0cnSVIb(MEA-A>N_{XdcVM|R;c?qaqC7t%Uu}s{uF5ucpRu??qLkF# zFl;NBo~kN!MB}x3|A9+txpJQcO0T(@WNw)&tXJl~uU=B=m*l&pqOGA0vCfubjTWh# znQtOq)YA~~BR0z!!Le&8IDI}~jbIGeP@0Rq1bZk}Rzo?&k#brrTbFm8ea=RgV( z1Q%Imt`5R*DK>?zL}*OX#>Tz6^{LLoLWf%o0HX^dkBG=L(A?~(1F6pMInLLDwG(b3 z`tMtIU3hr|Wi9Yu#6~rYaEf`w$hZRw5QfFzNf74}s6c&=M~UH!yTLG25iW%evF&wG z`fsY5Ks214Hh3~lpVfHU)1ZvATJaR0<-ZEx)2e;efgzP{2i&`+&I~=^`Wi8TEhyuQ zbWHIoJxw%p^YcfxDV%ha;fH{x}+u$^~9-uW93) zq-aOb#DU6@Rp%ZXQuu#*=(aMBxTQu1L+0VyZ9AFxdwzx}eM16*e8L z6Mwm7Sm?$)R?QZ&dCQZ!l?>d18pS{Ugsx0E5!sig$u#IjDmE=Q*bpdbbjN)-6am#! zG*E=dM*P%pvt-o~LmUCy9d!YZmAfhS-+xUW@eJlQ515Tg<%cUO;%}EAJeb7*==G2g zv`|*xTryWTAHSy-+<@Jzs&a|YX)j@W{2iSl98+IMZ%fy2Pn}x+O~|ExXcKMeyOc}~ z>SAd9(edq*+z(e8G_I@L&(2*3DJimnDj7AVoJOIFZ{wACXWP!M-a54u%|L9kA|Aax z6N}2kK|^9%lu1qZ6_EC>+d9@ddfg_>zlV`NwISzdQ#{g&I$OdxaQ~HGO3VX8%&FAD zV?cuTjO<^?$)Fr>t+{&&_)q*Dui0ZClsx?=IkR zmY#vxR<73V6~Rr*_H1}>9}ce$n2|;Of(jpoTw1evtFLdTrY|;)s*%abTz<{s@&0hC zHn;m))t8B%cY62gQo$xo*)nOl9EgY6?=RT*PW<4=sw1X#PY&!{h{<^?52M=~HP7|w zMdOYwY;y6|*OPHcWSZBk?n2{?sq(oT;c2&otaEJl##QL-yv6$u1OBU^nnQQm**}8> z7h?8%V7wDjoLbX`#yaC2r2Gn6y_t}UqFkBs4_vlnHb-qbyVu1vtKpv6c___Xx@*>< z*FNO;n(o$x=i?URn6!^y?c?H4vj8^EblJw5yF^BvKr5Gr-B|orn=8&IU#uvM@sH8m z)84H$&#hXw6Wvs^qzOm1=yvXP-qau8d&8I5m16E|X;&_cTjSpw-Ht`-?QwqW>5_La z(#NmGq{UTR-{ z>XiufYQ;4LkW5k?pCa`=!D-v>zDn%R+SN9h9Vz5b2q@LPz-go@()toB7j}0%Q zAK5-MW{L>7A8Z_EN|(@62u9bIkYt@aGQt(!h|ZmVBb_`3codDRb*EtIhS4Leiqfm{ zaZ8q9fE9m@7&E~&WR5Q`1G0WC+bFtglaHB3&`8fIFh_;kHT29>ecwr4AC*%wOdmgA zk4F?|SD$f>ihPdJ03DG-N5zQU-)TU!xGQ?($e3aR8q6ndDBlf! z-)M4XncQtS#y7IIZSgrUKExh|d>Eh;->X@ixe@sJ-1sn0clSIe=OsT$_{#`pmGCZe zyj^~G>=MEQ3K#XW{0|M|6A#ykptM78PKu%=rtD;mLPUjir?fp9o%e7!f{!jJ^^tK*gwCmEe4D-KwahgL6IvQGa!C#s=T)jMNiv%ZbX_>) zW5m{Ja?o&#K4CBR#(6YooOT`Iy=qTeH>!1OZ(nd)k5hZPb7BnYvZm0<*Z+=Dg_{e< zG;S^~yU7Sdn9YWgH=3|z7wT8xp?(a_2C_{xwyPdajCo=g&khb9`k z^ALZ@n9W;oN-uuH&MtSSimw$X*WoRgGh~~fkv@unO0q~wBgmlS~n!Xnj6A5A(saT8_HzwA=4q26}{$|DMqHb^@;FziKz=ueRRfo=s zExNtX*v4*B;bo7^((zDw)I*Vx9~#@NAklR$ujj~)7Me4`mGr+^#iNatkfi4#yIVRt zmdS@&0p-UW5h&nNq{#hzz4w&Vm@x25r3@PLPDpA)+n#rutsIp_hv ze-wNI6m{h*l?|cBpD2DEva_6GBdncM90^%bP(sNy*c~dtB?}RHcIlN*EVnmUE2sC} zMGlNbh53CI0hQBJ))|60ZQ z>6$a(!}@JgBIoXL=E3iMB<$I`HWY9tjoN6qC67^SzJz#Zy3QX z5jqDhg>le(42udeDWj=K$4O?Zco_{Lqm^f>UhPHQVuB;4k3#ltcb|r0Rw3$xX9%p~ zF!z|2W7y1Ew9Ex8GU6JE$uYB!9t_bhk*DjWjWzW*3FYbgJc|+9d*$jKYK_(^rW&?al1j;jLP?Nly~>fF*^UjsyX1D`L!Em`YpIcrw$ zyrI|V&#~z@saI>7N4eQIW};gh3$M2T-0Ygl>{#o)t9S*Yy=BDyi86Bd$0xyA#HjHY z@6nGKQz4t$I$X8hEE>5m6+(gztVA6&#XM=WVzc;Qn5R8UnTc06WEx{#6a+rGtYip4 z8e1Ko%hgd((}`|tBn?K@2XNV1!FBF(E+wMr&fmD51xS?Psb)OBEc29Y?46L`DXWej zy1j>=w4<)l0|SYQB~%cH>t0E4@(zzG5KSckgTW_ZrZl7G&Ym`%ha9}Sx1QTqzmE;%%)t}ob%!xodb#)sG1N=JCRX0$Z_QyxsKKLz!gzQAICZ4i|BIE zq0cuG&(%Qcq7OIaQZAlPanp8RaQr;y`n^85oL^j=nMggp+%Wjo80sVLF8?i0r?xN0 z!ij_xgry`tNpBkL;U6vRZ|!Y8QgvaY~EE_v)^3P`8c79dfnzXRK?jfF;&=56OT21nR)i}Ah!xq91v{#DYB~G z_~icT^BOyS!Ij_I{d$HWjla~DwnSFqp-ufbLele$=^QYcmE$(^=~A(;MAKuuaO(B6 z)pL~azn4~bNvrnyR_poW@y{Vw!wcgn?s}mdt`#5iF6|Rit8WQ-gmOPEDs(YDluZQb zh|XPVv@ov&3SfU>S{OWE)jnA1s!Y-FAoLWqAGrk!Md@$AgUt3%1`N2_SDNNolyq&8 zoUKH5B>H8{_76KYua|CbH~#YXu5_S)_Ktb3x5f?F^H?k>RwK(-+t-w=JjChFZy4ieZQke zB9xlmItOI$ofIdp_p3%bZniCS%oMigPgU1h0(-f{<;gd(v*+;e*@WrhQ|A6hGNpC% z<6td30)5{+F}LG|SGJeV57Xiro0hxhq5N{=t6aA{dSGuq0#*T6h?1mGP&!c-!@C5k zcRIh?Y(lTwN*P;5XxVSm6K`s{o(u^|K}y?xq7tyr4fs*Bo#&!DFZcTw?FU9?OroAt zt(^&F{7l>P>V6kbr&Y3nV(=?7M721=1g+s5lYG$t-D2y`sYx87iS)-34SH0<)1i*P zrAD5vRE1$oDzqYQneqz`=MDF~W{Dxbnuv{n-uSrrc{0HP>3M?M=q=aPxQ7&8$@l!%0x@Y&Z2M28@ue9BPQIWsN^=@*kuhT~W3YBF4B+mk($nS?hh(ItGiD!)b zLPeq$2`Ho^t>*MGAVy8?0;bDeyQxI!8`rR(`MqB|7bXoNGisN#T>L~#h%g(k`hpk> z;$;dTJ3&@!{ER;eG-_Jyp(0AkO8-K;roP*womW9Vr1}21@ci~X;Z;gk5jB)m3UwDa zQ^G&pkAF?~|3?gV!4Sb;8vMG(YVob3wfTTwT(zm1a`l@kRa(JfNhKRoLIa}(wc!md zxfP2kt)~0dqvmQOdVu!4LZJPVt8xH7t~p4Pyunk3Ivv$1U#(F|b`);VS!C|dkf8so zkw?|+<}oe2~csypxBTZM^sNTedalzxyQ9;EzcGZt z;Otd2))(ES;ZJOq^uofJy{$1!&jsf0lQq^X>)jF6mq7fZJvi%?S&MCT* zFiP8*#zJD2&Z~KLXLe|9B-nPCpvbo zyLw$~yf7+T_bm`Wxu4@*sq0e*@X><0)w2vwDX)qGxf}t%-NsFT+v^N))S#cWO1|Km zX;qBEcN#)62@|8jY0h@vY)6ck=;HPy_uYJ8ed59W1yh>9ZGmq=AnRIVjJ0Po5W^*u zuG~+~O*&Je9HI_-rxD7+?l5J^Dh6RK!)K?$&=?NfK#8SSU*b{DsC z%N3(inP33IA{*Oe(IUG{oGV6VpD`VWWXDqjs47^UACuLIB7YM-K6_euJ0Aq9 z*RNM(iZ`#@&Z{yAA?&Xcec|^t9Ye#prcA`l!kTkqjk3&}b-ow1zxQbZbK)84_tBDW z1{;H3@wfjb(pFKdU0Pg(q#Io?=Rfs~kpnJ@XD+Zr&P4C$wTIuaD{id&_e#9c*B z`IeP8c*i|g|Em_0blTOUw(zTq8LBU6N}GX*tq!%&O_yAEJz62Y_S6J^?+;btqrN;H z5N2ne(GXnppCg2zcvgO)7i*To*4h(pdqKq?z`RTu5C%;u@JTZf4a=fQ1FAq3#7WRb zu4>RwW)U1sGkD?-W$g^p_-0xt-qBTburzJ0v9@{bptf)!L9%CoMMATQ0w#ec=$eau zUJ0+b1pxIb5wjpXG}b zKI;5AnIl+T*aLN?pd2*Hb9JCV7(M9&L&JGn?e{~0Rl+$WiYV9-S^0GGM(A36zVnXNg0VY zW0psvi&%roNm8J+-nqqMQJ}z-3ds#j7_|5<` zgN1)d+GP6QBI=SGM4Y_h2ZU3R`*cZXAm+U38~f)bNGsLn27Q;3ZfQVWd2DKCMpI8P z$KgY;3c7wR80V>yE#J?#OWuFfDT;fd-H(#|MJ7`Lt1;|t`kDBVy5cF%mueeyK2U_dqTq6+8Vljf$#`U&}*$(^gt#GWrm7 z;;2^9P4RL0&)n4x#8#P@uXZmkR4-$tB`kwSET2k7BSv3Q3Omg;q1ILAZjv5=5A&3@}5q zh8FNepoAn5Uu2;2oHNPx?+};11^594{w5}~cT#4K9n#8FTswL{XF+CW^Wv!}SP<@k z>XZoSj?SX2_$0(J*coZ!)xsP-+{!y~8*L>Y#;BMl8jgpBJ|CocGunG|^4l&5iDa8F zYEsm&!tx0~Gax#bRNy9H1XSe7`);^G^P9%1?6NYxSS8GLG`!uIomS|b06?8 zCB=qc9}*F-n$qI(!p0djEx298)rYz5CyAxQVif0(L#XrCs(%@jq<(hD8gCgV(@hU= zxqrn=2_Q6&{!-1wAfEWg;$zg36aC$#)>;k)gLpI}{cDJdt~b_Jdxmt8B5VlQdLk#| z6-qZ$lKE{p@NYvJw-$ngfPW?M1a072vxEmFN?71gDBt5PuG7>s5~9tR?p%1GN}KEf zrqKjcdnHEut?V@48?A4XXt?m-2l`U+w?2dLTi=WKqy>~7_#m2&PeAQ;!0WZ;GD!aw zL^Xlx9&y1bWCh;z5Ouu3OSuYqTUXJ61NuN=Cwr`h5te2&Uy?<#g1^I|v6 zVM%5aU%5nJwkCaCSIYCF(JJ2_?&MSOnP}%WX#p9>>=@#(G4+kX7j4@Pkc(-IaftE+o8;yP#gi8f5Usb$+Xa0nJ5Y z;04xpeaYoFZ3-eCeOwp=hVQ$4ajBB7x}89JQ(CYc6A>#e=|8rPS-G520-CgBq(pUe z=8~~hp%=a{$YdRP973<*Q!Yj^nT1a>aWSiPn(X_dCGP_&8(50!-wHzHHo#$D0k;UF zE;E40d0#_&l)n*sN+L#XxUIDEgq&O(VjVp3=-+JqQq*h}#Mwkzx z4*z1%m;8eM3X4lFH5QFwQ}mGBmV!eEO!C}b4uQh2Si&FyQ&ek$4r*N110r?rY~wvMDmDZ-IjxJtN!48+1Wlham`aSufyFs=fs) zMlWkRDXNGQSOra11(w9p%@ZN^S9k6Le(&g#3sv8TihPe1h!PpAe7fYg5sFBx< z8w}VC(=#A7H0cdZ8y29JPGVE&5iRdfGp`AVp?TznEPY!o=>T`(6B| z`*3^6!2GI4yYwTsIEUy%N!|;XX-3FRlK)@WBQ3UX^|p9uW$d{P2>4j=4^mH&u8h_I zt_1lq@a1yogYjDX3v=aFc|ypO&*r?01J!j6(|8;3;nm;^hK2^&Z%vQBAEZmk1`+f` zbx{BKrdArWcW>W;1n|SqmKL(rQEEy?p9D2n-=m;|clF6T z@~-{a-sDO?{s{UsZ(Ef1bOHZ{exlFUTV=8b3>Eoa!i@OD^l@s0OEx-L(=|+D*M@-D z?0|f1>6g&MU_ZzCew;#Feu@|(6|zQD?3XbKI+D$Hl7@Om9sCtMrp3`T-|iqMdyEYA zvXzBuWBDYu1R`9`dboCL{F2w`a`aAuvW4c=rzY$qos zGZ)^<_5huCjZ~D%??3?hV7go5l{8`QcJq-otfD>%KI1ImgKg{=UlKJ;M%4-IbN6%p z#leQpEa>;nhYS|;0@ zbsvUGsN%ln(jKALNTV+&1RhLTl%1+FB|)E-VovB{RUd8O%29;8i7Fcg zoc#Xnlb2Z|#!EN5=9+wikne15$`@8yA1uF)(j8t|Zz=Z+6xF$7N=wit3KOL-_%;Sp z&HoT0-k{qCvU4eV7LofKG9?QOudP^|0n4`#7xlIF6N!N) z$KgvL)NKEYR+d6=z%BeNwVBrRtz`Fm*AP!Aj|E)(W84oq$4x-nkFuW2S``t3-M4iqI{a*!6R%8jJ**6;QzvW>t2+WZdeajF6u8=7ijkcT)BY`r|B zkDIswl9PQ(K`Yaan~_U*=5fm5VA?A^v;t{{a0zJ>hzy2<8}Pz{@+%N{P(llKu;_mq zmfHlah|AFC16#{V`qBlQO!kENA_>QxKHuI3Ha@sHv134bsoXKq0mt&pTC@TCrnPv& z5$~%WJ{J`E`9|(Ik*ctt1W5UIHEIYU%hm5@cw>j%%ib!Jwj62w<#r%~ik}pYuMeh7 zktz}UmiE8eyE3ce&x|{`e~I519J^xDdSrEM?QrCJ#As&e*5zz3D?AGP^*VOgHV5iN z7r|dg>{iImW?1Vq*|$h8&S86|eBQfvl<+>MdJ8|ZNK2r3UlbbOt(!e(kHcx}&D@|_>KrSdMSK-%kCf(}lx2suHIlaQyYtkB2sk~*2sfpC){*#)iG zq>#M-ESR{*SH|=xwWE!x`a!@+TVS-t_j%AWB~<1N_4L-mCP}4LoYmWwAu21(X=?*5mEFM@PDAE31l{A}qKM>@|jF z)Banuc`aQ^KV_^}g*y{k4if(7Yi$Ks6qvDHs&0w-3l?1oUh`ns2x&P&GyXg)Qo#A| zus+$A^|Bnlf<0+9eKHI7xp5e}NzmJ3(NDU`%5d6^tpK|oCw(-F?eMcKk)T$QI6Cw^ zG!u7<6*oY&@Wc)3B%XZ4R4JvPP|arOKB1k=wu@wXqKfit7qLbtApJ-JmquUnhX8sF zm~Mj*LWU$-2KA+C;qL{5y*9Dr`=YV)v7kj)JpbT^E>xFx>IBi=?*sA6%5k(Ab?$VI zY@+mf1Jy}WQ5o%V+juq2?u(1z(@P9BX_1$@g$t_X5UD6@(r&9s8q!~RF4MnfR_>_j z>{#D6la>(P>6Q@K0Z_Vem=2HxW>yUoER_LQz-;&Ulfl%A?I<&(am7i<0^Vf;rR?UoB3G`Z2ZLR>fR@E?m z3hXbE&P_TRYFNHN>xggpnGE+v=U>ru zLe^ThY(sgiG5|DBRr zh$E+mz_6O&tajwo$=cIXTyO5MT5(sZ4jZkByFZh7C-6m^pE@?>oD|FV?KH@FVM86S)}yXG3u!BhJvpZjFE@ZOhO8p0$$Q?jW`gk!FtTutFs5<& ze9N3!qf74gBEV$9cV?9A*G9mm?IZd!a7LD<^CaPI$czoO zJid2q4kVyoP~LwG{?Cv35v7uDzk(ggQWRlzE@q{R@tyD(djC+sa>-ykojWH6TOZxM zoU5!N9(8s+?+;vdn!ZPMw(FknBZ?J`cG}rHde~%Vl6BipF8`7!8qS0}=bWpg7*6xn zJH?ILb!bW#9d3)iVKmP9uqKegI=18;rs-U^Y0PX`7uP(uUV+VbUOEq|tT}HWDPFw= z>FBJRy4NY>#~*q~^MZUfCQR;(AENMnzq-UE3{%1lAWveB8rj$HJHKY>(46!Rc{A8$xofQteWjSoliX`SSPkwcdcKyYd4Z%4%>64 z6PVrb>-bOiI!z;fOYAk4ch_x?ubYmFcT9}j&0DLet}p9)i*J9Tn*6l%{`|Yfp&@Yk zf>cK_tY-Zim_eU>s|PF$TjfJCnN5|L6cx>PeWJyE{%=y`3xP?OuJbTs)Y)T?ncR7% z6+EzwZ{%#>sU zUj=^sF%x|A^tsZxeDSosGqUid(5AZB@CPkLeZM#x6tf_s)sUUDM;lHItl0J0^OaH4 zSdjxp zE4$o-A*M!>MxUo(R*!3xdSpQ{!H*1Q0OMr^X(oW-vh(Iz|3aGyn4##V=eWQkY(!k0 z0JGwKJS>hfs18W4HM9C->2FuiV%)#wc?X(WaNFX@-?3wqVm9aTWJ@2YH*xb=Q@K6@ zdOL56Pe%wda$-|B)JNMcS57=wauG-;=rbY?m_$_Yvyl(KB@StpX9#+68OcX;#Ru>^ z<*IQ#-yXiEw=pa4P^u#(JbSV&#@;Zi^(?zS50a(Ig!HWkNJVRQZeC7@Mh*@$IdodAKEvJ7b5iR(k&WWY-_Yo$>_Ze?Hdjc_Wl4( zsbr*CB>qogL_GMUg(|k*I9fC!Q`pF>ofcPnPwHZ$vNJ*6=y(=eb%d*}h4#~FbkmdR zOH9QkTUUyQFC9`m?1~wcOe*2m^ag&CzkXdSU-A|fdFq5aG)XA-&-)Tz*-g)&GH zA#OelVt2pPx^!w(&|lMc-4QoaeBa~$#zm0Sn=yMWi#$F3Y2WBw^S>=*-dfY{a;jf- zYVZ7aPM)Q*a(rg>G_ooH@p4VX?idJ14J{_Kyc|f5wTICTmVgSo4LqlZ74L z7>jUQT$J8ttU`tVSZm<>fLqqB=RZNeiL0|>_43Pf`lE6;3MkjCA9-p*M5vZqF{K`Ik@aU8oy?r^aasgDP8=u9tyc8Zb9Zb?@1>$Oh}@kFU3l_5{I#z+s$`l_@2Gi%G8tD<1$yu!`L_vNRMB9 zjFwYr`^DB(EY6~Luc6Pm_KD@c!Ods8o9)95PkvIA<|VuLd!HluUj*C=6p}N$1){r0 zWShuGr#cxEOuGrEB$o@XcO0T@11 z?(s#A0~%X|GJJ(TSKnp_pD`K&C9jMM*Szk*g28wYmo~i)*<8ZP>~~#SzniCy&btCN zbw1xhP41WRvy&!mW^8yL;AK$T0A6w{+-{*vMn}*5AESWcMPtxEUj{gI7es4^4E&BFiU` zUECl#wtRHGCJf()eRWK52Eb45J{??#$Xj3WmQbBtQ%gD)-+kM+7^k=Cuf7FQK@)S) z6}4K|&VVkMWYtUGb?iTQ*7snQOwy6U!?yELT9E~=c-YT%Jy$0qlw$$xeWf!?%+XjGq2vGkn8T$9|7~c@2K*b$XBP$myEQ9eK zDBNMQ=gM4j7V$z4x4^AUK4<922MpE}8R(c?S)5jW4~t9#YL(@3@!B`MBR;+{N~QT- z!Is)dsqHxUcTU6v$WqvN4SjxWnUoP7VBV*mUhRZ`xC1F`4qf?SQuTHlFre%0x z7vX(=IATPZWO6+cGS2GZ^|Mc6C*PGnGEE}ilN$R^dM2d(c5#pBTIJfFe^~q#sa_Tk z!Xo~IqwqciYdN@nrqx_VERPaZ6C4 zGn-4UAy_IMjP)$aHJk7@v0{X^ppxAT<4MYN@(>kUh;MeB_j;vfwB!%>KqT@{sbO1{ zNa-bggFbsz2|8s|gSB=dCHsGNeqnR^2cwX4Bl>0QKYiOjYJC5063}KPEMnWE7U$AJXSxj)_c69Fw-n+Q5wymRI7^lSK9;mfO(vLr1SlVh; zq_@y2xrU6wc1Zwk%sM0a7_7aV`+gJ1sx!zPg|-y;q6x?$M3WO=qMulP;?ht3S>t|J z6QK23Fdn8k>$tTzWd8{+mE#uLgM=@6%2KcYR+ROte-QvK;Z3Om7vV2&d)T4&2X7a;*5x{p8B@=T3zY zgp@~?L8IG*?_t9uq|2pjw%_NzL@)6@wR2cqNaZ|#)OrMLwEuKQ$n3S=lZb{AHnqa@ z=+^S~$LEIkknOwU)lZlHJif7ao=dALmOL=~6~e1P=4O*`PgFdTu2k3j*J1#T%!LJpmJEQncLbgWyW%l zu>2%vFv+AMA1RCOmWe|8sHX=vANhI-uGowtiQ%HfjgXHt1KE3Manb2!8C1^#Es90a}b%O^HBDJNjr_RJzx^nj9?MXo{sxR-i)Cs z4hG^8`aKdr4EoamP6>xf#UnAd5F^V}Sp(^~!W5K4d4x{*xXT{#QU9|D6B+v8*6-Xe zUN)SySNr-Gx59@`t$oJ>#oT_RiB!qEOnFK4q92!w&T)U7!%tm*D+N<^WI3`J>Xn~SWS_;loaS+{=1>gOc)E`sgB zthKluB9BVz4}65|f?mnc;k3FeTh%oW1irH1sE`rkLJk@p7>PS-SeaIDYlWp_p7ff; zbS5~0C*Q%iS0@ghZ8!XmacwG*q!jqijQy0{%2b}-v%}&T^$wNOfJ3S%0$agw{V%`8 zjbgK6WGpi-hY?8mhR?!R!pqhz9}ZC;k15QAvs-@W-z&MEi#JVJl9YL3U&d8i7%#0^ zo%xF7(T>v~C~Xy=JI`Z{hBd|7ELt7+ZjIdyBCWEtU#wpZVTVbXEAZD-oE2-Ra?h{Ex-!uiO3mLUb(c$}H9cYZ1Xp8+{prPw*t7)Khp^vO6EP4OQOP0;_0j)0 ze7CXfNwUpJ{V*XTLCPFR}6#A>;^{}NZsrJh?BHy&IP1JF0FR`9RIM$&p zHMjrdw(Zs8og|J?OHdgJy7*>BBW~k56nADngJT*PB_Q z{+SSlm%l9E5q-MT^d}1eJ~MEAx4Y|@RT%te<#|7U66!Kp3g2%sJ%qXOy4Xv2d!QVI zPL;u`He+a#JE|q#I_WCWlGu9|8j^|?t+Bw}I*s2QMU-klKC7D4>Bq-kSvqxfB4osp z2g?2iujeha{wFQbBzZXir&#Y_6LC{HldO^odYSlgg6AJsE(R6ITZ}IweknuCrEUVw zMafR&tiZGb`E6~@B|x!cP9oXJ^AXpYo5AEK!iXU%(#N0|1#I78x1o929RvAAJF=U& zr&JL*`91{dC^^`>ou-gchqJi1KCyZ$NHQS4DaUqoskAq8!R6xpseg<12RaziN-w*_ zi0jfX!&|KwvN$E|Y`2qmgRuKq8*Co!rZNuN7@A_YD_<46y|vb}PlFOIXF^IMZRW-F z)jxTPCv z)iZ9nWRp4}BX@as#dkwtZu45|5oWh=P#8`CrsQ$Q0wBczHANaPOMuBE9JhvSnF?w{ zQRd6y@b=M_NR?3_m$b;Dhk~!H#1l`Fx=f4BOb|WokuO6Yc*|tr#PhmAzBQ zA~J()Q@woo8SZ8@iexc#>XTEl|M(MK{3qx69T>YNm1!2N{A|qzeuNEHtZhE{MYnGj&xYp+!xiwYzw+MyVsNcWkK>8O zH~`Bcv-H+PtPvpw~ld0sKHB=@J0c^ zN919C%+koq=&i8pRs!5gU+yZiglH_oBuWd9{2Lya?w9B>-qH5%&2(fbjF=z4NCYEB zU@yt!Q_B2-IDlYmt$KQwY!2QfH5y#nyK=rtg$O}IDBqy^ZOy9Qv^)_dB_(gPN;2su z6?TVcXUvSOa)pLSate1n_uq|BKldE)d|0d8lAQV^|8V>5<$5t?0wx&$6%#H0u{cvH$HD#9saQ&>44czv+39b* z5D+`~l&dCWX|6Y{d_SW9i{6LFpq`RNy0IjcgVOd+jsDgZBI#c0SxxdH*=bIAY(LR; z$1OiTE}}SEJuL_6qdV?5HB73Nbq5>9Y}CY(=Frpe$Cc|psS*3TgT)}JZsNVM}_E`aD}NV7gEiz1f}|?T`d86`f;t+6_~nOm}zd} z?0*NntZOZMK#aFfGE4~JFM(3>uD(m4z5YXr0MRi(5`OeNj5B^SpJH%OjO!b( zUWeB;{V&cW?cY?6w4IFQN68o}#PlkJ=4#k2g%J^X1hCUkja3>i^84q^kI-}JYUP&l z!RcPG@T?~Zep4D0U`mRsTv9YVX_^x^Ob&s$Pdami9fOL(kE!+4w>I*kbNDr~U$xly zBRt$G6bkaYx-nNgiJeFZ;giJZOqE_l4VU-i9)zsAy9Mex$o@XuqoJQWQ}TY- zN{txtDpftaQ|4zRCmb|xE8(*eqPQ_J_gYp$rEmjVt$JC`R}yq>Vvc>qJ*ymKv_^#G z+x{Rk9n$_umyBKhow~d+-T8x8b7v&jYu~{TteYw=GL?u?B+cOukHP=xI{#1C`Two! ztl~1`7e7`iQwDN7GiOnM&i_Gd9xqgnae_{9aZ=n~>8uyQMYmuNR<^DyT}dH=v{GdK zy+y--N-d8{Csvv8yV~4P?Wi*R$ycJ;a+<$;Sou_hk$Ft#t09~_`m3{9Jp&@ERW^EV zY-oTh*V<#rru6RN5??Daq~yq3W*OPzi3+*h1Ew*=>t`>Vm=-8F7TgkP99nn!(-K$J zuOkoxGX+(44rHA}i48tr9yy-woSVlM-x}Mc{M8uG z(YmE2CpZp%>*omnzy!RUg{u8>KZbU&5KcJuRy`M3rjaPjA}%*r|ISY9S$~JhiH!6^ zSAvnD)kv!!!={dY+glhjtYxndul?7Hh1CJKCOC_?blfn}E{aUdJ|_~f0{q4)-=7pW&TOmB z9_3Ix?X+(kd`V)#E9pAXVa`d#sRWdq%IbHnqF|EbBd@H=j*N6ecos@{v185@#{cX% zWysL6VMWsnhFuNP50QrSuq!*~@8UMtJ7AzpAn)&biY>SHArnO11KEoRF{DRR&mZ!d z54x*qZhm;2e#?H_ti&~6uU>GsOXc0d!yB_)0*y9dHEYJg$&3zs`Q5$B^?%XMlF9}t zUp+3V?T45^<{>El9HmX*BE0%g#VlWz=;>kA)KMZ!_u-cA=l0%txgh) z!=w>}biIk+3fiT9>2Tj>l#L*8NQkEop>f+t09h@DT323L=XI)6kIdO zWGCP;#!|qBe?3;#$;oXd{?xynxopNwv^{q3)5b)uskMgt7J&d)pcWmH?B>mfsSPZy-Q<$bOn2?zh;I$wfxx2KOZrI1C?2Y8tVBoZFyoVy+WsaD&{t zE}WahicUIefj-aPv}>oUUbkPN-%sgEEqM8|btXKOgOdj*V!6Uo%Pf>TpOQzmtxg80 z@yj_l1-;U3duz6AqF%TJz4B~(>pJkwt!bKHNC$B_T^6T%IBdTQwN#fIHnOo=T)-Qi z!~M^7GN4hWi-TKAr?863eRQDK6Uz9wqb2r)d7z|)tl;CAnoy;n4$Cl;4zQ)3sBeIB za-h)HPB*E~UNqD&ZO3FNI@RitgQRaTq_2d9pg|jO+Dtgg5b_|`5_f=|L5{-`fw%Zh{L)8WyM`G{cBuDYhI5vB`}&wO5l0aZJ?dA8 zrOrk%kkpGg!WR(7(h^ljqteW@LGP=f(!kinuh5U<{#GT!JE`Kr^LL;!K<`crgvOwl zc|rY4GeQG{RK7x`FKkZn+lH(|tU-7u1jb6h(cX-Q=!UDLT)@3PQ25IU$d39;6td~d z(x(rZXX3j-X3#_>b3;So;rk?V?{0Ta`oe3T5PJk?z9?`1b$dt_$%8LLM{L6v^Q5_nD9H7{VzZWips1Y9y*zC#))}Vtgdge9>za89Z@IMNeSg zXZlJ#S%*?1Y9m8_>U30f)p@QZ{A(u)w&)IL+jHDec$JPgCjVC;)nrNULf<1V5VkLw zq0$8QlhO;y4a7mwR-1AKYM0ifaCdmq-J%k;VPgTw)10NeDCu%@~9FSVKUQuyufT98FdUkHlU^1pQ8AmAON zmSntZj$l@eJ+T<{2HlTURxYkOhZ*?9>u6Y|sY`So@{EFZItO${jYE`gvkp@YyCFM* z4lv|Ouj+>L>}AsrgC=HX01-;O)Ry{`d>^doh*<(7%glF_>IrW!c6t=ZCNY4FN`ic! zS#g`u7iMqT(gJa%o5sW)fYmPN)pk&-$i4qi4=Evly-{CsF)d3Wc4snG6}d;EX<}8n z;J4b`+W5ZvjtX!H8yNh@MTOQLKE`UGDP-V|vRrCzT|i^5nS6lQ?cfc$_k$xCEvgyP z>u`X{t3(HSKuEAQJ~Rg<$*Q}>95o;2fQ$!hEro0cyYO&}0M9TDG0aZ%4BqrCT#4OO zGw@D6j6UD)&y0Kh9;t{|3reMB3l_w6njja&2+MRT(Nr_+>oK`h?t_Y>56VbD?1#jy zIjQq)5!|b4LQeWc#xVpD$T#Y2=cr_2Dkj! z={$GX{EvreG+acsh&b3uIl4Zcp)-c(p@52X5<8;0x5hk@e-(HC)Dx!~HUg z+W8D^@RY^?4ChltC3fkCu`x}tX(RIAprd3P0h`f#+`n(w{hEX|Kd3_|#JwREdEg@7 zF&4ubc&EMOzg(0;A%(4iM(B(-P#qzd^Aso=^!kv~^KvhNOLzb9ut~h)4}QCc-%J*H z+)>*`T=3KxfX9D%hQZMY;@+2rvdx5|!JV}2wBmjs;kxoW>3u!H*FItuDN#$(Kpi!6 zM1%Si#+akU)e&g;3q=0&YFimJModMIqyw-xR=M|a5v0HKUs?*>S8ID< zIJ66=(PuT3LFg8R==xtMGF)T7pCe=(OTUxqh#%Q$*c39iBpMR|52(hn>FybME+ z8bJU@pH#$`u)TPJ+x_JwikKH2c^wP!rl7a85(bQMzu>1~6kINlq5t(>4Wf>V@T7{c zVO~b|SEO2Vjy^hDH@832Y=X#9x2^KFNM19}QTPEn*8;H-OJf8cb{ld`Ag>?JJ-Gmg zC4JMPq7NB`&0j;(*dH7v(F6Bm5c&B`?k}-cW&bZV4s_TBme{w_BOPWSMQ&6lHh(q* zo%QsRo05sh9y8+y?8CN%tB513a%WEaF%vz0n@s?Vo3;n}!7$r0qtHxAH$MygTavMbH3ZbhY`as8jkY6zq z#|nCoQ_&*~kHf6G$`%nSn)5;*ON)Fd+iGz%lI4gmhH040b*2XMAMuH=ABJ>LB;5e=pOhFTIza&7C+SJGu#=7%rqy1S5EE%s8WoZ+ zw=T)}@26iUoApMx0yn-9hEbVOH1mFa{+cU`kcZL227{=}m`R1P^|m)ObX{YH@Q^e0j4c9UbA}i8;j>G%jNtu|7UghO0d|yTMo(EKrT!0&RJft%s zUk9Sag?Lf}ui>;$K+8-3!m{6#S3}bSc`j!W|1b74Md-%>R;~z~u>A5KxUFhH_0d~? zEehMshA-1%%_Uygwt%5h#>n1kqRq5d2vN1bJ?WE_g!V51^>5J*LI(X-nP8DqaR00k zmzB$HA-}fZ%aC^g807D}uiFlj@|sN4vS|j8*9ixzu?&)`l-Bs;e32tC3b=82LXF|(Ad{dq)UtLcQPn#}sBzchM+P5a0RZMcryjCkYA5yd! zWASFc2bkp{7ac$7p`3`Nb$LPzP(rRfEW)(^B5?0n$WU$SO+)Mr31^m8 zt8%YL;LewNjl@;J0W0^})@e8B?7Xf&MD9bM^bla)&hMyq=V=VvEG9#3OP~90l6wxv zy9-mROXQ*duO4E1iG00-m~+cAs~h5gKEQx-+N+?X3Tlrw$Q9*BPUw;O>)|2$!^7e5 zuqy^+@1C0@o>WZywgEP+Xv99H#@Xh^s{W$}0{$-#QP<WUPI0SjY3}CBW!9U`nH_jZ zrF@sK?hdLsY1_a#7(@Kyu};KCUw5dt9U>EZ!|j)mqU*@nCicp(YU`)+)N2?GzOCew zu}6%zQ+SMMWq0+R#trBDF^1&yZuc=#$+P}xb*`zKP{c1sDB0|dBI^7%oVyY;zpsBl z({S<9X<$vuy;1>SxcY}6?>OxEUP9q)5xoy>C;l9P8xXYEeqg!KqJT?Bz!++(2Asn2Fh3eCVM=#$!R2f2A*T~bY`_V@Wmo$vjx>~XNpO6QmE zi1>mcRJ0ut?8?~g3-eW=p-SCy|ANYP{JU;)%Z@tf3t-9F{N&rI6X470)G-QY3AdAe zs1#+WKz-L>1#_>48A}+dQOp#aK}Xr<<%YNmuiu;qBz^teSu(Ez!8wGM8C3?mlA=S0 zg(PS;o`xhdFdJyCf~cLZzusUlhH=Lp2UWe=h#H1YSzPxvKWoj>cY1}xtb?vwqUFY{ z5&S(9rZ@LH=p;?02fkgQK4?BfU7{)q>A|H-MGg@@*h1htRv)@>|2Si%mA0DSg52iK zR3&5!u_h`^-@#d)`8n*F0FCguN)YiVP9wQA0AdCy zu_~_Fxo7NilqOdMilfS30!gpQNa{tusI6SVIER&H-q&H_3P<0dOs5sebwzu;+E-1L zl*k%_`F<>tbb+A~E~4Si4c9nYs<;T+|ZW zx-O1}V#)(3*hk-7>pS=Khh@6=0 zlt7LgX_kHse1Db!Ixh^uz8edhELMzbvC`wW9r7t+7QN&{9v2;(aZL5avhbcnp%1Eq z6F1ii1Th?pe0orf5EP|!XJgFwz1rUhLb0Rj^6w;AS zv!T1KtEuA!T)mig_EtxSYQ8#l@%{slxhQ&b=-2ArN3UDvw2qVW2SGz8#nBRRMrA-l zc3nTi)2tywhr`i{@9=7pEa$jM2LuziWng7Sh1^XiT;>O zYp~>f@fXzf&tlfz+1r~WubSE2l)~5Vyjx`S2|Mo8A09WPcMVnUGDx69QDj`nIbgTpd0=X=~J#L5gxjt?jZ^Wgo z)R25ig8XzMAy@jk?MHqj^y+`BFyiu~#MjR^MPD1SYzR4a04b?hfa9u3mq(V23vNlu zE9`7S+4TQ0SZ|igOwfKIfYh~kt#_tfv*_Uy|L00^!%%gYT}S0xOtLMy*@MkW;?C)< zD-Ri7;`Z|O)`)F}@&N-;C6ZV97b=l{#4WIiuBZ5>qH7cBo+w@N!=6Bn^7UTgK)-yw za4wF2NrPomHt6Uj?BIBPl=^f=K?m!I`^Q9mbp6eo8AVh$heK;~2xN2Ar^eSeG8lj{ zzOP9}lyjqm1N?PD6Wna%?TkC%q#gFnR#)#smNDt4Y9I$cIhdi*jf{Vy%###7IaU`d zFRtYB6u3^-q9rUCmuKLq0WrZ*QW+Xq{YkCwUfJ+DD_-{Pg_J@rm#LT%mE}1_X|EQJ z_to++fk{RGr^Kk_{O=^jSxP4;MKaaKT0_5*q5ZrW_eW(;=g+!~b=()gMM_v>Qs&YR zCm&?4pN<%QF_He8VEr)9|4*=<$??B2STC4v%+hgi5*G*8%bBhm#o3gN7TxQZ5jB&+ zNi_XWiP67DoIP*Pdzn4QVn8HA#v=1&>0AXUGWKhtHEp zE0&>DxZ`#zg-6&>x@LQn_KXLWlQ)Ynx<6!tkjsQ?fLJD=Oh6-YQb%(A$Fg+>4(O}k z@64ZL6v8z~vzFbSxqgBt#3!s7nI9&&;!@1lLz4NK@yCio)TMeRLMQgfY^nqz6Os={ zMwl;DqX>-#>zVunDvilVQw0T>$@ZVY6w?>R&)&Vd%BME*Cp^AJaJPyNGOW>KB_j}X zlC7QeGjA*xw2AcJfi51;3*+{SXSZ-I>8`p~EqQvFMJiJ_&t(@KD%o|VE}XAFtHKfj z)Q1Di;h>Hwii`Jw?+NCfhVEnP_Ib6^Cv3=-7wBOwP3WZg+B@aQ#|VlJu!7NB(--#T z-TCx%t~3!4pg%d0d+1k1 zQ7T;>%byQhI&){C4<9!i1iJKenN(#mir>dtT&vUZ9;^Vv2TD%%C0wLfCY*HFa{rw3 zt4u;44f!5l8CQ5lircnMxib>r33>>ZErRdOX?B@BvxPnM)`t$h8T>TqDBWy3M|q2d z8yHL3bBge@$bSV;CO1^&5_me60B#F%e``) z0Z)8vtTsYJvigV8_&#mCRJ)x_pK^W}cJ(;FlYIH?0q_@Amyo4w?Y*a z-+-b8r!e0Lz8KF0%=7?zP2kK!5P1E|t@<@`SChH{nX<`t!2fm{%cTEnr;(@7CeilF zUV-UH!hA}#K4;2AjJ+#}T1N6OgZVtgKdHt^^_xz%7kT=AP9HHw-#_&2X;W<=k&C&b zmpY&UJ)~K-G@bMF4xZaRJMiCgjWq{WWvc53JAviN9I^)C!frHXcfLxj*SOw^biN84 zA`h3=H23zf=Sgrs?p-;G(cF*F5DE&ljdWOz&p2Kl9+D zPTX-cO?^l=l+RgD6eMI>_}KA$A(^!NFPyPw*uQ16i`gSMfjIuZz!}dxj;fsTtpYAo zy@yU>KdEXkZnj=H4?4Jzl!*cGDQ0lXuYcdK{SDY-YhYOa@C@ycK59PsYu|$z)ol;y z@E$^P#v^!kuY*atb=VU;DvH$;F%#UoeGpB1l!ofJhvzJxRyt#VFAgdBs@K{|bN%|W z$ls@KO?x>&ZJ8$T3^B?p5G}S>3t{GCi>8cfYRE($?luL$<(T+@mVm8PXYYse24^9` z4P4R;zS}3D*EFqz#`lHsN7>`s=$PqLEJD@p57kB)8ah?v{%)S0u<>L$J-LEL^hW93 zW8BSW&aXpx6&4pc-g(UL0z$~SrQFa#NukJGgQcJ^(^Avv_bxZg_c0c6)}jCl)uv;aNdR_>z~bJo;KM*}N>;ta)Abvc;2R+Xl0jKtQok zz?b%MnU<8)mQ5V39RC8;C-sW@S2O1T54}!h{&r^FS>eO5a>jEld6bjQ9gdM54-|Q& zoE=9NaI(hmgE>(W1`EWWWxl7W-Zzue_W8C5E#LoPKW_)^@9~!g-)5W#k8T!dxXfTt z6G3fV&kj}!sH5?EXgH#iw`kG>8h5^EWuMfe;^GLDC=`WD0ExnVt?0)y&6Y+YxG^bI z;|QQ-a=SPck&m^y`_gv*xti-Clu-JEW32jm!0uqnBi5+oU`JA)P(;K=+H%_!90SdR zYS-%LKPy~u)A`!UKa-{qKd5d;Axa@|mdIug!(c@bm5QsS5iIBTvZCi@4HrsW_-I8N zeyig<4tO~C0Hya6@EDc1oWex?mg4-j9E4&jMV%pq<&Icw06PMaZqRqw!G5o-uF8RX z&U(GWI0MAm<#>HM^L+6;`mCLg%xqBzTaTAl)-fE)lB1EZ{ZU#+;piF@!*O2o|FG5?JhG%)veQHc-L&$AR zot)hO*s2%}8JkE$tmQInQ18YnI%_{JwayjQL$i%CN>lNa`Hh3!Hm~-6pwvD4@0s#Dx>&!wO& z@~`Yi>4{~rdq(M7_Jh6%92|FE-SGS*Oe%MF6Raq@AbS=4Z*a!uNv3FMGh~uS)U&tI ziX6^i9#s!Sj*>^CUy zSI%LQ54}RAcaRK?pY#zW24$(Uz%m%S_U7{wk{-QQjS1`-Kgjf+Lwe@&OUz5ktpLsR zsPq@_3=f%W%oT=CA20Os!qy(Q_oVM_PE6Eg)_AWQt8hMc4<+vBIHSXqR&7UnZQxpv znSP>*_vBJ|;F(RZ2>CS0Yv*?B)n0Cf%@kEEsV6rZIDnNZPb=`%aOZi1Bj+ell>e*B zq@E&}chdc-ACK-!mFjxIBYM_3c$(scUTc_BQugy_lkcE$wbw}`NbT!a!MkA8zyCut zBSG`Or5O>>%&U;lfg&k_(RdT6WzBAoKa=kU=SmqFvQx{Zd_tm(B~nuOR1iBa8TQ)= zl~7C*5*ohf0gsN?@-Wv~6Z^`xs>ba923k=y0Mj%0;Tc7gHa3|Ply>-i;%8Tic#a;G z$h;=yXA0t|f3kW%=NWy9^Zj%K2{UE!J_|AI*lEg9>E3bsW6XMlNP%Dx?;Oell^h3y_SZGbzCise z+V}|9)17aQj&-Vlk^5cj3)TPW*#88zO~(~*H*4LA56kqWo_BEv%(d{d7_oq>kZ=IW`+!T*#U=&8a zX086ntzIf6O5;r(S0n|XS^ugKL)$5Tmbi~hDr;2@dWqR#i0cT2KkBk41>Jx;j{h?J z#Q*M*?`xnN)6S7@i6@AqkyqTTcq8X2V<|STrQ7}M39tz$2DLk}r#6W#%kBa-{wP_& z{Q?X$v5kZ5gTFQ$xn=agvEMg=&V;%2G5b0 zrZG^(9ja-I^1KqF#9jKR5>$ZtLgH_DPT`;MA&t_cdRFnp0`qI>hwaVg_KE8`6mh77BfF6p5S$$Cgh+lxf9e>JM4@CIKWEf08_CW@^LTK5c?wH;(ME0mkZW5b>!DKO+oq7vxD-}7s z_!8!l+7KsO@lDH8oSUF!2#LHyaMSwE`eWkrZy_38rW=Jmy4u^OxA&s3WWwtM3As4uB=X&S*3RU(x))^)bEd3{#w$tsD-EuGbEjDI(u17j5o&` zd{`a%ws)y%rA&F1;wpV;<&U_>>~wB-xT9f&^VT=`A`Mj3Eov8$-=P{q+OEN|g0mJ) zUoo*h_`bk=Y$|s8`yPLKLyhDoH>ag=svN! z42(37Q|uOa7#Ud|zm$vZwWhh)VE!oKCdj@Zo-$JGg<#uUONKE7=)3(EMF9JR7jH3_ zD!cto=}ZkbM|@F;CMC#&4mrie3YbMHcj#sfLt`Urz;h(WZ8M*DL6CBbJc=4&hQIT zc}?!cU(wrZ#A5KL*b-*!@T+YH zV1geTaW?8AQP{t&>9`|7M+@sv9iMP<$*IOU80jf_O^q)~;&k9}uqf;#+)#GzSmwwg zhYIt33jfjHYRJtk1NTO9-BQ^svf}!Lu5eB3c+wES%-^VsqHH@P?rIpnqMUw#G2s~W z5`(@d*dT9(_2I9-MlapyhE z$WSgru74hbjaPqgSOp%u4vk)ZVU@sFi(aHg&Jj9-naM#sH3;m=Ih3aJ|G<-{c4cjeO zWnG!i`I|Cb#FR6`%t$g^1cg3Fdxh@_9ia|c_izXxZ%8{cQrCCJ>8F(?kbZQaStlDo z3Ean!y{;+r6uZ?~<~M>H1H`OIH|<33=A_rsz6HEyzRY=4*))VIvPTHpN=ofCObI_T zO!65t$W_zNshHWkqE@^XI=>^Pw(UKr9AqVK30s*;6lhA_fScyR?n2s+4)GP&dK(wZ zXy>cKuc1-tZ~3(bzM$^qOP{LEKoh$HeP9aQg0r=>>&T2r74F1MQl=Vkd-z4aI}{z% z^28y@9?_fRNkDOd(UhUk<@~le=D!Fu>GX{UMV~Mv7U6Lh1Ak|1=1S9SuvtUHOgDNw zjhDKNKOKbeWA?lNzNb<8f8dtItnXG!qPNT83!Ne1P9yowlSnGg>w9%*$Y%D+$Uxt7 z1!Jwp4YJ~$h?UwIoXW;1dq#`$lC?{0p74$iRp_5!EO>KdAyy< z7O_0X&{Rc)GKu*tOoN>m>?EMSsjf()rOi~Cf&;8UJ2O8$oD1YaO4OaO=_w z+B|65u~tZ>A26#a~oBB=CdWLBnm%LXH-#~C> zB?$!bW1`HHsOg5s#~}L9`N{|rwV>vVFw6&?&ck2t>j-!>Rh*S2z}>w5nNq&4v}fTS zX$ji3lY|oLuF%8$@ezB)u~n>-Cd3B9Z{}HcQq&s}6&5s4(1>p%_UiCi)^L0K!xATi z?ZP)Vl`hIKGu8)SEujSs!S`R*;;v75ij(*=Xuj4LbpQF=q`758Y&7s1b-c_wktJ^S zrN;wduUqzwQXB-W2(}Qc9KLv~@3_sL`3f}sIL91(97xeLzoKPUy0 zj@EIcx@BB83S0@|SehKyag%&x21Huv;nzrD&zfr5shr}|Zrfry`-hf4A{(j^Gd5va zM$~lygIH|5$g#nrGV6dT)^~fc@9chCR^y!Er*f$4M&;(vtU0uApsX#1o>s}Y3DPtp zkAMEUgD#5lI3p~#M{6q&fSkVLc1Vh=6Mr7CWOc<>oNA*gd2~~|$IBp>N`Ui2ZN7Re zd@R{)pv3l$->nn#jV&{H>vxs8k2tr^T$HrtJD!zvX44!OA};+(+!2yH8Ie04l=&TD0*L6+%!EA%+7neL2<8cd)T1Wif9*@ zSRL^jE*}f7xsJk}Z>k#hPeUR<0}Z|PpgzfC$do727o~gwEcI6+Z~HtDSH@;%cL@go z-%awCbS^J+Wt---h+`C)O~M2$*71=vD?Yb7{LVaxYfl=8b~9h-Bb9ica#zaToUgye zKinQ#@Ps;G@FA~8Crt7vdgbLi&sS(h2dnYM`RP z>?Z$d2}hN2yn`nj2=qgp!H}S?Xk|$f{n&S?o-q@E5oJ^M!}L&zmgJx|-y1uV9y@MN z2Fgxx`J??N{DCrY)tPr2c3Ov5v8{2_MG90I=ch&hL0Kw^I+d4jIpmplV{&vbKP$Q~ zW8VE8a-)fMP!j`iy}&3-mV7Cys1tXiEa+BK(V@vpv$7Xf^a@uO;%m$sSOE5y*+&`= zE&zGUK28rAI&l+Vtx<;#)l&L@3k6U-_S3Ae8BDhd&H7R^Y)Br`XI>NZe$G509-l`) zu2Fjt!cE7?i`H?p+ zZ?Xm%OU5M;4Y1?eTK0-R)&{@)cbea8Y9c6?2XW+4F7|Dhg1YAO?NfSX)pf$pU0+!j zDC`NIh#u%DW7BvQ!LT_um?ksud|uKd7kec*o5b=H>jeMXjD+pT=TPAKP()cu8|etk zHo_q@0wyj_{P{!VM+ssV%`}*6o8EgVwLc-{afEB#p-Y)K&qkRRiXsSasj>a`0Dpf| zhk)GdSXp&;rCizJUI@bn*0f&dMxFS=!Iv|zC^UImo%1usP-SQUR>U;F4s&?_>Ea7F z=Kl4jt!SkMfA-k+Jij`Yb~0YOpHCx^Oy!_6Z(Giq(F8!=oy+j;Bk`(p6-G+~s~9>u zkxqe!t0vf%C;!@(KT>RTLE0R7h}mRk!|n#17D|tl`PlRXRzWt$=v||)-k%LM^L&-M z3!jMTLMC2iG^1fia1h|&tcbAQUmcaC6Cie;>uL6t;-cU~;k{E82G^5^HS=W=eN}ri zy0%jP$jpbaFs5n1hy3VD{)=SaDH&kEitQ`IQfT4-^n9T2|MzoK;Z5WVglx)y5f6g2 zy7&lTF0AkiE*u^|!|>nk5-G-l#PB4mnZjE*R!U3zLHi3$@`mXk<)5t?jySY(uXLL3 zTtE}jke-`E_^sVM;o6XM7w`1$qOL~>Rt3+;Sqn*PpwtGMV84k(UkYQ);TnIt_=ZCC zJ14iVzYRR4T*&XRx_6`Rmbtt~_R=O5TTQq<^^Zv&;yof(&O7A;^mB8z@vMCH(Tf-J zo@Ek93a*r(YD z`P%G+T%-A!?m7?r(Y&^V3&K&MsM&^@Bop_3?5|?<+^$N7Yd-*w`R&#D<~lu3rd^6s z^tX{W_uv+V%GF}xZlNVT|6#e}TXv13<5HgS@u8Ai^LIqx{n?P>M*$BCcGB1i;tG*& zukKna?CzcN3r|r_Hz+CJfP_h?>X z(7rJ{`St?}lDC1@qRb)IjCbGH2KX#iquGnQ5=}x&F7`zXs{yTPDr2#h=I9d&EB;f! zs_X{L@B*yPW8DfI?_TQulNI$0>E<<1u{9GqkGYWb+iv_|BJ^ox7yb`rchgA^-Jj;V zLt&DcSRlsuo=VnrfR;kSQaQHlfV7eKFudCct~qyw;*7>mzTJlr1Kd`l z@0J#T_qUhtbPC0lT|sfFwJfpuC`*BCx>Vugv{A~b&`J$T8xCg!no;jB`|kUFHo}Lb5LiU|Sg~OrowO@{yOM2iFn?cJ{!X>34XpP26 zr52nL@_9+;nL-uaA08@hqg!4u@^}_n;Dv&1_zQt;cou3%ja*C{9BrSi2+3D)8~Tcz zZP`m`ovRm!f35nW31=otF^X3W93<0Kp4r%TjGbAXJM?>4%h!Z@#mXewGSbsWvi9Su z9ZBI3nu`$T&{@TvUhxy%!uJzUJa3B|--p<7O%gnHAE9+I)1$(zO+IG{+s)t(jc#Y^ zwuzFnqf`<|%@R&$(3it#qPQxeKpC2T-$c;=gvxsLAL-Wz?Yv}c8YsVdyHg3b=d?8Dq_ZTGpdOtU8P#_{oe5Djr?YI+QuK~;5) z7W3^tmsabvYzW#hn*$UPUkJpt_1u#5mu-1H`uSFUaDJdVk(5^lDv@>tKOq9@?rR0q z?Jx_6Odn9wt=(XETJtJ#9r=tb=}vy(62e9E9%KXBNo!P`Ys=5|)_LtdS1b;gFa7d% z2l-=(MOh!ZHfCECtM_cU=LjrLT>@j^V+2pXb%ZyYG9hP0?gQ9UwQnU2wB&Bw2?w<} z5E|eB*h^Es&Mia;-Qx8;9nuY{9RdC-R_6qx#$BYWvdO#73vK*5bdX(*?Y1jCyY04V zlak|A2Z5?v<;AumNVm)C9~G$k;%CP{VC~9|JU)tyA;7)Yh5N9mK zNvxzfkG0>h$DA40ofn_BK^HUc>2-fCTgffJuZ~E__R{p*a3td96(;k}7^5Zq9nAj% D;W=>R literal 0 HcmV?d00001 diff --git a/openfe/tests/data/openmm_afe/__init__.py b/openfe/tests/data/openmm_afe/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/openfe/tests/data/openmm_afe/__init__.py @@ -0,0 +1 @@ + diff --git a/openfe/tests/protocols/conftest.py b/openfe/tests/protocols/conftest.py index c18204ce0..64c8c33c9 100644 --- a/openfe/tests/protocols/conftest.py +++ b/openfe/tests/protocols/conftest.py @@ -143,9 +143,21 @@ def toluene_many_solv_system(benzene_modifications): @pytest.fixture -def transformation_json() -> str: - """string of a result of quickrun""" +def rfe_transformation_json() -> str: + """string of a RFE result of quickrun""" d = resources.files('openfe.tests.data.openmm_rfe') with gzip.open((d / 'Transformation-e1702a3efc0fa735d5c14fc7572b5278_results.json.gz').as_posix(), 'r') as f: # type: ignore return f.read().decode() # type: ignore + + +@pytest.fixture +def afe_solv_transformation_json() -> str: + """ + string of a Absolute Solvation result (CN in water) generated by quickrun + """ + d = resources.files('openfe.tests.data.openmm_afe') + fname = "CN_absolute_solvation_transformation.json.gz" + + with gzip.open((d / fname).as_posix(), 'r') as f: # type: ignore + return f.read().decode() # type ignore diff --git a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py index 5f1b3f3d5..9d6704972 100644 --- a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py +++ b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py @@ -1,5 +1,6 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe +import itertools import pytest from unittest import mock from openmmtools.multistate.multistatesampler import MultiStateSampler @@ -447,3 +448,117 @@ def test_gather(benzene_solvation_dag, tmpdir): res = protocol.gather([dagres]) assert isinstance(res, openmm_afe.AbsoluteSolvationProtocolResult) + + +class TestProtocolResult: + @pytest.fixture() + def protocolresult(self, afe_solv_transformation_json): + d = json.loads(afe_solv_transformation_json, + cls=gufe.tokenization.JSON_HANDLER.decoder) + + pr = openfe.ProtocolResult.from_dict(d['protocol_result']) + + return pr + + def test_reload_protocol_result(self, afe_solv_transformation_json): + d = json.loads(afe_solv_transformation_json, + cls=gufe.tokenization.JSON_HANDLER.decoder) + + pr = openmm_afe.AbsoluteSolvationProtocolResult.from_dict(d['protocol_result']) + + assert pr + + def test_get_estimate(self, protocolresult): + est = protocolresult.get_estimate() + + assert est + assert est.m == pytest.approx(-15.768768285032115) + assert isinstance(est, unit.Quantity) + assert est.is_compatible_with(unit.kilojoule_per_mole) + + def test_get_uncertainty(self, protocolresult): + est = protocolresult.get_uncertainty() + + assert est + assert est.m == pytest.approx(0.03662634237353985) + assert isinstance(est, unit.Quantity) + assert est.is_compatible_with(unit.kilojoule_per_mole) + + def test_get_individual(self, protocolresult): + inds = protocolresult.get_individual_estimates() + + assert isinstance(inds, dict) + assert isinstance(inds['solvent'], list) + assert isinstance(inds['vacuum'], list) + assert len(inds['solvent']) == len(inds['vacuum']) == 3 + for e, u in itertools.chain(inds['solvent'], inds['vacuum']): + assert e.is_compatible_with(unit.kilojoule_per_mole) + assert u.is_compatible_with(unit.kilojoule_per_mole) + + @pytest.mark.parametrize('key', ['solvent', 'vacuum']) + def test_get_forwards_etc(self, key, protocolresult): + far = protocolresult.get_forward_and_reverse_energy_analysis() + + assert isinstance(far, dict) + assert isinstance(far[key], list) + far1 = far[key][0] + assert isinstance(far1, dict) + + for k in ['fractions', 'forward_DGs', 'forward_dDGs', + 'reverse_DGs', 'reverse_dDGs']: + assert k in far1 + + if k == 'fractions': + assert isinstance(far1[k], np.ndarray) + + @pytest.mark.parametrize('key', ['solvent', 'vacuum']) + def test_get_overlap_matrices(self, key, protocolresult): + ovp = protocolresult.get_overlap_matrices() + + assert isinstance(ovp, dict) + assert isinstance(ovp[key], list) + assert len(ovp[key]) == 3 + + ovp1 = ovp[key][0] + assert isinstance(ovp1['matrix'], np.ndarray) + assert ovp1['matrix'].shape == (11,11) + + @pytest.mark.parametrize('key', ['solvent', 'vacuum']) + def test_get_replica_transition_statistics(self, key, protocolresult): + rpx = protocolresult.get_replica_transition_statistics() + + assert isinstance(rpx, dict) + assert isinstance(rpx[key], list) + assert len(rpx[key]) == 3 + rpx1 = rpx[key][0] + assert 'eigenvalues' in rpx1 + assert 'matrix' in rpx1 + assert rpx1['eigenvalues'].shape == (24,) + assert rpx1['matrix'].shape == (24, 24) + + @pytest.mark.parametrize('key', ['solvent', 'vacuum']) + def test_get_replica_states(self, key, protocolresult): + rep = protocolresult.get_replica_states() + + assert isinstance(rep, dict) + assert isinstance(rep[key], list) + assert len(rep[key]) == 3 + assert rep[key][0].shape == (6, 24) + + @pytest.mark.parametrize('key', ['solvent', 'vacuum']) + def test_equilibration_iterations(self, key, protocolresult): + eq = protocolresult.equilibration_iterations() + + assert isinstance(eq, dict) + assert isinstance(eq[key], list) + assert len(eq[key]) == 3 + assert all(isinstance(v, float) for v in eq[key]) + + @pytest.mark.parametrize('key', ['solvent', 'vacuum']) + def test_production_iterations(self, key, protocolresult): + prod = protocolresult.production_iterations() + + assert isinstance(prod, dict) + assert isinstance(prod[key], list) + assert len(prod[key]) == 3 + assert all(isinstance(v, float) for v in prod[key]) diff --git a/openfe/tests/protocols/test_openmm_equil_rfe_protocols.py b/openfe/tests/protocols/test_openmm_equil_rfe_protocols.py index 3445d7b2f..50c3c9f22 100644 --- a/openfe/tests/protocols/test_openmm_equil_rfe_protocols.py +++ b/openfe/tests/protocols/test_openmm_equil_rfe_protocols.py @@ -1243,16 +1243,16 @@ def test_constraints(tyk2_xml, tyk2_reference_xml): class TestProtocolResult: @pytest.fixture() - def protocolresult(self, transformation_json): - d = json.loads(transformation_json, + def protocolresult(self, rfe_transformation_json): + d = json.loads(rfe_transformation_json, cls=gufe.tokenization.JSON_HANDLER.decoder) pr = openfe.ProtocolResult.from_dict(d['protocol_result']) return pr - def test_reload_protocol_result(self, transformation_json): - d = json.loads(transformation_json, + def test_reload_protocol_result(self, rfe_transformation_json): + d = json.loads(rfe_transformation_json, cls=gufe.tokenization.JSON_HANDLER.decoder) pr = openmm_rfe.RelativeHybridTopologyProtocolResult.from_dict(d['protocol_result']) From 2523e6a3c99d5c3e56e8bade8a91e8afe4d0aa21 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 17 Oct 2023 05:25:49 +0100 Subject: [PATCH 67/74] properly test reading results --- .../openmm_afe/equil_solvation_afe_method.py | 10 +++---- ..._absolute_solvation_transformation.json.gz | Bin 42839 -> 113091 bytes .../test_openmm_afe_solvation_protocol.py | 27 ++++++++++-------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index e9bbf5224..1341f3ce9 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -100,7 +100,7 @@ def get_individual_estimates(self) -> dict[str, list[tuple[unit.Quantity, unit.Q for pus in self.data['solvent'].values(): solv_dGs.append(( - pus[0].outputs['unit_esitmate'], + pus[0].outputs['unit_estimate'], pus[0].outputs['unit_estimate_error'] )) @@ -154,7 +154,7 @@ def _get_stdev(estimates): # return the combined error return np.sqrt(vac_err**2 + solv_err**2) - def get_forward_and_reverse_analysis(self) -> dict[str, list[dict[str, Union[npt.NDArray, unit.Quantity]]]]: + def get_forward_and_reverse_energy_analysis(self) -> dict[str, list[dict[str, Union[npt.NDArray, unit.Quantity]]]]: """ Get the reverse and forward analysis of the free energies. @@ -269,7 +269,7 @@ def get_replica_states(self) -> dict[str, list[npt.NDArray]]: for key in ['solvent', 'vacuum']: replica_states[key] = [ - pus[0].output['replica_states'] + pus[0].outputs['replica_states'] for pus in self.data[key].values() ] return replica_states @@ -290,7 +290,7 @@ def equilibration_iterations(self) -> dict[str, list[float]]: for key in ['solvent', 'vacuum']: equilibration_lengths[key] = [ - pus[0].output['equilibration_iterations'] + pus[0].outputs['equilibration_iterations'] for pus in self.data[key].values() ] @@ -314,7 +314,7 @@ def production_iterations(self) -> dict[str, list[float]]: for key in ['solvent', 'vacuum']: production_lengths[key] = [ - pus[0].output['production_iterations'] + pus[0].outputs['production_iterations'] for pus in self.data[key].values() ] diff --git a/openfe/tests/data/openmm_afe/CN_absolute_solvation_transformation.json.gz b/openfe/tests/data/openmm_afe/CN_absolute_solvation_transformation.json.gz index c4f3a161974d8eb8153937bf7a0f9284941c9bad..c8be334778658ca698417cfc124b2c5d0a6ff842 100644 GIT binary patch literal 113091 zcmV({K+?Y-iwFpl*DYlN14B+OYIARH0PI|gd(*ZS|5vi?!N%HR`RxwsF)ojd!ODQU zM_WAEmRmz&2gfNC*8S~Q)=`j36DO+Wj>`uhpPR_CbabA-b0p{OkWA}*jB7I79=sin z@nMnIv$XlPX>A#XY4}0lxt`~nzGrU?hBJDf-WwSS-_EB| zGMm=rc$;>ss~Mq>PRY#yJMvnnC;LFSY^hN+)axR_>THEssoG}AN=P(m`# zBevnu&qk6Yj!j%-xwdIF!{xaDQePf%Wj|gWW%xj|O5_;~4(Dy^@k?QeD{|T)Ce4S2hM$=%Y22aa2|* zY1;3=!mdn@@PyFsUsLv5-)sy{2N(XgUlh&kvBPMv_t=-tAW{;%ab`B#nR}?qb#Cmc&`kg(9L-qh`&eCnx#-1Yy8~2Qf|PZDfdY7jT}}2#8bjC9>+G+=mY8A82uc~L zi6hn#m=D<>Mjo=E7oV)&XM#p-a?BjzQ(#?(FDtkO+j@ASN3$k=1VxO3> z5{dBn!|bU5_+;JxhCK|@4?-c@6#osS&Thr#bF@Nq|JDY+#+;Ki4&Bx&{&1jQyn%DU;iD;;38Xw|so92QX zV!8R&Xp=eavWTcJN$8LeU7CAM2p!NVaRpD#=)F_c#JJQ1q#qUqq)+@?i+`l9^WqSg6-CJiPp9yN3>(*R*Bvf8JARPsF-LQ z#}C7!le8)+po_+oX4+2i^O?0VJTb)Mm{ixY4B3x=*m^x4HFH2)olmSuMD@Bzag{E* zckj;aXiTwSevC&{InH*O9F@iGGMGNpN2D5;E&PAN zI%r}ymEok3;H)~Ic$k4tElo!EWOSSt-SIq`P8ZE*Yz4j>n3n0f9&!z22GZPfaYVQt zm(vO5LHESsh&C6q(TFKhTE31ZWlhDI;}O5n^g71LY&MR-Vq3%l2BB`EvpR3SjK{0Z zvnY$k97r0CNqtl@{VB?1R+m{eJaeD7z0YC9EMd;l0v9D6h*X^z$$S`i%MX@GyAN*v z>-=FJyp1&%_wowdnM;bfg=S3V(1y~T2~JZw!~))!z3>K)=0UNAIXB;R>OBUIs25Lh zM5wopUgbqvzG~=k#GT{xm2?9cVi8k3o{UJf-a4Rfufi^iQC^S;7kG3sr5&h5NOjD- zo9Q%~UPMp;VNtTt0lolXInDjo!v$m2xH=?tM5?N+7I<>GL=~5#`pp;@49YcrydGQ~ z=!qroQ#H03({9 z9M(tYHOi2st52IK;pr@Co7w4UW+{11Fb%U!r!?MrJ}p_8VWA;^!)3%Zp3>3~Q_gDY zl%g!BB=Zy>xxIHYdOogC zCbHLOmA>RWOxZ1``RiRwL-)DmOlT{G7R!SXW&wW)Cs3x>Zaut4y--EcsMFDod6E+u z*QB_7%1TXBIePtnE@lC)WC4BoNhfp5vmIo(h{8C>3|!0U{iKsb&(dO+X*sqVSdNDf zrOI^u%b;pZQ`)FJCe;W-6=F)K!4)IOaD%|HC^5e6xQ^qX1|H|sHYo6#kp&RF**dB4 z$$4cdX5-1pQhL+5X=S4l*RIX##7+2ZtQk2BTAb92CWPj5)5R3RQX zGAzY`C0CL$x9Kwvom%^`aKI2cAZXAyc5?M8M1}wYD)*#c{zyc4KzIsq0Rpu5l8qgJ zvt^^DV>S_Y*36p|AKWzS5HO#efI|k_QZ^NGuRr^VJp?g}Kww3TJf97ec#@Nk&G0)O z3W`8r65;_i2ssRB{;}{vWAD50<;USm0ZDuUQ8}}v9w_`9f;nqTL@>?Vd(%+h1!M_` z0INdi)e~43`x>BEuLSTR{#W4v8wLVBc?k;>K^)-_usAikDt~=h+DgIcC1@;<&wwX< z1`AUJ(z8LF08D&C)*@gr=vo+s>vc? z0kdPDgRj8^Y|tk#LBLvmD#%RSjSw`_6cz^S@Qo|t2J*sGfF~f91Knf`u;C8t=L?LQ zBCgo6P}(@%xeim5_ZVMY(G#0HtHn43^viHK!06Px3F;9t|KeNVv!1aY=^+P*Kr5rf zAgnQUh52Cc7^w%eCb2-D2>^VsN6;fM$2j?X4|ptqW?+Pj7?gr#LyAIRR6pU8Hf~AJ z88Xo!`SvR*AqsAvhLX2M%=!Z?Xh1>9I(QiiNk6(K$~{fv#>T9?eA|2$s=%q*at2F>ypQy zQZIxwQ3iU;cyR82okAUCF4QMw1 zxy>dFXw<61mj}BgSA=)bBh7ZM=IJEefed-4)w!)@A1nD!(%-K?Duk9Mkc~r64ml(j z!#&7+gkM3_$Gd{_Jb0VoPv90`i{zdMMLsk{0FX-J_`*{{kc}ZlM*a;jf#OKG6>Nsz z2+A=2Lzo=caRAx}gJ<}2VN|KHQ-uNvft`S3H_TVX9ay~q-}ftaJ4*mYu<^m6_&kl3 zl4SH&a_Qa<$o5Z2?QJH70Nhi&(-xN$yxL0JSyPIjFz7C%$R0@594BNj4TGJye}4TJ#0prQ`^?-Gh!a#Ou*2}b zGSi`e0E%}^{n3sf6)IA^Wa8fs|B6NY%FNFIcLEp79Y0Ezgoz-FQ23QJcOB(<56ucKOGv>tC4>GNyJ*>@YXk^)zWzL1F-e3;0C{kxqg;Hu^@4bhLHq&>ITe|DVoas_7=kco-Q3@QCNcw>YYhaxz<^2kCJtOJfN_5On+WsZ z%3ytzHP^v(o^9{Jjtn?jYyy`24eD6{Mv~vxD?%(yV^<|f%bAMS(I+zPTebf<6bkWF{HI_9W;e4Gkcn;5#vC>Igwn>pT&~ z*T5ItiBqBNK3=mtw4~C|lEN;{nc84?8%|pi|J&ln61TO|I@-kX5O6=S{|ZELE9K%q z9&AZ_;UCEU9P>Ik<1Vh4_>+8ZzkTt}f5D)O_A6Cag|c+mfFaw7d+3 zt&HMrVPvJFYBi7Z@;KfYNj^Q0kWe;H)(d`~;emkJ39pdAK?UoXw4Y$?$=UDf0_n;s z+>&C?dXjGrQHI3?#i6#fKm!C@@0={S21C>9c+=Wq(^4B8c^uplGgK+xZP z1TdTjWN1zAOO+^F)T|OPeLxK`8d!c12>ihdlMr*$hvHxyLRSt%aU+qkxH!GRbkMM# zygvdzL5QSil!(X<8M(pBI-Hjd60irjFt6u&RmM7;o%Dsk>f0g4}R!936w z;(R49|5{-@jt5{x_vW6p7J)AC505282(<(#EX4k!*WXLYFi-g{F(aiP&Q2tsb2QYy z!p)=WN2+iB7b$q8T}NzegJBLs5d0WPvMth(+AVZ~0N~$2k1jURWflC1Ygug67pGH4& z#4K<%Kf#fF`K@#m!{y)~(lPGTqDVvR8~@C`W+rx)IIhHEE|f{&5)S{t z#wSkRKC~nc7<)pSplF)#y|r&Y6kqt>+f8TRoDQ};%k+HP^pI&Are&Xd59>^SpS`Ba zUzv{B!S5at6}l+PJfp;Ba{~4M3Ovme`D1AC6J7baOb;pzx*99$sj28gRQ+8RsK?k;bc`)+dJt=f z<+TF;;(y7P+8uo9GI?c}%+t7IoYkd|%V<{Nr=@|P=o0ggi}2La5LdhEsA|kdJ?XB> zI#l`TXuv5%QC5BO)a;N8W{N!Z6z$$xQ{I&ZezwWQ)Rb#_9d#8c=$NIj7F8eQTr%EQ z)u}>*PmMLywN!M~?vbZXM}4X_;Qc~_o~q(x9XwU7KN&so)YODmEDd~h*5qHIC>O6M z+piRP5o@Y-yBg}jnS$=9)Svp|si^_0SRHFjZ8}CY)RWvxwtY(j_HsJbLYMKh(qt1V zHRf7_53@AoUA6|kIvs5$)`0oftpTXif~pUMmluhved%f3Q{4jnAMn)E;7@CXu`>;R z3~9h)g(kjQ9qY-exlUJ}USv-|g|D>+U*u|#m!T*Ft0Okip{qLZdaR*t@AX(i%6h1` zsOo6kqwUu{+P>9e?NXJuRYzUTJ|I2qTE8eX=_=_FqgoxXkZKOo#%t0eU)>I8!eT|< zn;LwA(XsxodRD5|P#e!W`U0Y1zu_WrUq=qE!c$WNepB%WUC)}d@f!71Td_3cb1Hh4 zb>!zo>QAcYNmbV9X~;>8j`etz{S;N4qIw?B?x|jNk+ZGFCHhWPyl88PJB9|Fro%Q= zbXA3~m8N*)cI0XCFZi3Phnl&L^OQRAfUfaX_`0tDEHwC3PXiusd*-Qki8Yh1??|ZH zvfWd>P(AN-k@tSOp3(1GAFDOhs!a_!lPa!I*&|S~bJcuWB|bYEYEW^{G2ZDR#xLV* zrJ+{WHCL=O>7v(>pRpzz(xInT$J|KOpCS!-jA*Fe#~pQ4HUC!01BeEms`?L=vvx&K zc3*3XwVo!uK^^6ub<_nN=euJ~^?|N?jjCrloR0h~dVB{V)|8Wbnq=0MuPSGoY7O{6 z1(&PTTjGv7bCG>6RqW>U#8affK2_fB@HExCT@5kH?3i1r>T1 zSVcPSr+OOvrmo-a(D6Q5-IHHk7i+|tYD_xz95N05w=W%4%}rJJLv);H`4H?LX~=hK zP4z0Hr@CX;Gp;(`K~lx2g(knLgQs3co~oWrB^|M^jx$3S`K<$2Q?9XYU0Ov~V+}PB zrz7st@w-r}HHLNfLoy9`Rdv7K?YY*Zvs(Nr^cI$~KBA699y*{(cA8sx1*UbUi6UiW=PUB3^j zk_)4b7_#aS^Q!)CkB;94Ff`S+kcND}j~v$2P)F0nQ(HsLE9#Vm zSNyC}t5U)GhK4->Rg9+kuD`7*kLvP!O{%|vs-te~Y4C;4C1aDKqc7F*s0?zY|Hjy(CMb)uUb7+m>IlmK9qXjb@%1 zjYfuOs+$`g6)WW%l&7jR^$gHd)h~kBM{RNq-UqEM=;m`otQT_t?$z~io(J>0ntn^& zoRthT_XC5zc*I^R-F!QUeV*BSL{k+z<)UdCXwG}pwSCn!I1hmBb!8iZrYh@$rfKQ} z=;UhJ-l%+$fhM*e8n&ZUu*$Zww*vSa9Dt8HyCj+p4avAl6I;=5yadt4AayG@q`QTH)Q}@N=5${ntuKogmDr6Duj=CXh^QA z<~0leJ`~j6GH%eG(v9O$SwF1|*_*1e0DfOW@uQR>a5$DiV0RdkA;J18UV1AieI!aXip*Nx-D;9#{L1sdFew< zdKLzCluE2HD(>07V7#xoPOF<|voPfQ&p>?Ofb4$Vc~k>vsw!huW5jiHW~huM1I(iu zpch<2_RM+azOTu9h`bYsHC-QIbE(WNReOgSve^)4!Z47(Q1`sE8YELVKu6h{G7@qA z9D=3@K2_aUml@JgXVI?)*{de=g6rlu&kW6HgpFux@+c~|7iwRxtI2zabxa$i2Xy^U z*(J~vC9@oWKS#v}i#UrT0N91PHd6-TRR-WyQ@QwS08JUdmyF0`uDU*{vd*G=76mHz zkV%?WAeqWE{de1r6)LBpV@PjjAb%(W*ziM5-@oc!F-y~~RF&WECD1g`)ETPz6A(Fo zJ%GGHoT-O6`w(GAS(-M+d1&o`Y))U(FRij(qoV69O&jk#u_7jEir8~*f~FaWrar)) z?Wd7s#i~AU^dC;#S>xUxWN>x@#xri4pPu{v1_#$~65=Dr?k#W}2$_@O9$Y z5PNuQgT7A&vQ2gK8>z%9soF0pYh@1L8Hn5|LjX4Q0d~FaS+=V9yD)@3RAU?wd*3nu zpQ^?iJAgTkz{y;*F9iq_B5;sx$Uauh52L$Z)Fe%-OPIHgrrcA-P(w|;?}9K;0N_&> zpx==BWulHt0}ON9(TJRR(#r2GJDZ?_&mi(z-cpRP+=oH!ULX6f=ZT zP0oJ?psAh-1Q=a9*LnxCDHv5Zt+&Ut968{4LPwi{xvjcQzwYRnrOv_VKG<5)&Q#IBV zasGG)nENVwTDT$IkBBQq_>&8ey^qKxrNSQtfYy$t9|n~N!!_VbQ>CYhzW`zXsKz)V z&fZe>S7n+up(!3ck1W--D^>SRAkM@3`` z{(GNuv#9vBRb!J?F_!LGBfh2`<$~m?sr8NO66U@xO;s@=BEO=_T3iO8X$7#eYk+Js znFCaH--)TbClx>jF@TS)FlZxtm*5BIn*NU3AWc=z&+s+<@eauUsWSgB@=Og?dIlgI zggA>{W&U4eE(6pa1RtRHRp^-ke8(s~6=?c23QgWXtf#21S5%s|0V>`b5zCTk&LKpc zHwWm7^UN)Tjch9SH$wNR?v?QX^9vED0#2c%weQ)bKM(poKtvg!)?4_d5b@Hzl0KRuXJnDh`lcxAI)8ISAetA@$M9VOa z)y=0~Yhv;OH1CI?d5jo`O4HBJG)%YN8$evJLb3nxQ*#Mk?z=qrq z?$te$9F@;NH7>F?WE&#nBUo{017RQX)@43rAshu5_`9MBxJ zKOJ$mR z&jIJ;C|ixTbUs_V|wn_hHWXGvt?9pjp=jr901nyKtAcf08J6T zK9llPRbQ&oRK<5}>bzseAm+d5xvy$R>gMh?Nz>sa`5%G{lBqR-o(MmGW|*G7raV>I z!>&S8RawOW`0qTq8&%f%RQ(+~bVaQDsO+CrmA$6UqVNpR5|x|Sv~7xrqgRPz%MAJ_ zEr8E)TOY;yLeC07PZu!nRo82)OVIh-$~+YwMXmR!##%c7-vDBtVPSx#s(um&pd%6a z0a3CEvF^-4K4?dCd~J{|tKxsymM3dXy?-9xqyx~EDtwAqbI{ELfXWYm*k_31Qy+kz z1C6<_vTkAGoLiqY> z&3Sm97$Xb=?#}Q&l_4)zsOjd}k`Q3VToJst`jmI61ksGpP7%k|%M*UnPfealoT_9oPB1 z&Xf6If9LlB&kuNU6i*hMuA?ZK#PfL+iK{`8##I#`55#k#Xj-O=32$#KrZZW*pUBeLUk$wQN$xwLfes&JSZ*_T2V<6qoF{UiU&OUU?l`fQQ4>SKneo1B?$ySB2e9dV5m24N2Jep)5%2K-L$|of5 zPqKn!?2+?x^JRzfPBMNeUnL*Q;{%!)bZLr4E)p6WThb_zWV8AD zl90GaL2F)pUVW~7Zsf(3?vRK%b-^8yl2XPnDKQxF{P67(A+@&U>zMf3CP|*$UAY*M zaJIw4KbGIeRAP5Riv*)>^-F%*suoG|tGQ+1Q$egLFjeSxY9;ZzANNaAZ~Vjae~W*z zi1bt_HPnc7tl#O$ehCZ4-Md?GK~>$iOPIUDmV`w%uyfe5Grc?h@5)6OsPgT06XA;c z)(W3feDdT>2}pl$Ju(T6OiD$qwC!KC73`FFgC3LgE*Rl6J9*IIR-5%MVuoj=6&d?K z$26PNkb)FZ;1d!rso%-B(0RLL$$f7LoK|gz$v^(y|6a1sTeVwbfNUd{?yX$JLsdk2 z#WbrFP*5P7Ma236Kdbo6kzJ}!vTliKscH7xC5cVXqBdon-(_zeH)E_LPmyC{!{&zf57B^Qc@|M!x z2sJlCi1kWBK~gBN#@Ve&*!bBB^U&`%bd-5pE8N(Ei=#g$M{btb8!K!C5!>sJwF2Xv z+z=9VM@K6cX^W|X^*E|uvg9nf!5*z#Fn{d~3BxN{bRJ8zUsXZmZ{)-?w8|!du_cRq zck+?=*Sh)T7yJ4QL$OtrKuM%P-P!oBfp(7d9DX<~(I2KwB4RU7oT>OywxuPY?F+c_ zNkVuPS?he7rN@~XY|}E{Ha?A{^^C5W0Cdr=LvDivTj_hnZhv0p?Q5k?QfW)}qBSOB zllZIt1+C+6ZIdjkp1CZWjIQ|a()bVk)Y*2LZ?oW1;H>SdQeXv(AFo`b!z(7d`ds-` z(4QLx+(za*I~X7h$Agm-8L~645}kNNR$Ip8q~*p4!y^gw4g>n(sU*Cr5Fe zm&L7}dyjTR;Sn$Doe4kK->LYF$MufAbHEE;$x1txeDyE$g(9*=+#fFxR_^UHEkZcI}H4kxue(~rK6!6TFgy- z*ABwWPJ^Kkg3aUdVz#I$*s2L!N`|LP#NXaZiQg+}UYnFfGeEp* zUQGdM?xSzMc>H}o?DmMhe!rITc~V{5-??@t8(q7#b4~oT7GoThRmz+42h_1^GKx-I zUl-(eZ`|5B**W)rFN%Vs7R9|jCl~X#6jDU4KHfaGVUl(CaOCD*k zpLa2cHlsMpr}v}pD3WsAh4yCYKE;SqG$^f*kh7AW5KygfhXiCrL8RT&S@=0Ei9Bo@ zSadLiYJ{n*5Og7uj6R)*WNlH^lRNU;l3mPV32%8i?$!sXAEEQ8TS_GNDo@*&)h`Ae z5~9e?kb+V(!W~4v5xY%`Ib9KTlQ~_hv#bkgC0)~6k8!7Q+E7#Z8G8iVZ*}^HxQv?3 zdnDt->wnNN|Io!e6Ybk~d&LPwjQZW9v*CFE)Er*kS zdrhOsJI=~TdB(l*A;iXdgieY5!tq1$E}vK*QGdv+ZX;UTNjaF76CvSeB-+Z1?=Hpq zy+>;`$dhS2nS4=Bcp?Ue6uMo_(9w+zBk#Gg1amtAxjeRNYXCNJ!{YQcEl^=ihj8e_a^yoyi=qr zog>Ae(k=Q$uR~3bmNXo!dB?p&KFyPO^5l44^XXY{Xe-UBQVV5j{^>e}bVa|d=*R0i zFAmm(Qukql?EI5u)s=^rGhUoRM@_+W8pWcJ|GLO0&0o>H_xrT~PAIN~XE~pw(b>)V zQat*LFQ}WJ5o{`}2M^9}ob37Xd3)jxpPbzUG1_x6o;fkm;E(`7vxOr+$kXVhV798O zUGZrtm_`hfC~NV<;P5!DN&)Disc5q(clWGQeUo@%5KkGeZfBW2eDdkutLdb92bI1Snmk&6N|5_L_MAfrQxN$-nT)QC^(+ zteF_JsHq!O{2&*jc-(bRmd|AeXBD5%*S*i$3rBwFxQ_1!wr|-^*n9VKIHI_omh+jQ z0oQKGp(rjElZj-cw0srK%32I(9#7gEO|7FiSuCaz&DeJg3rz_1DY~fh=3+YCJUxrD zXr=>CqbaWsOUXY)nJnru%Lb?Jw`Ffr*p@S4Nzx)NO7S3GX%SM&Fp`$y^U0e^#&zICNbxj| zxRBP-%e+X-mrZ-T?#@yAvUkI_x=zgF>1@KQ?fOA=jShBMO!9(9aS>0B=c0lb5ndfh z>E?J1rxy`9K%|yzc%bb-H*=c%uLf(LRmar((wcZNr`i z5>39Np`X90WVD%$A5*BR*$Qj-89U?Cx||EEu}F0pSu}H!&l?RP`o=dXCVS`h&MZ$r zuNF#4bacK7|GfS?-w;IeZ50F+5(f5p~+Ew*k4_Ak!YOZI(>vEMQ`-gZ|m zSJs|it)gA6qFt?`U9F;Bt)l6!qW#!kAbS2|n~I4GoSrxC1-r4h-$Y(Cj)Rjo%7)tf z?%K`M^XW2QO)K@Kr!QHBDUZu}{_1foRQF1EW};L;i?fwcEET?JO%nk!zkTn~Z6Sp! zo<>@s9ZMr8vTpbE@`-dc&HLKx&)JzZxKb)~q4@LZIxBbA*$b^8be)msJL3OQ-{bg8 zF6_m4QdT*SW{rI^HQe(pame!lI4JBVJ8nazf0- zYvM{a$nreTw;x5QtT`#NE`pT%6rP&4 z>i6D%x=eJj6;T{tn~~>(N1wf5Pp56t6ImnuIiRghQYyQh=1H<~&?`L?ioU}n?4bZQ2t+!~}ZTa6TVz=5>E z$ZoaD32EkxlJW)R5rr#;T`rC+{Tt~%_Zek{O9^Sb-aR2Q-J}r6xZ7~w>Q|2*()M!v z*Ud{tLr+6Om>_iSMWlhp-eE8MqId07ev@pUcBK4rr$dUYp9YVm6tn*gjX;+MuiX;B zf}cjmgv9QBMZ?2_x`R*jnX<2YIr#|ijfVHT7ufETF%5Z8{FgB3v&ZA_zdfwGTz5%& zf4ooZeB*JK9*{35!>FSHw~teo_JECF4EDO6sBuzvER+rJb~{RGi^9nN@kR%s5{vSI z4C(ndI<}_SOPMP}?`{vl5m5ABU4Ky=-#vr$pFN<>_>?&AS(hHL-E>H83ENGHH))2@ zxHdb?s$aX@g|WTcq3U=!>zy`NW<`VCP0p}Q_dwMN=B6LeozI9E)PmuJIy<7#c|ape zJ2z-!9kV;=OT3a2oOj0y+L^TX^lvoWFWYBE9qmUx(IDmH@a7Sr;p_E($o>dOSn+>! zMuISg2ICHzQlE$~Z0|Rcp2Q7zyO$6`jjh+jsU2w(=Wdrtk;b3EI1NKcOhhRnX>{K; zQ#K4&XJ`>qvED}ckdl|STL{?j_v@VuZ$+@%+fh$=+8#Z!@7-wA&-~G2N}p1YX6e7% ztRo-3A~t>42?=^?oVGG^KX}~}n8P+x(my*HOk{0!7V89fNO9%7*LUJ7+DpsYw6PU%dF?yWX_kLcqOirfnQB*_6!b?zE<)*X0IXvPXD_^y8h@OpSZ{@OW~wYZ~o$ zp{;*yo*8F6f7vl@5`W%JOGb~6Da|Ok3wYr5yav{o{2dvQEr`2aT(LcVcF=Wht`QaXZ>2xVxInBa0 zV~O{{x2)??65d_aMBB;1HS=lXcv$Vm3}Th+Lz>9{&)&7Lw{0BTzoM#X^Ri{UK{#pH z#LcUz6DLm6hwZRfU?DRhK?Q^qJE{BIFR%j|kR?8bb5?eJ)#j5d5CnE-XJ^iwotG*-vh*NY;U| zhH>%MV6PT=u+C*}00A4F&qi~$+jO`%5Z%-ufDnH*`=lGruexrVXLNO(KJDt{_`UCG zX7oeyXm1hsGJZlBJAFvSe+dUP`1##_iWJeuJ$4|I+3n)(;17AQj>)3{A?d1*1^@oy zcYVhiNb#_nZsd1AT++09>&-EZLN8C6$U5oqMMf+a6+=jiFR8tM>1mu53-!TPfVK%3 zZ1ObQ?9+vLvT0n>I~16#ENb0VZusqs-s_TwN!(Q}$-JL3Iymm7Rpi5}NgbFtI{M|W z0~7BO$aH$s-uT6;_l8L{{^Fg}@2FD`Xp7>g^O?7PKiCL6dT-npxFijC)#rm!&Pitk zf4oI{{(XlKo{v!xesB>Z?}o@WIs2_HVw2h0I88Q<$<3ZF?z|(p8Sm$Tt?m4A+{bt1 zH;ZsEx4Mcz-u#Rlef9@Z+ws}=(dirg#ObPP<-si)>8|*;*G;-deMr&GP4DdI`@@3T z{(x4c39a9MhrZVhVcKtFRnEI)>@JC7F5=_e0M{m8z14rLvspV$4#q&F&dxnb4*;)4S+Yjr{{?aw#X+Hv_)FkDkGpy8y@zWwW#{xMwjMgF0AH$-JXY(dN2fW=BlC30ZA&^gB72b(ZYn4B2fsrqx3l zb7~AJU{`vHgxp!FxCdCYhD7Tx-MEtub&COc|FvB8{b-#0_kFZZIz~$vB-Czxt}6U@ z4&&f1Ie{O?{fy3{?2M*a_3cJ};6nJhIaaxdd)I)k-CX^SF6MGm_QD)tjz>nFDMpj0 z=L+BYJ(?=A({K9-sGFJE`}cqQ3SV*YXrN=-`LtgaE3!eTo_9SV_no-E_2545K*x5f zW-T+YDS})VldM6Me4t|{v+09BZCd0O|Ap6@<8_8guHyN96-A6sSitcV5rHw*`1^yd1NDF|%g4L(pxddpO`W#6Ka`&~y#=7R%m zF5|Wt1nL?BZX=tsjbYdr%5Dmww&L|oh;R+Q*yz;O8_l(|ZR>!1wGbZJxsMv|EWPsx({eM!1T_FvtEvST^d8S33ChKf_ZVyn#he zdK=T7Ta*keY3dM8aPRiZlAUYz#=Rj(e8SMqv|FznfBEA5T?N!pZw~Wf{0%8veEau3 zQrBI4-dKQoMo{c4@;QUdn`HE0Fye0CADxW9>v0}AvrK>1XGeACVeNSC*5Ntb`OBvl zGw@#%RdENk$_QlSU3%AY!1+4;sFLTV;qO9ZcVI=LK^(S%ZDbOiZwO%gVQ`23-6#E7 zU*uTzAStX{B+q((PIMS8$h*ON145e~I6CN7l6upji(yB8zD1V#=U!O|B|6<>-Xt&a z&jHDhbg;@D$f6^F{`<{<#r1onl{vj>*Wnv?t2_Oe<7-5NA$U9NAEnfqjrmEn(1&pN;J zu=8);p!{Fzf7q$>!+#9`Xa|-@g9=qy5T;HRz^8wC_n#W~G-?~gQ{zb0}o?$M;9zVA{}|CA3hj=BV; z8$$@&4RWVbzmXbGY0-4tgF2^+=ANB=-Q}x({2kfv-wlAY8m9aH%dT!8|mJ7S6&fQOSQdURhnzU^T0ZY><-#e$`Ug%y;^4?lV?lR9Q z_L*lrhs<-DcRhQ#W}G3dc0BvJVw~|@FspIKO!;WQ8RLo}56(I4933bb`h}8dz?7-a zoC9jdekz7cIrF4u&SlMzt75c|d+xg@oU^s?4EIfA&6!sfQ%^&{dGZlMrU*GHz#R82 z%9P_j3g(=1nKI$nl~Ul}HrXVu&2yPD;m9>$jAbeGJGId7ZIWpO@KL_dFT|F0G!Xio zk}1;=G&URzf|&mm$X>@nxjF!S_W|>u0?4#r%9MlmI5u@xyM*v_>)Kl}^(Yj|lj~n% zhTb_s+`NxWLx4=VK2$OF)U%+c1>^exQ>KV{?*sf)dBwU~05CBpCg;Z4F+)a(cw*i7 z))r+dj&pkUnwdOUaOv2jbWq z0oPgrVp49bQw#N9S?1LpfdAxhdL;ly`9jXO2H@#U`oWQD1+w29AB9?b(E!XY<)6z} zM5exFJ}nZ)>m@^`2!1yJ%mEL?v1afEaBY)~d;;#8f=!u*udqz_$%o@$4OcjCIefkb z@%*8swG56f6##q1^)X85n>YRB@Im@wcRfl2##SoV3czXG%q8M*6cu1@DF8-B@#S2ZUgg|(K$yfA&VTh1WXkcQD7kX% zq%Y(F5bLg7nN}92gr`!-`Ce^&r_U>9H`x%-7{02dhvb+-`eEn*)gH&;Fc z#Xl}nA31BcHs$JC;2af5rVhZSc&~shMfjnYp{JZ2whF*KBV@|Ww+uk@cGr2p#bZ~A zqe_6SLgqpt1nfY&BM5d^9({-|~Ea7;rT)CJ>x_;$>{C*(hOk9ieA0g8cV9U7q zw4C|7YkmnzPeUP&F9DcopM4V27`(n>n~KO~3jpgl6?5Jy00(W$_j53(0`X5ih^tUS zp0WVg8YzENf-r@RI10JH%mwfXwUBd+K>BM_PivvQLB#tZK&Bypx8DcPx&sOwyQ0g*3(tXZio z;q}N;jDe_^h&ms~ze1TJ>g99dM^r8Vm*?m3eWB2v+R8<#L2C%ywE|x_w`u^6Mbv2H zU`WI{FNk$e&i)tf-0}cexVD6M_rdTAz@bE-Ipz!37ixh0bphBOQ6D}A$aGhXssLOZ z*YAe_yD6=kgxXuiRt^qi7 zAAZso`qx^>#dFtA3X6QcC-i?PT&;w$Q3bGJ+%=DoDO1VXhO~whsy-Wv14iXTOY41V zAy%wGI6D;b*8AkIM;2sS0I;`oT?uu@#XkGCV+(U1wQj}H)zT8bgS4|QVC<#qtkQEy zx%>Z7wYd;=h!AyLv=Coi_4&j5$TR@NIvRj)VgPP+0Q29s6kl;h$C0u5-(i?^bV14i&*cpl{?Di`4MZClDu$K zdr|Qq00Ke%zG4g&Ku;w(4b}g1nR2icCwJF&&Odi=f-j7NxN&TFh4Y__qg)|(f~W(r zPcD$Ha}u=>Hz4Xgpz=n!GUalQQr~YAqXqyR9}45xePk-lXGGNRk8zLHGm_N z?)l(w{UwNt#~P7w-e?x9yq|2yR+>u5$tKEeJe@*h|Ir*@4i0AoLU!Kf3^o>R9A8q;>cTz}%1j zhh-{VE3}n+tFDkQBIq`^; z*LE#&`rLdDRP5*jxIPCEBXj&*B;@W;@i<~1HKLB34L$?K)pBz-IXK<{tSfNlqJ*1A z?&Cw{Z2A`W8-zkVMeO_23|)!=ddkI|`|v2c>Z^^jBT+Td5f~RS|7(EWAn;!Wh#hR7 z1*V1eTB@TiXx<`nSVN0?DqS~4)P-|FwlDzbdI-Q$k~Ija9o>h22>^U=4bcAz5aUT} zOl;Cen|1s(z{Yx4%<-UVWjO#n^94Qt!5{h{f5AOB!~vX9A+7tzu`2~2PDj;h_d#}5 zY7aRw-E~Y#08XqyI2&P4xqOla*elMs+C1;4v?$j|XivGkR0Y6`k;K!Bgy0e_Dg^wtK~LRYJdA1MG(fzz!VWftddx09!_u zcyxq39RQ!n@lU=mCftWh))w{Dx1^`2+C2*3rx0@=v7gulaK(rk>>dCcBGw8K=S~Cw znWFanc|sddTZ&Cl^&nhJdg=higcJWsb7#1moDY!aRiF2T@=?e!jmoDE0o)?u zEDA)eeBWa1&9$Y7JQ8JL?sMZ7#F_9hNLM3(E#u^}pw2ay=K3OPChnR`WP3m8%UI)Z zgq#;=?sM14eSn_&mUtD)$^w_XLp>jF1zj zE}sYBl6?@z7K6s!5ps1UK(9Ta5AZB}*f$nn5-!K@gX|Ql z#(^W0sdT+QvS>pCiRk+^wi(!6SE9gGSzrxE+N zq~{57Z7SkyK@`u2@cq&{wl%<}7NEG$wS+klYh7Glg*el_0^mp+dBryCDsbhAsLhP1 zJ&4E;Lga8G&JyD0G}@BsJ~q{}5X*AqS_0-mC7gFCdD_|oX3Ot$^$vkgE6^O3))cEP z>8P?SQ)zw>VlQnBTANS+zPc3J-hFts*ph6ewI;ZBIZbSbic$@YWZfscsZxCm$ z_zb(kktu5Z#RKqnfkhk6u>l;6R0DXJN~ovOefOx^xQMy0K)jX);M3A#439Wh6mjlv zWC=e=^B`>H1|VdKtOFs%{D`{IsI!t{OE|u^%xP;&a;*V4fr|k-`l5*=YXz9g93F+M zj~u&S0=Q>xj;>Uu4nU_l+y&w+2E?Arz>>e%r*4<@Og}`8AH-QzHf4&)UFXQO0&rM} zy*!AySb}h&BaCejdwIAr4FK4NBU2BA`*-DY5PKwQ5JuzXgT_KG$Fn3;R6c(s_SlkKIr||H^-uzUuGXOWD9!EQ zjClpHHyU7X5Ij*W)lldY) zo$2m_)A@_gwuq)8;H0-~FR$lKD;%2c{XS7(Zx#kNmm0YcNyt zkREQ{r_3K&dS<#XGL2I)31j@H!4x3b=?~4j3wq)B$lPdNn3$hgX8xW1)!dMb``*)R zR7NT~r>4@&W@GbiO=CBDW`6m1%@Z8z3#s%xt*hyitJ2mR2Unr7S@6m2ndz$Oen!I| zjc{lfm7IM;dCR>pG9g#E#4;rqRF0OlN%k^ELmuNz=^yPK;s6 z&CJ#w*_@kX+{WLT|Cwvy`1j@!IT=1LHGd}gc+{JWkx!G`C6iHH`jt^<8&mTc-DzGv zB792?Kg}f7f3d1+S1E zzBD~wV;~snvC%CmBluDH=lcYsvtUdJE+Dw_7s4KY?LO=AdXj!bXyfhvno4)m#hm5@ zDc9(cX@O~TZpJzdw`fKZY;xD;-_Gmp&^P&q?L%T%NR7@d0G+veUmWh z6ABQvd`|_tnWL?CVa?c{{J#Th8}PtJDQ}zS8^`|N6n`1jQ(fdWAvdy<)i(bgjn6f@ z>qDbLmt9`@vE9AU|6}{GyW`yI_BWdQ4JKem4KQOz(RuV$NdbNz+OD)Ia4)fV1NcKc z!MfMbmsPeNR;R_5h9uFo!aU(l!0kX_lbcv$8+Ou%A)L4bXJjb{K>coAQ!}z6M1&D%BR*$D1UsR_jjnBj|Jcz|Fw|pnz@O>y!-p4aYh!PT`djZvRU|^e zuZMDNMS)wzv-Jr(+OtirW21rCH29ll!>|=O8Jfnyp7*x{AG(XXO-GD~dZVj*seVw@ z{_W`RWXG>>1q&Pf!nS$8Zmhk_6p@xvE>cYEv0IvM>t*8XtXHGa_@#4uSNE`eK0MjY zSnW88&yDjYh1dyPD_Y^ww`i@-+?A8ZG(YtPazOdlDw?Z)jSrtaGszaqI<4wSJ)P@|xes|-YxBv4^oQXm zm!>~Vmy7P1ho0m4H(lqZ8-MG#x83k|=p6;|$O%V>Z3A?fU*YS$__iA#g-H;Gp?Upe z^7CmrE7N)R=zGD5D4ExAvx{|pQzJuJ&U!ORIBkcIktPb#TG z_c?oAs#}*r*Z)jLSt;L2 zQ4(5_CkF)cDxygGr>o?1f`(so4J7dB^minAj~2;BlOa~#7d-{2if3e_X}PmVQ(B}N z_a@uzvMau%AgT|qC|$03S^e;kyhraz5`wWsM^cX{%r=ENyZ(lGz4$KWLU?%iSej4)8tKe`CUx8E~N z0_jCIX!z>O&xl$jZU5qJ%ETN%Y#Q1;{m((4d`X&B5Q&fu#%DxS>Ojz|bWU1MOq^%V zJA)Wum5bO-%ES3$)>!xxGHr`O-&~pXJ6~SvLGI7f+3fCOrnAOj+*w)1HD08*oJY&{ zd-Lvl&##+TbOv`motYHH!>`(>oh+8qdU~cOjjL!cewn`8UM}@xM zcK_+J*7J35=x$olO&ye}^+%UEq-*)@BtKo()AIO|B-kNrFrB}@y7j`t7bm(rhmP6{ z^Z6ugF6y68r?d87ljY$1mjbxPab>!g>RCQn->k2iN8i0m-Tahb)1vz1ll6@=JzqWV zsQEY{l>6!NG@ty^C|lLvUFrFvQJQ9$nrj_?IDGsxuNDoU zo6MUwH`o0m=Um?mo;XbBN>{gv!VT_!bo69CYu`cF>gkDp(q{Cxsn7XkpMCbp$CG)3 z1*d1}tWl2hx12m)EYs9=%+=PE851*E*_cV+kmn;;-1Lr|Bgg1N{a9D?#RmC*YiWH&6aa&(5ah+Gm|KgEndJomBdG+Kl4U zzJuoa(sb~o(#z##?{kilQIvST7e%ofHNjIdc=u{JCTTriEKeF4XmLXxH3=2&N{uDi}r|lo}`S$5qF3NT~=zKEQ_2Y%n zpK_6%){CMzJokBb?L`T@W+tpeTBhZqd62F|f;2XatmP{!q8wV|n1%M4#-JSzJ~>JAym`bWvhl3QCY`OE;s)V-`1~g8K5*7p@jRXA##&E)nU?wD zm$p4&|H)bY%isgo={vDZ=O;5=?bZ*v*Xm#|%GtEkleA1{PnXRN&4}pg%vd)sY&E@{ z5CLMmWUB*R2l|=Qe*Wa}l4sRvb*$@&uBt_Ki6svPx}p}d`qz0{8YtJ=@%rGM1B@?u zu~qYFIh{AwdRljEju|`0`2Sg2myPF{WL5j=G;JR^TO9xE=IWESF~{kAo;uG>fe}!< z)6WcbIPeF$JZ|5-4JLhTZnHYdEk)>RXfnMD> zrKY3Hi|{WTzVo?2gm0^KGnCWga&o3;i>wI+O>DS=?(KXdZal49Xlg8z=bVcRCUt|P ztF#FUTfyj?PSo;*X|-r0(MeG?wqBRn(@Xk;?#>V%HfCqG)x|%*$*S%eXE#-304Gg= za$G;Ys8P;vbaC3$G(11eHoZBt^yWeEnx&1KHA@!i=*Q*4xEbRbroVP$)M&7TeifPm2F2tvCJu0)q{`2P5P1h)NX`)P3H32C>-lLOT4Q_1E!89Y%qg>Pn6tuQV z{8(o{oh;h^41DobOuNCGn+H_s@t~8F{=>Gv=K%e+TEAXu-3{z(uGcH|eL=Bb2sd7& zD_1LP53X0yu2<2nSJAFl(XLm~q^oF;h6_Xw9(AR2bc55UExph(N`2-BjdWJuFn!rYm~wBioIbgiHm>_ZcTSqC4YXMAj8fz9o7S`$Aj?~KzP!~~ zp-SfyQEJC3$eB2|27dXOp_-OM@AbdgnM-h`ap;ZXpUy9fa&LM^q365aXf%o(H%xqY zxcD$_;>C2fsHS>y(sERdwn@>$x#q0pvfQ}or{^Yq=tiOM`ten?YTiV&lf{{?W+{;n z%jPv{Wg6tfZs>S%9J-#LB!1M2>GiBQf7GMX)2V|?QM?+4oo}mlTZCapY%sX z!Ww7%xGx?xO20q;iCn|@hrup0V_M_lFF}_vdXIL*7vzW&^Q0shyge%v6+SD%MehvT z%22fVOjDVcp#FBS=`0DUf)$|#a#&ektf|{+TO^;Fre=@&9UqkbI!ed?`SZW}48$(; zl@c1eeQlKU9|XOhCVZ2Ps!mcj?+@x^(H^7ln{nr<4*&8^pYNTI-WqsyH+xtd_vneX zy1B{t^{g;;e6vqJR=E+GOHd%7@?hDZ-dy4nDocw&esStW!9Z`$?I!DXIKF@UZ7<4V zG}u*?>vumTc=g{uem(B%*cIx>hxKO%_q(L~*$;2`xvsjP%hYJ9SXSu2zey#~z8>G{ zAx7>_|M-{B$M4Wy{i5$(SCOo^JxJ9~14_6Z#djYbj{AybZq)Cj%=3DN7 zud5xh?jX|#gB^Z;pTym55_SJZQ{w{?5y9W&50o4}@;}%<3U5%R?;i$AoBXcZ6Z1ad zcrrDVC{Cq!x=q0}s|c{t2P1t#g?iFr{GYz$n@;{p4g4c*-5dYd_kq5c_&rSRwz$$D z(*_AjeecS@#gkv?(c_PL^FUfdcjQqr*tJM@(eXd%M?#J2C-h|kqiRH8oFwlSoo>?8 z|2YVHsEAzpw=o$uYL=Ukf>Ar-H-?*TL$Y;U_(K+h%F}rt+#*3er9C91i8NrbX=_S3 z+WyHIVc;pL$_HB~&bDz|ZT=;oKL1Ov;JHIp08{q?MFnnOHZBXte6 zdq%q7Bu4Q>=g<7{pdcW*F$yk5~fdD_WZMgJfvez481ZJt(}#;0?vVQ+tEJGeti)(wB&Z4LcmVA8x# z2OE{hEYe&W1?v0buAab8{mTfLXy+eKx?+xGpnksbv==qCU3F2f_hHWvxS{^6^X6~% z)1&D2F@M^JyNwa>djGuI;Y)(0@+!-5J`TF6W(2Li7?mR$ zy+M}8{SN`A52B$tdU`@*=6-O|?QF7go2JXwfq0)(;a~lfQDmR8e60TR^{fx^yHK18 zC?v(8lp3KP{Z1dMuZn;W;p2W!H>3j&BpL^wKKW1Y28n}7N8@on?#lW5KYCFnU73NP z-(-^eKlYon-TdjepOm!mGR^yvIz_C$9so0%p`$qZZ*QesO^QOQou;$FBzHPKcs)GcYFDPY-(2 z`}g<>ZJd2_mp1r(c5_gQLXj$|d@yumWWDhxJ-F%ort`5wK>C9XNNpnk*v-Xkvs=5( znN4GpC%p<$phA408`Z--=CnI2`Fr{MUYRq}ri2P{{oJ}9Qj^IqSz|L1OJ;orLrvAty` zEpfZf`i};ti=N{z$bEmX34-qg>UKa3TO86x6S!@@tsBW%kF9m_X|Mhr{ECKF!09(* zQqAPtzdulxCUz&DJNxN$z!c}>)4rv|XOo@krl{9h>pRpG_4&j4!@;E1;jc9PzxwQ> zzmI$Mp^bV(7L(G9(kHjZeRo{_9t}xK-SRI9l{TU6bpUP?Hdy0`HW|FFQ)%}BAhSYL zJI!z9-}%ol{7HM6k$oyw0KXiR8yOAgla3ZI`f4il#jYIWC?+hU{Jzb3XQs)P7vwuA zG$4VEoNCaV)S3R{=3o9co_M{v?RNZVvois&{0iA@e`cPqGd?(8^h zZ}gT)1-(ke#qS367FE$Am`<%q;y(_oRzihK$O{%7{2S3cP^4m`ZWnHS|Br94ifcdY z$Cso+Bwo{%pT~bDmH(;gD=1~&zc$hk8>85*anV-ry%qRvLm8VL)^4_rIm@;WT<-?8 z{^z$3)u$aaiw9)QuoJE^PghZKUKM!i&6}^6UrS8dmPypW5 zG@}LZ&T0}c?+ny%tG!Pn+#%j<(0O;{{H_;kM`F{A-;M9vfjrvEO6(W_Z;ZJvhx)q# z#PdjFoAbKN5^kGRyN%qaBlthM`^jJdy*t06W5Tkhy`+dY%k*F=ntmqwjI8IMyC{4& zW_BCPw~0Mk$4=}N3vZ9ecEa&n@!@&gvC)5RGle_MvaQ4R%i~_sqwJ_xj@%za4qc{- z8qnUOZwIA#F5%L*QL>j&eH&<`ja0?9Sb1x3wGOmzf`yyV#Wo+c)$nbdNxO}U9(Cz8 znfk7`Jb7dai%_!QFJ_ZmnNKQxrmLl%v>6k}({2r{sM2h;FXAee;T-#m#1xzm(@Q`r zAH8@*jmEp)+Th>!dgSE#=l&Q>q)R`4kKVF#@gVJNd71XdXX?!}D($B`W=db)^5GSo z?A&3U?cChL>6UU=diIx3FYX(7P1K5rkHY<8fQIuPN|7%hk3+78QVQJQ_>=bs<*16D zG%K9J+GFAcodP`V3O(K%oDLNpkH5+O)R*gPFk#uJi>cx+S?7ZuH8!m(dJB>{2WNwo zF(UP%M=v&fq!wh*e(s%2;k|elQM8Hkvbl@8{w zu@a_f{KH_l{8^_n1Fp7#3)Q^$AbGbA68-c29znY5qb`J_WWn&il#$|nJ*XK=!{23x zbg^5YZe~zYJ&kAV;?t za9f0u$exic?VdIIFOd;n4tTI+Xmf}=)dO@E_2W%cr8{_cx8RWUK|wx9{UtQ*cJaX- zLScR%e$tUjT?9Ama#!X1tDbz&#f_f)&ISq2B#9#U{lVW}3Cp-r*VoxHj-t0;%;iW2 zDe8K4v_$x@PUd}PU}qkQl1b8Z{{0~Pqr=!v&ngh{GxDGH1K$|Y^1tu(0R&~qb%(0B z;{nZ5%x^z97jqA466QV7)3gAdC4*Am9VrZ-{ow-CWJz4E!|6DTU zRqZ$iHM4&q;D~LHDeIUiYsJt9CDhA;F-~0jC_T1ZSv<;|V~aWRESY2K0`zoKe>}z* z1dQ>mgh#;_ueeN0ri>zx&DTP?u9Hi(qaG9%=e}afE@X~@3(%?mHJR2R*+mSQRzi6e z0Nqz0tmrcO;sboirk|Q2tB|1!N{Aa?C7Fg6Wg5Q%GW7v64K2nQ+?dCPObbv<;{oic z3y6mmK&I=)aaVj_f##GqF+0;{C zXdh#co_YY8`WE>`#N3aCT#07Rai1B}M2!9@#<-w-lqcj@N`_ts0PeFCa&8ED7M5hW zN$)fWL;9AuOxtmkD_9d)r+XD3u1E31Tnz33eE+U^r4-^WZtSwpI))FBsSAjsJfTbj zfIW2pyYX#u7m2fWBOyBG=#?}Ma)5?-O<13b@58%W% z-S2?rK>)C)kr4MQ0GH+i@S9`F|CJ0I>syMUYEbTn2FWx6;4~%V3rhgcrvNb!3QI}n zKn3WZ0{BUUu9g5Uh>J}<0H*T+ct5fvQ)!%y;DNZ<)Ca_*9>71jfU)A}X$8Qs2p?4l z=PWYcFa}^Lj*ZlS^(cwvnbPD=WPub6KQs$`Tz{V>5BvKEeQTdTlDch0E0+n%E{TRnD$4ymz+DtQ9g>3 zpYIAe5e@KtF(?M(;-*ljryP4)fp9X1r{JvDyB2X)0pP+oGDXan(h|>)lBojlwH%p7 z06ncO$y9;(Q;z?u0X`}c+6nF+PZa+40rMyl>S+PMb0NqdAuu?CJBk7JUW2&YKoCn+ z7Hf@g_vJ+ZnIbU%uKB+e0H<+f%AH42Jw@%+@PszC0Lat_#12xNS6IYR+*nKjWQyWk zr2Hv@tK;S~O7o?-oIY2t9SgB3*Y9)ljx>O8Zfazls1hRk}~3w8&vO7ULHWuvVY|GDYm+h(PhP0@yN7=bH%4nbbHV((&0en;_v?*N7 z?^}$cTubx60{Hw0F!vF$m;>PMxodYWz@|zu1vgIdg|R6&kDfa}IcuuQ5=Wyz+)4z( zQXxQ}c3od60k#lPr`8AbkIVI=Y6wYlDqut_6icJf$qzy9(sXxcRQWkf-wia|ls4$+y7sA@*jM zfOvNwn}pC)55TY2!gU31-bw_}4^%Fpx?;H&0Kcy+a>uc7?sIF!NpnC@XIyY|^*kXr zRRB0I1y~b6?R({(C&XEsmBwh&vqTC&oX$PFD+KVFSG`ZrRz7(Ru$lYhi6UxrbJl92 zE1df^fH&GFjzz?f#TD`hs9Yru?+}1&awuFwC;^;T4UlOG;LK6=+BrH^0LC0~=8trL zDssQAEA%Iokgr13{&xWL#ipJrOMagdOGtC8P>v=EABq8c&q?$D2V!`+`LK;gi!#$&LRI`?yg&KnTJ&5_`(cAB#cpu(UQUYTbwYrSMNGU z5o;C*neM|UaP29_=16iAxNB>Obz}!LW+A}mxI#VUtoL&AYzvT{Mu2!9aUQ$0em4hi zhA(06OMU7-d8QIuisIK4Xzu$KId@MOC)WU+hdBR2daiCMb0_epDTJ#QI5XNv59VI4u&!1Dtuz!TyLdTw}{JMbz4r=7ecsjJED)1@adG zz+d`8d2%=<4d4+R5D)KKj6V=(sv`V8A~zP{_oaF&t!absSKNF<#5r!YMefuGaP$fw zSMFXmM>s!gK)+G;v;gzbD0y;usS+ep#9oqJd3t48PhCrVOALrhLreS~0pyCRgNfK@>jTDDS>jK* zHT$JG6R5lf6nBTH;lx?*4TLrT!ExHsQ*KQhP7VsHhNK2?oE`vIXkomGswIJl=@9X^ zbe)TXIZ-xGdj1Zo#sp`+XaKHr0Cp(^_$V%3MA&+cJ+)asC<2UubZw}931b+5;-e72 z{UZ2oeTDH`1@LF6GcawgbED3qkZ_DOpdY9+i4bS}plaTrWx9{Q^g;T;i2-fax{}o0 zFD?2?&YmY_G4=}q+fQ#q) zd(?Rm4oIevFphPEGUeh#PJSmh&&CDe_z>Wu9DpB})^Vvpc;5xg5C0XBDHppUu#^wb zS6f&LRjZIAQx2!B0lvV-c^fF1DiHQU>{XV^RJxzO1nDT^>=({FI-A9!UC7cl_}Tf#2`M2dxq<- zbrCrA@3}tT5z3F7{}@~5^gVfcS0OFY>d!%gF&a@@h5`?k1HZ>IJpR&mNXn;%+dx#W3 zo*aB+i%Tm&b6{PB?IWe_#FBxL}29+lrTEbHi zfCJ~?NYr^foON^s@EeF&wzlM7xUqmuJ@qWgG!W_-q8{waI6EA1-fbZq^U{*N;&OK- z$lv-xPFe}&S%WaS2Jm+%yp7oRgvjxcu8~%NeslB`v9DQ!;%^5K*GO~sIWeIJ;0ubE zBvVe^Dd`@B62P@eWm*CJJ-24A0&xb2eQt<6An95v!q>*2*cNeK8z%=)x>l({oFz9F zlkR~=aBR|=v0Q%52ly5gmhyxgn+xK=eIedQ?2k|uWy)Pg=E@a;TPq>%FNF9HRWGEp zxXz8(R~`%Xw6?6L967}x?s!+6Ra%<+HHe?!@EE*95w`3#lfozgpm6$XAt~;VR|9 zPnjy`SDzy1C?vZ!2=b5Bh~;6!|{C{K&IS$OT^wmM4hroVB1QN zpNs)`lM`E27UMfatjd)u;;bG=7}q0Y%3aq90UUJ%z<&YAp6<#&aq=aqS3sWotev6k zsZ^$(rPu{=UWEp5ldrN&x$=kr@z2YUDS|VTp3hK&V%Si~PayRau}5xKT}ALJh;wAQ z^}`(i|HQ@0(m0BnkG|_V32HBd3*hrP^5oROmC6lK{|{Ab3sGCK2I)WI9FI`Iqoepn zu1ux%a=5tw0f6Vw7VQs*&$m(2sRHN#YOjLrc;6G+y%M1R+?c-x;I{%W2N3%ZJWDQ4Qcv1Caho_l_5q``cWQJw@bz)xtPPntN~+Ieb3A2TIrCDuCZAgf@1cm_}O56_vNm z?PnyEE22gOcRkOy$SZjkbw^UGumJcd*P`7l034vUsS-Y3`5R;?zfOl#1*<;XM!$)AOA0bOrtuF4K<{IF;3c$RN0r5)( zz-fqc!)@*vvE^57)@9+w_DYDA_lfs?z+9K+N+Rs3V=>0|0Dix|Vwvuft1p$Uluxi} zPd$tBi~u&gwzQ7G$<3(&nA-L}Lqx4<&e}yNlqo9KM#!`V@y}A329~%!)LOZ|LQKp( zcR^W_DFS2o7UTNRl8$omKmowX3J`m9W8M%j_iKPmx%(Sj0LHhuE`vC?rxxI3Zmub} z4k)(8eggg=FDv1?TP7X;0&{HnX+%p`pA)~FXX&X=TjkKehx1cy&^J2)JZA;9P!JLDWYy5!k+Fb(+VI@1(4^<@K4;> zBe-Hc<+Lrf=%WxaMeKXf08Xz2@a`S}!$@&dZHaTM0bDuCX1XB$)P^nHCr>64aHMF3o z+&l`z8anE{KWTg_&Eey6W+4Du?&C+@SAd(6)_z6o)eZj#0?3rB?}#&%ZShYw zWf}owipZDJ0GnuQz1{)LS?)d|U+80O^G+!T))Ge`wl=)rRNc$&QO!&rP}7AHDK;b z&jt%XwwE(6xbi^MR^;FnPbgDFjZV&-cp36UoPWlVDdOxd+j2$3`q%>FA!<(t0REz|WKR(};~{`g z;&K|1r8SH4C1A?};ER<|cWvi8)fV|v#NHLj-pB&rPq}frvN-n<`^>rXGY0qo7Zk5c z*ZMdybt#NNH_1A($ft391j43D@XkK#J<|1M*Am{h9YfiwQ^D1LoAoCO%e>OAc)bG5 z@xTJ_wogqlWjQwWEXb6T_gM&eEiS&Q06d?xb}|aza(xjhZ#)3lGDkR<3IImtU?x=k zd#+uOVt(nFZIwkm<;JFgWqcX}bahvoQviHa4Dij|xog2>HoAvNcGi~s^ggyDva~kLUDu5O??luO z@hA0{{=B zgti@Z4t*?)5ngqfmP|Q?01lg5^I8L9Q&(t95i<2G@>N`ZKm&BuwXjCP<%m7N+>ZhL zKxNtIhXVeCYfoJOFNHXJrLf38B{gRD$*=b<&VNom zbOGQ-Z0KYG(o<=E<32e<2);>rMiwWo@jzHF1gr~i{FF_ZO7%1XVLimTZwfRnj)htu z3c!(h!dRdH$R1Hst_0z$NT|CWAPzv()k5s8LY*mMb8nd|BSnBK9R$GmGDof6)j}RU0K_S%^J-p(u5#v* zvWNwxF@*=HnLH>i+5_=YJ^-J_0DUh3 z{;#lzrx0ge%$HIq&j=t-ZY~m{9vPw*AYxw2jYAt#NKQKr|MetwIv{C3oYtiB=q+#NO!dmpK@i&sl$k;oZ8w-I4&hqrjgJGb8D+{?X2|tuL3Y<5q0cJ0M8#< zmZ>LfcPQj`5c>`b08fDQQBs*A&iLFkHa6tSrlAb+_s4XY0orgO6 zGqPMaV^`nE*?;GP;$BqFL0kO8UW^M_HNu>EPT+G92ngSQ)n^sN0Gn5X?4SpFj#RGP{CHb65o&;r`Icf_R2*Qd z9zqBhR}Wy5T_G;v<`YQwZ8{*_j5w360L^tClNbufaYx zdDpXmYCxQM)pOtWnR!x~+T7p3$w?{zKA#%{*pewCpWtQ4H3Zn(8W5XG^^}X9IqM3x zayPhfQ2_E$2*1N!3-W~gln?N?8o<#9LM(+i6KS9IF9f!Wg}TbIL5Ld1T)eFSKEMaW z-xVO<4*(npSNEi~Ol;PDM%1*obuN!hnbshg27p*iTf(B;GcF`&LX?){Q-nW@EzW&k zh(q_Ox6jSPmgZaUnp2=G`*LYcE_V&V7viHDFy5#=Fg}1MMV-GOt$!swZ{t zwZ##RK_T?PsPl(8v9JQ!NbdYXoQF^W>?tA-vJ}R7sPi>BGA)I=iil0QzOn$=NCal# z$q`s1mdP0 zaD1vP#?AY10m>pjCDm7{-}gX16d}_B5O3BNs&bAKx;f6f!|C|n#R z&0j*2i{tF6+hoc5}V1PIYrJub-ai_O@cSNb4wQZ(Kf3&9w(^_^A0)J*_W$ zAzi-kI(0go)zfmh_KmZqPvuRwy|?Xrk(-~zrk}bPx4&HThcbGTm=RX#4c6GqFTa|Z&)o6ET=uC? zrbnh}BkHr!7DaucOIh~Cv%WplFXfM?Mbv$4ZcWT9i?NZPYhId}pUJ9|56y2iE`IpQ zyreKcGoNlEfuwz_^yr3plldbvj~$thRY9sePwQ&>_0sJe(v~Mg7i38v zUVhoT^l}<)X-1-V9-qY7F!1C6*Npz_r4MT+?bgw}Oph|XMW$JN`S-jn-$2bZ;<0`9rF=3~7f)cWGMYY*UY5ow>NIX-V+^Vq!}N-rH}g zcH{cujS?D6mP^*TYc_MW_iacy>+a&+@Q8Os_0Uq(CNtfUZk5m;BWsUjzenK z5@$brwcVmPyUpQ^<9eB+%C`or@wPX|yY=~H`iy;j87u7RjN=+CwZYR~VtCg~LE%v4 z{^ejrZFz8-T-u;f@{*=C3X`slRd(e$2L5nQ%h=7JHN$e5)br%>yMt|m7;YLwF!N-4 zboU>!YNMOlYRR@vlr>|sv!&Y8jyK5Q(#y{OJIeOusOUQNTQlXhPSveSu!U&69A{+t z?tw{itvUbmnz=^%))|5|CTf3CoS&>>~XIvu-IBh~ou*)y}ay{yx! zp48L1zPQ$$r?obey=71w;n(Ipcz^%{!GcQ&ZXvjP2s*eE+;wm#Kp=R6yL*5Tba3~< z-Q8UW-}&!8`|eZwY2T`@Q(ZGvT|FPBPv7^suHTidukAqL?{eUBK0SiVs_`HCQVWSKpcBDDOPeX=htM1)PxgPjE7YuSENA&U8%2PV2qs_8w<%Bmh zdLwPj#W%d(UIOLpZ7;CeaasD6=;6Ir0sA%=*fHpFy5_T>aArCi|H;1>$FLL9#WVks zBxq)xNMn0SX5o!1XqB(=Y6rn@<6BXzBc=L-RPjv^*y)eFL8=cn*;GXK_0;f^C-}3A zP%*>rX`DzZ=gbe6H5?S7wCv$e^IS$oY)a({RTTeh2JUvDYns*&7Q&epzg@*Obm2M0 zD~30291$y@$<09HTGXi?u@h6^!D!iYF74X#p0I_k(%U=68c5PYUUs*028O zR87w7DX$T(_0#2|{yw++eVxIc-Ik~2m#Kpz+6nw_#gN^!*Q|i*`!J7ocy{PoJLSz)&cS>OJ_hod*`&z_e!SG^7^vLNi)6LAOS z*{ z52G+sVFE2;qR#vs?M2&he24x})If9c3=UMRd3T4APKltXLb^X8Cj1*@#*%x2?y%_c zLDNIdiVejwSp8pu$|t(wn>)3Llo$nfb=(_W8R+c)aS4rSe^~H1!ydLo&d7V2kmhK6 zUTufAyJq)EV;T7^_%c)T-y5|krW#0wS)LA0y^dQQ8rEP)7#w`_mFeNcLC%bSpxa^( z|K+>eBKe%V^MeiZPkKVwRqvnb8+x-v!q7Izr$e}6emZ$MySQdyrB}f+`lzwyUzF{W zoFjj1(#hTK%3YQbZ`JmfmVN%@TdutUESxm0A_Bjw+Iu^mXN^0y8;L!V*BPPjQ>+htIg<9VnxBMP zw#hvxj_yCpTi#`}*$Nxo{WvY6TbT|@(+zMAQ z1+5;c2V2%$5z3F)X-D!?o$+7UlapJ=_`k}4Ht!AbdmXkL4gcS~LX?IIR(%e{glWJh zMsHo^CDGlhlc^g0gcH~Cd$Y=;e$0H)9U8$3B57yO)2q|PNIZ9_g7(i^g<_%r9e3VO>O?1SVxDkCiwlzZy8Pbtx(b&!+tT@^D4@sR zi`8|1ey=#|xBjPk(@S6?x-RSK6YEtkyS-1>OMJ~!awl5ll!r;`sIo=VVg;pZ)l8>h zLXCAPy>;qn7F#-L8t%kQYPEymyp1)$_f~L1*agO=G6+h1{~z5ovSW9TVRlI<4cldM&qkkt#gQFI%`J=tV8&^bE>P zMJLsAe7j!t2$Sr7`r6>WW36{1#AD?mDJdG@A3tMJA7!ysro>iqt5S_ATtzhe(S`+F zO2Z4yP)x1}vwA3M;QM&DKHE9?8L73&`KzLAGSP++pYI+x=*^nufdxZx|IW6;7@^KP zjI?85S?yhVg0bk7N8qL<)zfa$45!@aw-%@Mf0mpikLM$mGT6+1RzR)f6ZH z2V6}pWH&NuR~`w!7mXEe_@a9Npp*vKY4rC6Bh5o)dAY(zB7Oo!GoJf+;)@(K&Ecx2tVvV!cawc!qkX6xxK5n#$PZ-m zeNZ;W)=z!`kA*rkxWe zlG3%EYeBc9kK{6ds+v$GL2V}^X21NN2M2>VzCKYoZSuhUSdSi$RVB)tntlsTi3epj zdaOazRQ$4q_Wk4)ZiT&KzwY^paoaUXJ+xGfe{#wCMgO>N>2mby`HIQZ22TEZ=lObY z^xWutM)xl;<1Q2%_=!qcW+k6JR$xkr;QPRa*}bB7Qkx2iJb(LR!s`1bo=aM2jiTDr zv8)6l?*Vx0Uq;@Y%t0E7W~nL0JA&<%eYT`*Cw{ER(s`<8 zH~KQ6l3%fNLq2;tKe*|bF-ZC&{?yscce1~+QgFH}os%CD4D|>6os!3H zuBF1BhHp-M2Q)sHAyh0q=h52ITv@mKP-$BMX*<%jt?WOCm%82mQ~ujmPFn_}8f$J9 z+;h6%{}j{5O35-A4VE&akspVjveh3HwB&`YYmQ=y4Z0*jg$p(~D;nGSe79Gumj$A8 z5t=;=*)E=i%)i@)D;BJlD+;Hqjo@nX>CEQqt2j2-PRlApb7VROP zndAmvPicBV4{b|9-Ug?>u)XVJ_H4st(_4~~nUydCjrtKEL4~jZ2q-%~efR7++W)R} zI3c&C=}35pxae(k07zFmpk(P>C*RnD3oEi|VNU|bv9Q*z;#I22rK-KY>4?xg=B~VX zWzbD2`*&N@jn0|MCvyt!A+}ZXN{@bqmz@e;_V!%)QEdOBr+X3B$}8SrJ;5=VF|5Zw zM{GDoSZDO1HK|z;)!irV84CfI@lpoxcC!d3J6oThqGW$|+sv3NuPEtj^wUVcpK1;% zw6vi5V@6!d7M6&D62lV#fICwi=s;|2q#bnn!Km}ArQ*UMUmOHYN@ zf%fK!?7mc@>8EAo{duTa3Ny)(bFsKi_EOcmuVm2v+d7*ae#@dWhD|aqDzz57m=<`pNq@tvaq<%E80w zFq`*diD~VXeOD_KpMN<3t@5Tcthwe&Fc-W0TQb7y?GIwJCM}tsOGPQT-2y856XfmN zl;x&33Y0%<9qx%M8ByG(O&1n5doA?R;Jhhe5V+EO{rn>7c*Y=Y=z$&>q3}mD0ab=$ za%L>1|MlPsVNh%enYi~1pB-+){wsHkj2p}Cb6=eHQUWN*ae)8{=-v7L1|Sin8mJ+V=+{Z1g+7AzKYAr{qWO z4x$8wR6dD-f6NpN0DmEiq1C!=N!wlh7=Sh|XJy@ZQNoDVSSB;c`moRsk^8q^jS86w zhtc@)j$O34a8aDZTGvlnzN}qC)}A=J z?pvuG=S>$vk`5hpz*`cI$Ae=&9{zg=Jptp5$8cW9GC-?TLu zZwf|Tkd`tGqs`!5uEW#{Ji>tgoj8K7Kedg^^ zol|WAfT%Xco*GuiP^k1sCBWAg{qITWxS?mDS4V0C-IqBKSOMW-tQuUWq73o*PyoZ~ zW$sVf1X#BzVMPS!%aduMPwJIsG%h#dY$_WVKLy5RyZSRlwvc+*|M|O3%6C+Sphl4^ z{YZ>xvhb!O8hq8xmlDb4XlmRNIt1O=-E*G(Djx@RLDsiK37>-0&*5&mo|5hKoMpJF zsjqr6B*-GH`{DXf10lTx;j&Q(O~u3~TYW8Uz+wU_{e5)E766EL+>Lfd@~Q!7-$C+3 zzyZEe%_cAfIxbkA=zK?zkMySuGPf0L8LCsP1j;+#g@j2My$?PPgFd^Hk^eel2c z`^;;aJ64KdtAHYQ)(Ej^-hG!ql}&FhulGrIpLo|uemxQ!QTG?IJdjQM z&we3po=NgmLSLT*9L_mz-GoJi}Z3PE#}peR0d&1<(Y+C*=KS$$_v5qD|vUHs?!amHXt$#eNKY zQ5qLFAUC*xn;azc3%Yzt+ykP4`3#Ei_oUI8#N-{OG61p}p;OY%8MqzUK7i(^@C*IG zlWTx`ucnBV-7k7K)`!nAtz{wA$eT|=fSmltZ=(i~Lp-x9{XneUN`kL<)wH+dXEtg~ zaYoo7Lh+cBvZo@>%6yuIDrk)tre^Pl1tdiKcZ>__zB9!cOiGM)AoSFW1WGQ2y}F7u zs_E6%P3q21rn!fc>|p0OhS#V;gz_qC&`kh5j0oBci0hhYfphj3zlLa%b}F-TOukr) zSkgtIFvYFOOi!%$@GxY3(Tk8*asGkR5k&Bf&3dDe$|hs}k6d>z0Y*mv&2eKwH<+c) zn38Ou6z5OqLr>=&MxciYuuqk$c@v#O8D$+44qK7gac+`09)apfO)qS@7=}Nz>KhsA zY-StnJRlC?&4rBY-G#K}0tTv(jpg?uOh5K4pf0D#H7RN9ZgN2&IpHLQzv7lQ7yE&M zI=FjpzK)4p1N14st1a}8(%9J2UJBS{8bD`+W1^*w=hTw;aHU#ZdeZ6?1%C`NG60%J zJ7?+--%RK5k>mQvb25j~MufiDW$WW=SyeTjNFF@uuW|goi{#w)uyWR>QU^S>yd+yAKVs z!kfzyMB`^~I_#bQ%Aj=R2LXr{=VS2^OukM(urTog6PL&_O-ZGquMMtr?a!djVpkl5 zo3ztHQYWQO$XjaPebbn?zvKF+q}$Tf-O#-e1aJA3J{4S%)fGjS=oA3{F#}-vh)x#O zajy*GjY7anxk)k^sR~O6Y)fm-Rp|#QL`VNZFA z&B?2>m+5p|aD80-=G!Z%x&aBk0VgdY-z3@!*#Cn5S1X*$3|N^exZG|Rn%^VZ#a-*4kW9-uL^CS+ z9=_Q!HvbjB3tPj(mprvO!}YN#ym3_VwM9AK3QucT*2l2MN+Y-v+nJ!ky6<>qcfbpO?LB_UxPM9(;=O| z!D|@umwU6z4523_Vl9MaNw#m+b^S1%k!*~jPt_O^-n%AbqUUJdrLcWS-Ri>IpN(ai>P;-<9Ap^ zv00_D$@zNKYeQ{>hrs8j#PWmBw&E{7(pfj=k0gGp7$`cxpNcTb5n(+XXgSuK(R$kBAe__Evq?B1Eek0q{R!6Hx($UP^#(5qV5XX0k%{?k zAeo3x3qqW;pxojP`i)20nq3f3kQq59MA_k_Ud*arC1vpT9&@sFv&~7L&)pzsmfG&( zl`~v+{oGW`FG>4DR5w#(iV}jSgZlH%Jf8omTV_4rX6#mksK>ubx8s%iFGhr*_=(?y zM0THnXcR(}as9C&V4QqfN0a9k1U~jg-%K?L6v7Z=89t!hd~1K|or<%b zO`FSh!sL^Fzgn{BMHiUQF8&+^SZ3MGaO=^b4OLObt)n6fd|9eg-otPss6!pmnfvDz z6Ncv!{AQ#ldX2W@Nov~)C28|F@j5f)-jyvpU;MaK;*YWXYyt4%y=6g5HUAl0jyPpB}du=|1&-!m|BAKswMLN!? zN87|QK=nA50~?Qp&UN%r7mcQkM_fQ|C;oXJ)D6de8h^24VVMzXqk)~oKt8~)x$bB! z`^>#C-S!)nMQ>|+&4bk?yqvA@XU0b-*>E9#y?Z%*!>>&i4Y8;95`GdHoO(!h?3S%) zZWcxO@>HXXVwvy4wAuOvYbK)7Gwq<;G% zo@Lg32ySk zIbx6Am$Almm*VPP&&*&nzQ-ga;8>@DvYS}SI}pz7Z0g12gT`c&Air;+JZwTiqG6FJGShnXIpQMmCCvYvXzRU?j^a~Id)U8{0@B)YVUsRe<$DlWHr-D+^ z*{J7>%g+?mf6taVYatB=x>Xs1VHm)$6GT3*(FY~0zbE>nEEO7R-***0Sm~LAiXn|8 zpx4hpxB%{ch-c39^BY0h+b5Sk!iiVBj$Z29Nm;;x^BrBzWgl(}g~^Hp5)4N!8kp{b z<_i~f^%&Pl9|z4p{unygnOzZ|HjkfO-`ea_v7%@0Ncm#Bl4Ap8h;*NA5V8bTD|&{$ z)S>qL%UBLV?6Azav}yEFr*qR|lArgYJRL=Yb!hLeRSB?37}KKVc!WRvAya~DIvvUZ zJ;ZND5jq2WsIosomt?o+X-11HSWJwN!wOmY($g5{#_^J>SQgz5L^2GnM8C@OIAv$B zr(1eB-$@ezvJ!@CoWVV~mgn>Hd(kEB-}$Bnh%Qwh_YL2sjDkP;Ryw#O7=A* z_*a5~&dJAjWh|rS)Bfy4>ybb=v;sF=8-7h8-`nH9PUAvliz$}Oefov1TA|r^^UT>P zb75#wSObo~;PjNK0etA7rWm07ae>8vjXr+2s{502Ig_9FypZLmRQAiM)Kd|@%s4_ohEYS4+^FCV?4fZ;QWo-qKRU0!>AJPOI}hANL$Z<3|3j@=a~qaj5Hk*U|KW ztETes&LZz?q zwoUUnhDyRl+dsGe&K|KW^^z@Wa|no5ZR&nn${m9$uPqnW1f-?cwBL~^8CmPi)l;2` z!T|zT!}?4LT<;E5vQp#+)vh=mmPaC_o~2}*FNYvJzJ>MY8JJHk}VCBoEF6i^4OJU znqZG;ZeT)(NW?3r!rbcZ^_b@r_bLp{%Z1pjC;BFSEKJy+IvlOroi>x4?7D!UD-ZuN zi_T=E=NTR5MZaG`_O4$_Qw(madj|B>mj(?CVvbiKXL;uU3G{>wEvFvdW%ln%1|`*$v16u$aPit!LY7`Xu;&xfWM-BVt7i&@8YJvI8;R7z(HT+ zbId(s{90?b>vCO@(ySAr4gm668Kpf6{mZ z+GoPo0v`K1Yl2%}@BXqplD(z({PyxifhP3hXKmtGb>gG=sY1|M{=VfWlJ+~9?8Er9 zs_lyt>uksMH1)8N*W0Z@(Gj9%6hZ0IcMXqZxS2N#c#NT+%^D+x{-=H*$hqJtY#V%; z1xJjsFshlyk&4SCP$Z0d47{dPZ72Ly74pd*X+ALd<^Gf2Y`nXT@~7oLjo&%S-Ulr* z6R@{jU?c@9ifj&7j0lPV*RTvcNi15sS#!EfUdT&(j* zTdSy8^DT~o23*f)1FTbCXXw=q?9NaB`dYl?ZR2{o;zl{P-#K2fb7?Q+Y?QN$<`=x5 zyk%eg$YJJCw_m`@#&H-7Y79K=gncSzFqeGu6FQ{90gmmlI(mmFE{8w7?xEikx zMn2S&`Ok=)m{n)YPH_oa3rYK23}3I@x|GIFZR`lvr1v?o56PLz|J(hwt-m;yLCY&- zWtggY4V~JpS-93(X`aB$w0+XLdZW~^Pa2F}C#*Yoe*W8I+3U|9yeF;%H2t~pT_J5s zPjXd5GqZV~FvmX7^%opep1Y|PYWv`36H1bmpZr*^$1ddSoHq=fFKOk-R-W9=d%1TN zuzls$!EOs+^tC0^DvB)4+KIn`K9qS~uQX3XmC?Cb%aq~#QNF3GnX8yImQ|%*^RL7e&s8hiFL?ei!~U$BI+`F z16}{w?ON&TR?e`z{vs=@yjyci?pl$R1*5lLv`XhlHo9G~%=Q&ZlHo)%^B{CiB#%*L5_u8*enWQ2LsO6~O&!JNx?BPPfu;R*uUm{&rQaFt!-VAaWvTRmrZ1rp(l; zZ+I)R$M9bI=bjG;zZvxIj4s%t>WAs?*{j~mf?q*|yxY!Th)^apie@L0K0aQ0MPtYHgM=y;~DI1m6 zewqu2rXJWd_>Z@0qWOSEiwd&}%msX!>>R9Qq_iyin!Jq54|d=#)h#+b@_m8vv5ea* z`mki7a;0)p17%kY)-rh(FOG47x|Esr(Wyx>$ZX6U+8Nn5-@(X=H09xgmg?E5CR*^w zJb&No*kqHP(AbCaMdf1a$Bui>xvo9T`RVqxth-z#KL-hx>wXpU-*|2T`mC0 zS(NN$yKXULNiV4$DU4YD4CXPYr^q`x zn)IhqL|OGs(@2)#B&DmzkMqBrf;AOvFYtSDo8xOPr9tAeMoidxxR8!^`H>$vRaCTb zZ(V-?%`N1``98;Dzx+q%uZpZF8&MyWc#h+_jx=9sE4{upS|?*R8kB}VRWxW2uDs=~ zZV~pp2Q5#p1no~srIy(ss(x>grH7C#&*ubN%M{2$N^lsg>=R+tqy7Rqwa@Qio8>9) z!MwaUC6im?^z_eR_5ouE54Lswt8WS@i+ou+fD74I9mR zlyLkHZ1kV@S)}ULS$bdXJ>~y`jS~L{HY#V*&(s5lje7sRA30YT&OLfCIv%2nn}l8V z?J4giqcrk%=g*VgXOW`jieAe63UJskUhIiDumdUHY!~bllPJd1gn>_VhE4fn_8$C+ zLuq-V$kvo)CNyeoS9@yy7j6{vKXIedkssua9~6ByJFbGqZM~Z3{uRlA>|RSFosIlx zIUT6k+wx2(E&I3HN%Hr9%O4&lrlc>2v<#vpz&xsWcXh@ekz7oaE)Y^TLm`AWi9#|j zyp*rj2Gs;iu9q`sgL|kupN`*A#;2_JGQ6M09(<5d^@|_j7iofBi^i`#g_AWoyLnyj zQ2Hpu-EWVyG|c%zR07U?BzC^Bxl{f0C*|vAlYP{}OIUm4EbE^n;HT~MbSAeeu_B@eKUS=#`Gol&x-CLd!Xv4qr&Y-tSbT)E&!- z4-@76hg+7Pt7C~rTf3kRYIT1^5N&*Q{QSb|1nWuWGo!j3^7Wz~T#S@1ER3E<+|WE2 zFK6-ZKb{u5-rZoWlg>8c7fsAp`s?YH-gIADjH`+7sqa2?D^eQ9Zrl+6SLuPD1$YYo zm^1yaf;VRhYlj)P-G%+vN3cVG&o4VF5z;r23oBaxU-)TRKRo<2i5VV#YQNtYktp7O zTJ~+4US;Q^mGU$$D~7!}gKn$g;T>&LOVyt-Y$hv|;pXdMuYX{4wiRebV*o>;rTKxf zBdC5UC8lD+*z;6F${^Or^LdI5WF7Z9D{M9Y4o<1-=z33?rIyt0>+L+(Wt&6lmnI=2 zFZ+}01l(&SOhcN1Aw?^L_6I+>?jw12^DA|Q*f@|dp}eGM;@HE1Qph z@`LZ+kA%0wIOxEOuLCR#iH!-{5#R?qT0 z>6HEW06))rEa}DU5eeq2X)FN=8T#%=xih<>=Rd9j0hx)5k3XirFI&IdIBtp9Cw($y zKu>BxGVgD)7H+%Q&6(Cr20va6!mL!xBWEp|F}w%uo;EULAM(Kr7azcsu|AZ_%-&ue z8Q2wPY>|Ck!g)y1G6^L|Zrp{YH`_4uLaeynJSh!>TTtCvv?#h#t*`!gd|M%yci& zpB{bYE5c_CJ;QK~uaKwt>d)e$TGI2Js`^nD3GJlu^}Uolu1-r&`A2--)*CKs;!Y@w z&lPUzT7cpAULRxjS%}}N!#+7(8znc>+P6HA3D1wLHE#D8|3!^v0gmzQQoT3qX@>W! z{2JF1bN;IvGD1*(YPaln?EJ-{m4Yj(-gy766O^IHH5Kpl`HM4Mlnt>$KdBNbd#)7} zV}tE{14T3?{I;|Hlq1v6IXt}Q%-TTTHAms2^T=HO8Yj{;!a4DF;XcrLBkQRgaFq?l z@arW`|0cU2cO%cWv(Mr7lUY7Sktx-i>84meGlpe&$TEtn|JC^k#p5kvpP=oJCqDWn zc!t^J$VJ5atwQ80UrK|^RC}3kvn1Nt_wtIYhpTE^FSjMCdlaa=9TDtdEs=^J7 z3tj-9!jvn1v%~P3{22AO!VJWwt+r8})sbYR8o|efm>nAFu0KfkoG#e=@0#B}hlf`G z?*3~`O{KKf-)^-_970%a0FCRcylMgL+n{#{Vgn>kgS`rblvy+M$SA&VZSjGb5o@&$ z+j*l)(f-9Q;%UZ;`-XJKB@!-<4s8l8e8%yUA(a;+O$1gQn>H768l$Y^nQZu~ON?5r z@HyX0VExmD#A(@XJ7pVND{wNCC=LrA+GN=JNwQk z>#mKa-Cp&C=h{b$Dga;Vp@vX^s3C@*B!UeWVma8fX=svcb5ozKAx|8>$-IruWUK(+ z8Ye$U<0Ude3!Ik@g0Z8l(_xCe+R9h662)5s9E-%fK-5+%F-N2xHy+_`ta@ZPCYgkt zv@W~yZ#p8$V)zi}14>V#!~{qYcWDr-h^UN?5S`=@#*Ji4OcB1dsHDiHd4voJs!3#h zKk~uo^90Z7AW`3{b!q3}nY8=4AQ?nHXk%1!SXP9QK3a980eD5O07CXC5!3{MHH#nC z-gHzyO6U)FZiEw0BlV270ez@l&VaWGOQI-NMu3juYC6)~WUH>G&n zxx4CNYKEi;fGJ2PJpF4fbZe*yxX4^7JOdEH=`?3ju_Hlr`X2q9_y~K8vQ7)kFn+uF z8TYv=#IFh=#cwXdC*~wPv}Hx)g%SB6pZVI7AP{XUN}g^T23qbFsgJKo!bLv`LYoKY z0ooAi!UXVLXqJTz-W1SM1fnJGJ|+ihlU8R3#nb8{xJGq*5lFO3?!co-r%~3Wkz;!| zZVI$lYDTtTfR_YXL3J!Lt(@|bx-;Sqtgmx1HXUS}qnIe?(#wrYXa`s(l)n+@b)}RH zSZe3=9q16(S#Uzy3v7w%fZ!hz4mp5tK3Hj6WX+>HntZlp;V_I|b{lGXbtJwS)q#Fz z$PEdw|CH9&Xx$V8SWFJ!pyzFvlu<(MdI2|H!aekr&+MVT(U$z!NwWfKR6dkkW}Aq7 z`jr*CFrqcQTd|#-Ucl-K!h8q1)$iwPvKDp3 z%`xJh1vG%qdLT>@$BbD5@NsL_c$Sb4o+0%v7s%{VErZap6YgD3(BW?-w!9t;`;uPg zj?}?7jVP!CI5%S4#=R}&-Nd~Aa7xjRu|5{}NG(JyQ^DGY6FR=Co&@{Z@$Xnt%r16b zj~1$t*eCkH8tQr@!^wReF`usn464bfQ-;Z_<6$7%S}193+p z&0&cW+x|Tro3(z%KfQ zys=JE_*FRY@z8FkoaVN|#k_xqh!)UJfaK{!1VnN^iLM7~X#oU{ugI8j0wp0LiX_TT z&e2HcI2m3Wh}$4UPh6CahBgjtv6D=k5a(dTsB)Z$;w|QF00yuU9l_M9yy!;6hXB_{ z6t}GzFw(C}-0s9dXd}w!VLyrMB193Wws|TodCd81)=%tMdiTK&`xS&Ri%bK5@crw{ zjc%nXZ#w3UEiSHkz&r7gezoUeLvn z-fnpS_M=w zK6+&9A$R-;LRlr!uvONLopWbn)?AF<)MO9&q8IP^dEAasD98Rw=W=;hTpLOzmn3)Yj-{`a|cf5 z!1C`|xW*1#+Nl@BTLi}7>{XtDFL3kxS;}_*x!R|s%SOYYQx&NL!guOjw1(!b!+x{f zNI<2yzQoBirAH@@HadNy?`EtOz0^B11^|Z@G$ZPB3YuGHfKU`F%u|Yj%JQ`YDI7wB zd4a$iHjZSheVSH^dae-;ro9{7lRBwz3iT`}9Fa}mWAFl;hTG~DTKXmfS}_rLz99S! z#+eM5`%S*_$B~Je{U{hFs3a-`C}IOtha#RoB}!*4S|}!ve2e5yDMp5IX)y#?3oG^b zNBOx}1GxtYu?-D&mHZ)2o?BPkqqtBT=sj!=rM)_ z!XmavnK~YV5Md%?pJI}yab#f_DCY#r+~iWNa`;-&?1X zWEv0O6ZO3){iz(mo2eBu+MA#?1H5i-fg*n8%b5|En zHW_%-v@E>T#<9u>__tIXQ%q>`uIFU}V>=s?kyeqxX6-w)!b<=!;YBm+rlnpL`$O&I ztBc+h*u0zP)JOO$5?{N|FoWBJa=g#L+yqSiC8$yE0OExK%y#~ZX6cB`L{ATJdYW`& zPRNWw;A8*Wqs*i-a)gu>T~hsFM+0*4J7KG~6h7-+;JKo)pDF$ad6f_5W0$o8^Ic%m zu)_mLalgIg5jILYg8Ru$jGeIn8$=*WRwvJC4EUq+h%9_@sf0wBXEyn6@FHPXsHe*d z_YLQsh*0KdN6cT58wh+^n-R`LWrmdBUsV7sJK8;p;Z1Uhbd?e5l(3*KFkBbF<)ux}GZ9QSOZaM35y)H9VglCye=Z{8M`)b9Qi<<3HwEnF_Z@ ziONdkD5A34Smte3&;=&V=?%cSUj#B)9s0u1!AEnLX$@#&VYwe7;bK%}>M-bJ`A=3%|B9-)U~ITM)1VDgbaO z2bu^d8E~m9;$o$7Ap~;$dbphYja9{Zx%hbgO7g9SAL8|f<%2=`IN@HSBZvsjOwg}{ zPyW8Ju^VbfMHgfU^4={VMA=&X3XsVV9A1(CSTU%B(wT4bKqn0h^irsp2Rg;m&Nr**6@jo z+<-T;HWe*?XIzh1g0QglMPZ_GLWj4E6*WQgVS(!m7#nTqvI8?tciFi%9~OmpXHeFw z@d8V6AN3&oJsIAiWP0R5f7y%KG1l{e_wv+$cTy6ST{RuAG^1#{yS>zhTr_hM5`d~h z3@>&mMHZ}eVw*rHX2=<{xM$2!tS|LPMlt>GIN>#SrNy+u4Q0sdw-S7@Hi0a_`@s@# zB9a2y<~|O(z+{Q_4h&#=bCTVAn)Wur!#12rS}6nG)0Vvv2#}+nmRfm8XRTbE3?(ng z_U*bk5^xuj)rp}*=oBxQ8q}3a84@gVIbC071G#(Y>aNr z>cysXEiOc#tOr7T{R8lhh$t7Pewnu%^P%cNB1_F1wUW(1h#06+CA4>9$%vfe zxx;a=LT=lTdqOCz#tN9P--ml^hCihOzhTnKlXZx2_X+_WO}ALYrC9A*qo(dUNha&m zVqRQk#f2jq6T5$UQ}NT@%E$oPjC7a2n~K`RDN9cy?>+RX?(g9PxEKL1Y@tn{Kojzw zHb-?RAf$c$lA+RR1n6r|6$OuPi9m$+^l00TxO+cb zU`##y3>nJ->Z_4>52#8)h8Xr}Wg>~F3v>tkAw2iwQY$=Yz$KY3SU|RZ4KYwzii%AE zb4yC9%^9GLw7!Wzx_9Z+FC8%h+RGG>AT0$fry+&!sDKeU#~`Mr%sgcXoXj*z(}0z& z*>7qdS5&fHP9<%~DBB zV!U7JjW}&MfccDf$&ZAd&JL%<$u+Wn9;rv4cTCVi6n9|^L_pHq%~@h9=0Sf{Dc>kQ zQ8>dGwwxwjqT5XY2R$#YYY*%>-NRG|vS*Vv`K6SyPWUM>cHq4@v%EOG+70397wvW@ z=*AFH{sjW~hSsxC{ss;}7Hk|_KwiX4R>g$a_q4fIj$N>x`o+0GL!Ar|oO!xLd`0qj z#8AQ=Azq*KN5^m%$8`C4Zzt5SUvYIxEWcfmNuULC-BuT4Gw|Sqntt4Q53Cjr6HUB~ ze%zLJ-jY3wxBzyY@>6BM4}4&oF;aReXYIKq0iq>R`E5JZlz6N19A49m=E2!P03v#- zS^7e+PjrDse*h2Pwdbk_)1CbS*|%v`xa|vWoFU-IBT0{F#>R($8Hka7HiNJ{dG%WI z7B4L@AdF89U>;w%$DjdN8+os%r#~#>%)aUCdeC-H65`>DP$aoc=mL(!-4n^Z{q5w7 zlvgBd65WIhn?(f1&IR7x11go$=F~X%0KDuoNU*o*dp=TtXBCOcSgt(`Ap53)F-!lA zba;lAIpeOxOw8NRm$w90-h}=7-m41e>#Qo4MPY#{AL@I+$W*?R=+9)EzD2;mPW~HF zrrZn=|0j$P+CsPAHh}@+Ckrwepcp<`asrQ&CLVnxr%#qupYlYM8L!zf0xNDW0v10h z2l3VD|274EZwvth#uswx()BDXHx?oHkk^Cp3hplpk^j*hV`F5ZLr6}BqDP%xCg6|cU?bU zs3AoQOB6{}tZ~iDHjACQ)K*S%(^XEs`K(8=DgrxEQI2)^yO@1**Zb`eIoPat);_D=I++*+<-nwYP!tsWBzvcZE|{Qv&@#@PY{7Q ze&9=H%3R?_<7>x+@{&>NUB{EsQ#C}k_xg9$NbeK<)o>v%DU{57O0WoY(L8s1#gdAUb}&N{l=bt-F(X3r59N_yjcT7?_9vWiV9pBYtg%4$z-6RiPxZs16l{R(SUCA8?cT<>LOBAz0CIm8Yq4uu>ao`|X`HpnzU zYob>hH`Z~aw^A5BHKw_WlD{Mtxkg@1b$5ebRj`GZ>COFM=bJ`Q{FRgERnlyn0Or`X z~CgN$ldi2)}Bfbd;*h%hp?}y*dt<;6SW9H~sAu(#0(Xp%RU&^BD9~6utSz_aIB?odj z_u*r*-d+<6+RT2GWg~jhEd4YcP$3O0J^L6rVzrFD)o7_cilxsJv9+XEWH9l3dgp{{ zS4V5R*kFGOe(G6mbSFZpas0xVW|aN`yOy7fI$zS3-y@JC*yrd>+S!hn3ou}4s_|}f zN)&P)_LT>Vw3~#M4+#*|A5K-@g%R>7=KZ25&^S0%*(>53oZ?MN;AZRCFv>-R+g8<5 z-|Rc#yKS5o?HpA|{O)C1(O^{^t)VyKk#7M@Get7Np|1RR-nF?{2WjEf!@s$vd}$+v zJPg72WcAVj?9~kK$ZWS_%3)(!sfBaqnVL5RS)(_-iQ_^Qb@$s{Ci~~{@6PHA=ytb^rCgt-pD{B2_}&}FO8|pit$XiRcOV7i=R5lAhr#W=DOb&+tJ8_Q zGU?U*r|0tkJ{BDp3*E>j^oJt;_p%rSvK?5nHXKR`=Zd{~xM^)&EV9$rt6zuhW-pld|6_1l@cYCiI%OfY*iI?* zKiE5~sJNmwO&4y#ArM@HLvVKs?(QBSxI2UZ6&!+VaCdhI?(S}dyHm*2-`#&t_sp8L zX7!qjzL=YRed<)zKD+9B-{%3^6Y+q$C^DJuj$<};MFoM}zMxL}>{R_A$y7CuG~3Fj zizmGF08hNiN4!edgDQ@9G#33|@LDU2mJzkYWCjgYpJTQHp4QiW-ia(3GN-FHH^%PR z$~M~lEvDLcOUH}zs4|Z z0Wum(wy&Ui%f@e~BZ^Rct6sQ6Gco8wT`q zviFF^Mhw`5$D3-4mCBBteZ>B9a*r+M8jcn(dy}ydx8E5NfCBDH-2Y8_T3h??I0J}} z`TNx6d~&uMV&69~p2P26HpmahXaVEr>|mkk2#&H*8Cir~aU^XUvcrrymQMkqh$pEx zis<&W_Cx4xGG07JV+3G-u|hkQ!*qr`|ASGv*TGjWzVvm2G1rCW2!4jq=7F%=q#MXz zehwRc7f)_C;S*aEMeM&TT^}{T5SO2`uF~Xf{uRzyKg+Dg>S%$GVp#IUm_0#n2Q5t$ z({P$}yoO(TNy}Lyw+ks2*UBrSamxP-^J(n=Wa1Jtb!f)_ zD6IIwipZxzU!vD)Gf`Zup&m#Oc z$s&~6|7#W@$0FgmFCvNOKLHIih{-t2fSsolvMo*8irH&&gp+IwAp`0@{0DVefnu1 zLvK8dd-~Mesjy5(7Pha}OF?%UO9sm)hmhZ7uPm$U^SHOWnUwvqme|FrA{RIb5aKGJa zb&xtDkVrq>9!|dsd9ji5v9v}!i{5uIEat{PJo5=kI=gx;wym&VDFx3>rWjtI|2bB< zvforFofO}&sMD8Lj3HIMyXivUZMy0L3*}D$1PnZZBSVvm!ZUc8b%$s6G!r*AVrlxs z+gR5JimnogKGFUKMeSLMHqqyTN8B{~S6>Fx^0NmMtmG|zu5Hd|u`ccnCAf z|Ebn>p2#%q2Yu4Q^e2yb^p2bTL#edhfwO1Th4&{9JIz4pt~~YKCc62@j%`JRgD8H% zKG&P3wn_P6rbM(YvlXj&wR_J8*ix$fM?{kz7!XEX+n?yV&3a(cn7^TQehKjQYkOVuqd5EQ<^2n->tx-Ub?Ino zeg5Z0;x-=g#f6|$%bVjMx(9pf>cJIX!}6muw2IfG#iU(dKb&B5=gI!MTLsCYhe6rk zox$R|`AK(ZM8kEPcUvK%}8%fjiQLVyCQw7qNAjDakjqYDOM>OIBU>RlQzw|kiT4in%6Rqo^8EOn=^{(Wn=vz3->C?0VQ z3@>ng{k~?($9A5!+W|8H^7kq6wscRAYib>Do4n&X?{c~(L>tcxg1@j3bEWYdJP*P| zC!D9P-u1r|Wp*bXB$VAw3Z)+HR4gAl?6LZL-dq#b-<%)JwXraB{c;_(1g*o6;r7bkTs`zEUy9v5$>b|DA=zjj{e|m&=TM2%6i1)W)B*@>5yHGf&V`zbP4Is@6=%6PJbU zYhAcm&`4)ztG14XNiQ#Tww4QQ*p@OGxq1cH+fp9Il;;^B_j;xC(R8@v%w9^tOL&+w_9YBIk z;CGIB@ft?pyGFPv7ip~Za<%DdY1Bka=G%X|Dr;h73;uXdFI)7*vZr0T+9?^E=vGgL z_Q*0HT${gK%e#K;G;4!Y?sX3j*tEyN>et{*&TvPDt%lPcH((c3i-0F(Y56xjw((=ns{EaaTF@e)~O5a=_re_WA z@+^gtcf)XILA?CYC?1`X0KdJ72v2Dpys-oNz!&k*7ujxs@|4#6Pm8<|Z3;~Ey84GF zFu*{U8=l&DjQdF|dh^#OJC!lkh!nQlPqj1r(LOy124jb2JsIypL!WIzC))TSMuMf! zVL2YUK$(R*1*9`5Jam^qX4tX)nRwFel@0=oI=gG?=r5K#kvcuGbli&DJKG|MdUCM_ z$RX~bC%a}MV6LM^tbuu=3=NnabBk$iAHeDXn|a?=6K*%@IUfr$0JJk!wnBlY9khdk zU|z%tKyW%Gq#zN8Aca$!NV+RscDDm_s`RrzLVxc}D2s-A8T;I^{U|~jc$Pi7 zim)|~3_vS|swspX&r==>c>-ko*!p0TBjj>GXgdBiy#Kx@2UC23s4@-P2NuAbaw4ry z{-!48JGS3KsjPnt*g;{|I)i#)`q6uz%2wa6_DzHc7jP+JF}?99CVUQkn_!mC^VR{| zPZHOkfdUt6OC70TTbQ{u)+@VofYl9iVYbR+rU!6cfpKcg;DlxV81*ydckk{qAiL-4 zChJRfA*|CC@|VgGNwkd7o1cMRgFSiF#uXw+EnsAh!4{sirgqCeaP5UyrisWHvy?n^ zaKOp$AUJ29iV@Rx0_evwtZpCrZUR{DDy+CJntsrbft!qT3%K&@&gd z?}8N|N4|hg0hriYX{LJNI9Mx(i*&CtZN#u zv5)jnj$;e|B1Mwe7%D7C9U842{^=4C2fKCbUgDz}r8=|T_nuJN90TdGD&4~yhC*LV zUYz1JMO0u&%Tf=*m{DIRP2V${=><9-DesS#{`H%|?F;>gniA-~8qDsbRzRxvhYpmV zPOCy(eVY1x}mJ=w`&l7yOc+v|=5Is;* zDWHZhtV&JHXD~`joSZa#XgUTUgpycf7`UU%wB;!WcxY5INE*VjIz)Pf4CDf+`trbA z1WbEcJU2u&%)2l=IQDi-u%VSuPT!y|mw_GMkh@Q;r<(Zzoy_o#R$48JKepa^g<=W2 zz+~yTD35NHr^-IB)a~O!?qhI{d~jwP&JTlf4?;Kq<_=g#^Ge5d-kNM^V`6jvTGuKm zDB!A1;6+3wK7XFgOG02uE_`1*D{b^c$dwjkI@K`M#6zavJG}XOpkt;33_d;i1=VhW zRO?fz><>OYk}#G--6C+0S}t%CLKgXs@uS&Sk5e5JAGN0OlkU!_U8RRNz$Y+mRR5F+ znUxbdY=DfgXFJM|JUO&jEp&)Ubl)fxkkt#f%riN&becv81a_#nA?gF*>7ay4u$)yn zzba9p+#@H_;6nRK0WJfWyC)XJ>{IewSRx3gk{lgSFE6MA-!;izt5Ye%ip%to{P0Yp z(kb&sD3~SYio>BAo3O@kB@9f@TMJRH#f1=IoF0b}=V;LIG*Rp{5rJJW%4^+s!Z`&g z#EHU`>mHQjl;F zpmyNTTOpH-BTRu)p^RkL%=wR6;cGV$#mO`kgBA?ZT^JK6%wZwLdVfUlQB?+mdL3jQ zmKj8RLwOv6N3UQ5n{y!?WxOqjLn9fZ{S$W#1>kZqE_QUSs1gpL?5pi3JOJTjKR@tJ z|2*p>v!Q4P9mP0yg-A{7tw1{#4(Spf_6TT2jI6H@83Uc-_=UpP#e!8xyj+h#q`hzd z*uBB0_$3&>8io80uUegb13(&0157-*@yQUz{k=K{g|8eG)2l%2?0Jc~5k+aLp z8LuIS3%r>DykkK>%5TO`ZdDFH113VTeG&oXfBSLJ0@*JC*W%^x$$*Jz%mG#4Gc@A* z-A|qv+lUshj#$pyMI8d3N_Kn%DFzpAO(pgE?#TG0L^-dg*wZ4w=;xLshEG-VB&g4z zCU#O}-@xgbABLQz-@sNR0~gX?q-JKJAMTAiw<;H5twYBb@5`bx?=U)A3B!tpNyffq zgb?aC3s^3lXqo5W4!Nw1IO(%xU_ezc)tPWW@w<>mEEO}7o+B?YgH5Y(8o-ut-SJ_ z{aS3(^$nMCodqEP0@x<%irtXXoSue|j5QTf3}LxNMS1|~;QpfuCmtsFGG8Ae0L{0{ zk}edjDX$N80CdA3e>o6{c&Vc@iXG+sNk&9~w20g6WXKpP&Hz9N*7ZSR+Gg(45rcd= zkh|e<0Uy+%c>p+7BjYf)A--Br5VsCS6ETWgDWGo!xJDUmzyej{h}!K6KAlE`^Zmu7 z--*r*FjnF$nUsf#n|RPm0FyY0(U+uTCHO8KlliBk-Su4?Vje_(jt{b%b$R2*f56U3K;hTKPYK_Kk>B?)>+2kHy9>1#S6I((Ig zKrxil7w)i;LWQkoKD`6ZbG1BStQDSNL+iP;5|k}VdO%kwPZJ&Fwim>rOU|21MQW0_Qf+DMa64ojtH=bw#7!jfD|601(gL+Jwa%RMUdX%>&$wAr5!Ua+$Wp$J@ z_O}SH}boD$*+@PmW>7yttz z`}h>j_4*MRLNuU+>n%8uY|IwE;yY=H=!}oo)WByX#I8CeM%%V%weLavTji{DC<4+@ zmwpBNoWO2qyjG=5ojx5d#P8aG=W}RKEcBzLbp;P|r$h$b7T~lMx-W*c+k^ZbhvHaf z@$V8$(Cd8R(m|hO4YgrApq~=xCzElv zio9ji{L~xw<@5H!+Lg#l3Qjr$06t;Czg$GWY@APj&5Nv}ObgI#*Y_!;KpB$6+0=h^ zgwkhgic;;wbwi7-ZzMR*;8QA=gCi5#y=TRW_JI4FUJ2J}oZVoEy2H!sl|EA^V1pry z0S&UY8*Xym;j({)RV@fG0w57XWmXx2rHvhJ0Jl~AVh|Sjw&1#;jzy} zEEOKwLFVB+E}?DA#73-Es(M?E41mfj(F}jYtpUt~cpNc)J?Ka0bnrT{$@h?v-sG%( zoxqI)7*i9)FJDKy3iwm%1FF}0C^dC)C=nPqy zu9)BRS-vX1f!F+zF)XVr>HJ^ zs96|>m4nl%goVnY-HJ~%2`t%$0t~1UWs4&rmPTyLWP(>4==L;nod%#0X0PwIUDdDK zKp6abqn4oDZvp9uL@O)f1m7Bw-< zv=cW>{oiIhv-OnvVIGb9yb>}5jah+{OIw;+v3lY85~4bB)lI~n(=(x)r0JjK;1=hvZPDVmpChO zcUrisW1%H_z!*0w(7O4`(!y&@1*|TX<0}VH(ns++l`a0A!AF4Hw@wEz>b}xjTOrQE zP$F~g|9;*gc4{lOXXms7fbBA?o6rMX_5g{rt^GV9-qI}UjZXOf0^~17V#+UA!H-Rd z80jtxb_vE~Ja339Eci}}_d-sMEPI$L;8z$E#=v;eK|@-R4}H_G1Q z^IO*9y-|z#Kth2q5dfZI=qKe$yYP@#h~pUVT^1%$kSPP{mjn77W;%?@ zq|-ZEz|v%4WXnlmv7l#zV>{+u4yG3^Tp-N~jqn%P$O5Kp^7YB6C4~0LXDU-+*yPE$ z3_FN)MvZ*$9K(&UWsNbJCUIeFhOF)Ttd>;Z(>|1A6?~ryf8`*tkdOQBR`m=`opqAp z?e9}3U6PaGTE&y2+APb_-c^Jy;^U(l=E<4mlEN;U-`|s%lYh(HESjViX(S2>e$Qf0 z9m9J%lJ_9Zm{1zqU!C2j;x|}`csc`l8)P1XObvwvr_b#AzsD`8Ol>s#W~AAouU)F# z6nX0!-HWyQZo3`3@|6|sJD(9+DP)hN+xuKp9<7A1Lzo6H!u4J1t{#8*M%c5DZlmtC zOyQZhj^jh$*|?iQ@Z6j+LozPC2q2XblGzH7Z3yA5N2_JZ%+HOC)G)j*_5gf1VqT*u zxdHCO0Y%Q}0M4dRv*)?`aREWK^QPCePj2Y$UEn&e^f)mSd-WqoX_V1NJKg6du=DDH z{l@94&o`Jv7WHB0VqDLqwa0h3IE(OY-uI8<^F_X)$71g}(-|tqk=U$tW=7jtEP-U{ z_pFLrCKrTP6~-~JSUigYu?^6xIQ)ot=E_S~YnDcFUOUYKs`a@%2^fPhRrm1lln<{)B?J3R3*%qmWTzTg9`An=T_N!< zHRhgm#cj0pXzRoc*3V^V6MS_hKj-3XLdP7*I_0Y5UouTRH5Pna;JacDSXbsc`nG<% zZcO*{5!(E+smYw1yBRef+PiJ^YcZznRAZ-yT9Z5WPk^2K3;>sxhJs@_Z${N~1agaqaxJh)yw)()@t?3)*J^g*a1 zYauR$Y&%4wgm0|w&PGnQ)SNfO<#xP-amJ7IX}4*&0*hhd+qh;*lV3fvL+{3=BdY20 z)u^7doDdSrV3ppyKR9yDCYJh1V7Z_G?^AZWe@QnAB2@vdw_~j-za<&=XrjGd&985)`50!s9u9rxa}CWSmOb|C-=tWStnT_3NwYsbALh3? zJ-rwejcY+{EZ*+RX2R;DpitdSqp^t@?^ar^3!R7Pe~fNB3jRZlJe^T1#->UE)au_p zq>#MgE>C}>uj}2pRFs~_;S^4xmXpjIL~5JZ=CtFTV?Wn4m1Whr$Q7{k7<^FC<>YuU z8VtG6a&*h*$9vHNC0AWi9)!Wxxx|*EZr1f@8Q~87>Q%z&ne26|S?9fE%UFRiT zCKmP|!TViDEha<~hM4<0TgOXovsBhAsdy@}@RTugLjtFHXXrN_9K)(CXs=@YzpCOT z!gRJ><*Ese(wwB!FYPwUsm0*6`<-HM$8cEp2K4TBP4Adpnn%#Iy2_X9A5k zKvEd15i!}I`P}KT|5>H4=(ykdKhgMc=Pe`l{z>DHQdOFq(aB3aaNzZ7*DfqMNh!0_ z9dd>c3!)?}`D`uz=L{Q|Q08A=_GB9T(#6Rg*tZ_JEyMq#2zPl8GQu9K{O1VUIiiO3 z>_1Jg`Fvaj?4+)&H$`3itob}=w9ED_E?lDHRT4Zq@g!>qIV;&ewfk@q{jSeqNur7R zP~S`*lRmnC^xYDN&PX6NgYnpsr|760{HNoDaa>X9;CI3F18T1r_77o6r4wuLpl_$2 zcTz-cCK{g+Zy=YGbzJ5`L@tlOgO|sdBvPB@=W+?%!u?z?W%g{U9~@kfjJFI7`92=P z{zup37agJ;U_Gx;S4~QbF)^^b*%TT=c1FI}hQJQivZto`?}^$8TYHR&ut|s7a~P{A z+{xKr_T_hb$=0bX{?@D5_t_cyT_1!TGdri9-bAFYimvobi=zirn* z#bGw4v z@zS<|Wd+<%-7?sJh!$g5Y!cXs3^KSlQ74t^6dcf)I=nF2EvTXk62hZyhQH0!Xmw<$ zzo{ay7l#QSZcnFU8GSE{SN#?N=fm}qixnc?@C_Z1mVJ4NLi&Y$woqo({b#pKt{{7N zr=$%%#v-Njf+&z-N(N-KZE0CS8ZmbWHj!rEY`xj9*x#+U+4+Vk*>-(}eXt#mMTiOYuRdJ6?lkqlwNLad4fv`Pc;^TZh)TCYH!eO54 zeTMDiiuS}!FPZF%RR4ORh9O65xaK&a1h-Dckxdd7q7he>48trLjcojs0|7hoU)t}5 zCMU#(g9c>@BHB3V{>RKnSqo8qD-Wv$hRyW@Ur(}ki!OYnkv>^k)DBr=gC5bdZFO1l z4N5E}RXp^9ce1UOioHHpXoFmIU-Nx`l^gkK1kGMtjX~?q7B(2%&~tmghjpQGxz1dF zmtx4TAIj|SF>aY7>{?`MN=){3&=T!mj|GhDh}bnWaxsBKt}0}gtIChf%V4@bBWlh3+Hu2#o> z)qeQsbn_FZ8cK9XX;{S9)1ERur^A-7B3@I29KnNgnzCrqgMPY*3fUU)+VXbe#r>>0 z1wwdK%+@k2-KXtzwakI7aqj0IIuAY&3NBo{)dZx;3i)%9 zXzMMGy-}U5Zhek$(qai@B(<=86plm9sVUkp-_Gs~kQ#5{eVZ&Y|4TBPoSl}OPkJlE z>Lve)Lv)%h_frA-x2m@zQZNo>g+nZtU-jyTRSqe1(v4H;4QlL^=&!O=t*1gIb4st@ zTH%hI4m5$-Jm2DY`#0h+#j3jBj}PjV|-Weqe?^^=(?b>+gU%PPVySuow!H?zdvyf^MkPs z?%%*pvT&3;VRw%Q(hnc~wh^%m41F&rIE07Chs@s)2%}aNZ_LVG+}>gyx|n^K#%G)Y ztPptNKNr@KmC^W$i~G4(CHo$W1??n5`S7p zB5GMr7m@*^Q<{*r{a%r6sFRGF9NRdfg?%t9{2PfVVEaCB92L#Grp)oXv99b5)$^j> zJ4wC;|1|nW2Z>WWGIfvV%d7HXLcMgj$HGz``g5Ub8qs=uWGDW+-rZZ-rT?N8S?lzp zp%pB;k4nqlyX(EEQ5p?O)~#WNM4SicJh&pzw2X4a{G4mNetqEUYgyGseDvEYgDtoJ z1X)ux4D%J)YeGUl=|f@h?9fishN$Z|AyQ#-FaNL44--|v34N~07Dcnm(|^vU9d9@| zxt?mP>hk7tUD{)wcfP<5sYwzL3E+2$*Nu?wS;HSI99v$U8GV~NT7IV>_Y*Pjqj@ZU zVd<$t=Yr~vN--(3U9lnHo}d{p##|fOC@>Myj;$bmFzg=BT0e37C7^ZFanvm%<;TgC zhH0Lt-egjdg0b>)>hFNpCVyO?&hGY3l!ltmD02`LK79fCnEZ|h#21duQ=AFv>9@bW zH7DD}`W{Fe=9jm!+sY$e4wRT7Ivp3UkI&P{gLlBP)Pa8=*11=Fs_k?8hvd`C1{9FR{pe)ycxIPmUpO=>%~u&`WX&C^M>RuvpTBiNGY z(Yrm+bW~QK$(lD+x*CjqCse|Y{uWq&-pKmg6&H~9mbZzSpL=$@20w8C$7bR))k^oc z9GJA2Q(a-``u|uHs)^kkmV2jSY5|Sok6;27d9gaG{|rpQgwg+@o!G^vsAH z3w~~YxFa(z)P&nrh`jqObxQYYL^~7hP8fw9VwV@{H!Zw<%h#b<%zw zoGx^%Rk3z0Gx8m!tP_oMHt=Wl5g74RJz_Uty2~H;{Pu3$`1f{>n$*ev3@OGFc*DkJnY8M0V{(Xr_a6Ndi_9hK!S10ydSAq~z z>i-@sXlkY1CM&UgZtR2)cL5jmEEuOggg?tEUgI!ZWayS_!rRrw&kUqu zs%$}jjCr@XFmch+%7&QSb5IIQ!Cug|Fs{;cd4|k{Q?wvcDl5< zj`CXj&B6CL8=$3Gcc_)br=NO%;T!?goWCcm`zT+x*?EibF&52&%#LD)<78oB!%yS; zHp=kp0XN!b>6(lFyzU4C7DoZ$=4#aKwO7hIIa{~RaG94zjAD+=>kb#$(}qvgTF&9f z4}nJLju>E}u+aXGXo8yjB>3thB^l}KPPUvKjqG8=mkj^{_A}->7 z!wEi4p!l095{ zLWONi@F8nuN|i+~mps~S<#O3Og4sJVG$E>uk@pKUN<5}rJZMXDO`b)s6c7g}PLI{p z(y1w7pyZ+zp#2P!(E@nc6X2oa*baypxP9USp`d4NQpH?`UkPay$p$KD01PAY${+;_ zWb_!zldw^IY*Gxh8Hdor(aCtuikrjMoV&|#-O()o0nHzK$0$$c_T`j`M?$vHL@f&d zJshZkQIqH(mcGx329jHDa9d|)5k#scK@Qu;#kD*Swh=CyO+yCG7J5i-4W1#l>LW%8 zWCrseSxZ^E#n$jGf0s8%wn+WR_|l)yfl!SU9s7O&lAQ1I!4P2 z0{nNEV5}>LKkKw5HRF1omtknElvmOSnr_(x5(>EE` zvflQ#`B0r+yh03oHE*D9Yh_qaCW}U@0VxActC)!Q2MX8jFeHRV%f4JdiqC4nkMx6B z7qsI}lBc3OrgK>4+?Z@pA#D4gLL2gX$-NI}YA0mS{RC~-Geia&05mF`9cBpwTIRv( z6YsB3-DPWc&qHjBKbQ0fbpA?Z2(AZF3yKWa!w;#ak>-e;;0gs=MH~K*huO}M8sexg zF2Wp=mT@t~@Dd;g_TIoX64TA5l$7OkE0Hf?@PM}yB{w!ed}z*HB8(XbIk9+>ht^2l zS_FNmVPcGA(9!8@UuzWvt^m-0tIkkY zol*D;FqhwGQ{e#=x;v(zqFa%mc=<(_6UU;94y6&^e7ym-EQzA*3d}uAeU~Ym zS-C9MFB!@e+#lhnCsSi)`LQHz=YlE(UBUGAgqLC=f)nr{%RAxsrHf^cseqFK_uKj4 z#}>~%Lwf5D;y$6t9o%?28pRfy)PcWVlLo5?l|3PV?<@^=h^fvC2zH|!&_ly`0~^KO zaWxpL2uZuNXqwCO_nLr-9vH{Dez|yShi$1ezvhS{qdC z9!&CyG*vO-A1UAhi_})K%u6U=H7H;K&s!JH^$REBF2y4~tTd5!ZP{9DC~Kg&=o!=O z$6Y#W5_GHA`dSF_A4kgp*UW=I0hdP1>O7 z&q-_8po6EcyW3vPk&Vfk%P^;+ZpSj$X=qgJ19X$e+e65IQMn11!BcL^Wh6?2wVih#R-s~4^#O66} z8K2Ie`@-l&K9C}|1e_bPO5nG>M+!8iFs=fXQ8R&xW{?=Y-LDRyY3@y$*t zZP9UDq{Ix<>I{3Y+@ik>tkP)p70P4!OxO+81BN-E`|4J5UlUgflgVc>0yi;SmNgB0 z?I&=L%PC=FD&StwcTPXf@y-;#l;UGxL*P{3sS?V(W!Ux?ZkOHO7PX3tMks`$7549??si&SSGUl|fYQ*;NrII#MkC3W3R;P0}&C?g(Q z3zDr*z!f?uK)O_5v!Qnq6J9`aQZ)kiAZE=&1>lg5dvgh9%n4R;;csUGnLMytw6fqa zMUPaf)o89zzAX8RgJ913u4%Cd@WBrQJ{-#X80N(XHP)Iop#&kGu1QhhL%1kRPE1ne zs>VmR2KZr|o(H;!jQaXe)Ip^jn^}ruMLIeBdC-d%AT?CXKeUcn6X46Wb4pve8DnIU z>TE}6x<21Tt7fl=I5ea|Z4uMW z`97|Mqe;QLkhj5a~9QhN_LRbfw7`}CBx3k74(+}ULW0h>dlxT2C2(oYT6Cyx%iz!X{1;P zs?0>$?TaspK7m#iQ6~WN-O+^z6B3wsDiy=WUYVKp`(DCPxp$r}lJP5ur)vmd%T3}< z`@>cnX2TVx)%WFixKPwv( z>B$qlFTfe}u8~#JlW6tfuPCtYkoV@D-uBakQb?AUp~sI_Rxg-Quzw3;!7? zN97FOsP5RA6|}YIDSaIt0C;qm8&v6cF+xmv?~%8VNesB(d32F^y~FzRK=M^u97EVz zP6;JwgZzFp@0f}&$*H_P%bTfhkNVpN!XK|l{4m*}8-|?oWXOT?hbwvwXlxvPCTR!h zJPn4TX%cP=7M5!+38E~KcUBjKu{SigqvMiT8U}RlQXKa>J}u}1n|Q%B%C`21hV-5x z7OhGoWJTG?f67?9-IJ2ck73Mp0pDrDF2t{HW${Oo0w2jRqH#^pNwuQlCS_GwiTIdMN)CY$Ngo1;NNeFkNX&{V`dLH7)_M2y9;dt zVt5`flYFk5XfBhlF0MhS?i|1v2W$ng?=9%y z@C+L8hHFV?;fiDl8E?2O+Lk46-FP&35fMvPJUF^k2m-hRm#nmPW9gDyNaL7%A4-zzuK8pg1IB@G(eoXyR!^jkn@d|nZd6TzAopza6g6B19zk^;OCp@4*} z*83W0l`3PzO#G#JvJ)}na++CjnyA-%87i0phq!Ahihy-+wip|$ZuVpoMO)WALL!y2 z#YNyP!FTG>x%<@0c%o-Nn~spbyyNTAA;o?pQEqV+-uIe@T|}B9|b5C4}dM$V3=Lj*SV5jS0Y}kyNb*cgeff zPHQnNopXH9B1XKwC*wobsRcPD*f>xf3ltuPTbv-Dk^kV1Nbz&}c@M9VOb57>jlV;J z>$U^d^K>_n3LLPBygcsUOAt#yie5Rc=tiUi{+nmPZr=Mi0&j4XYgPThz@9ust zeZ3eSs|ieFk-nDQ;tWq5ydGXFwvQv~P^TohhA=cm#}=ov$(6RwH_(`CKcI&=VE^%? zZ34Pf3Ia$P-8NXE({r6m3)~j-pQ4a)@|H5daK_*57GfogwMa3N`#v7zqU=;BV(5K? zyg)!oE?}9A&Wd6DfzAkv1s8hdM=>4c`sUBlgYmdB=ph`pLP^>!yXKQZ$a!v;*66TK z*(nEGUq;AO6xm=EI2yIL$X7&-i= z`O>3bFzI{qbL_pTrHA%@2wd~J{~7fA`%8pZqF1?MjJ4^MRZnv+&3%RP5jRN6MTXAX zTb`IzA@^xZ=y_u;Hib~th4rD+=LDOwJONfU4eUlg-%(UUHt*Zi47V{Uj|+~n}B8x)$H57Xkf@+%kUsG%|yq+&8^2|L zb@gQE-E)}l<21BL?^#lje7ic$1a(D&xVz@KZK3-oG@zL<#an517h%o>5;6UG7+Hx5 z?LM2fDVDqWwydyXVYomoW>86J#d(|^=)kAZDW@x5xo>mSMt|xshn^x`=i6icT3y$< zczoYg)-GOEKTdyEY*;@aVH58!_!7Ku0j;@o}#|>J4_B^*60Q(#ro>ib7w7KV<9ry9mx(4GKtj zj6RS6-TfY(gk&w`57zT8G$Op?m@GFFXP*f>1TyFGi~@Xh5A%MX5A5FrWO5m?PPRWr zre|e)(uddTx5!{{bg~aqXu;X*;)Lw}%sKK~4ZnZ-hhC*#mvrslazPG6YfsmIT09F! zpN$J+97xrlC5rBXd>qT=3{un4h-T-qZ)ZpOYdbJ2gcude%@ObCbt@6=AH-b@$ZUh0rtrt_o&Lq6vRBSEtpf3=c(o;Njf05KVu?5Yg8<{D z@F3I)!%H&3DyYnCQQ>#KmyMx>C_$vaLABbdZb1F3NoVqBo#Xx-qr=b4>N2^#<&Nu1 z=<#1}#k^B0PprLsWzd$Z-=zi~w3-Z+!~cN;&m{2rLyuG9$LCGUo5P6(|y-0#}+K-hwMn?Jv+FrUz8CRuKwk(NoO zrni+HU2$Z;%zW)UzNozQbt#Ns9$P39v`g@nFz)+#?raC7hfq4^fy;*(f?0) zijo>Tknz9cDQNF*88bQ4yxx+24@Ov|wMMtB6e2Po9DQH-YF=2U`PolAD@NaGmft!0 zYs>LqYZ?pJ(7T=s%-6MX{H%IPPu)1wjH1EuQ@-)lu6A?RxzBtg1dyug6OAB$dXjCn z+sucR{0|l*1b1oNJ$P`J#yz-pV@=}>Gi!dq%&awcby;%iysK(Idv8HSIrW|Uw_*$D zy*6X6Z*Y47dOJ}Q7FCi@wrn+*2&$^x^XFk!t&h%Bie6S?*(J$8BM_5jUO=4J+7v zR-`OL6<3mkRF3fBH^E$22W5}vJuybN!vAl`M-nppC-oKh=6 z&M8qRwLM`pVv{TL$^)>wkV_S;31V9QNqG3~RZl-H?l8kr`|+5^iBiDeZ4exsB)4!5 z9|z$9arsWQx0!12|M%x73O6lr zAo%C1lYm{bZGVG;I&X_{+6q|dAq+BwO~Gev)3Puq-ZNSOA+h1twgfOJhqxPxcbBP8 zxq7;-&hv@+#Y&K0Ng zDMM_rNtTMZgwMoXj5j>vq|)few6yVl;qHkFjV^2tp^^i+iH&tLFln0o^qGHJ4vJP&Pmu|S|D{{v0 zMrvaK6B>1}ZU;=|x3t}_c<5m>c66`6>60@!ifcEb~=f{R?QR#=Q@d_X^$SsUxy^ zG-T#JE07{{YFMd}>hR{vuu!4ZarPJ>7W!0OE0HLKr{8nxz4HPYSEa0QIoz<8_mF?F z05Zi}6Rbj&kSzxVC_X>SU@gd}v~dD)6}SA&gNR-j!a5E0U5jbR_2G6Mb}#a)qg>r~ zo2fTgEQYHIQj;@Hr7FH*qN+^$@4#zJc;SA(MEq^N=ZUmpX^pdIxvb`(eP6D&H0h9E z{VPxtHR~f^*8}9t{nAp!z(GYHe^V4ZO^clS2?hn8;>^r|e-&$C|Lw47l1JOUxXeFM zygO(Qj2mABr=3abInwy9^#q;O9E0^-^L|W-Rz|?*JqbQI z$&zG^$Dvb3ri8P*K0jKJT(v!Yk8TVIc6pla5wCe9A<}!fyyGjsUV}e0Q2FtGlI!}= z^vDBef_pq&T$D{qs5bu5Eugu}pZ;7rU#xy}bl%+}doUx`8uLfZ`;5A_@!noliOk_$?+ei!GErKkVAzQm%ikrikU z{F4bY)}<{il!P$Y%sE{bu4$5lY|S#{y_onf{iSGia(s<01Sw?6aBUe1*|q$sEh>T7yh zPWsCI$VAoXGS~a&zvu2~u{C?`vxCPOqyLG#9AH`hp=^O?do|?A$f34p)4Swmi8P37 z2lKz@s^`?XVh72_D1@{H+p>h6ZH^xOAbqi~DxHn+>4;3%xSGG;u(fAco$zlx%{M>T zBG};hEcX-WK+N6Ip);0y?B{o_Q!Z00LC>iivpP?W^K@G}712t+4Aq_+>D}uLhHTz@ z7SC-PcFqjc{Vv)L@58ruysk%X9E^QEL?%ahuT>CusO_0b}i>++a1m`1yO!0*X=L zA@Obt&tYZ%`&KUhM$*ht4{oL&r@(T4Bk{8oJ|*$<)Dn(XRb_wbWX#XIb>o_~1j{_f z8ghf_9n(n^%v#2sx^|wWXGhp9;wo5&=(|-Z)V`CWUB6#!Obv2x&i1{SJntCbd z;DsUK^|n>_arEou`2#B!l$0{P9o#B@HsIixU?CFzu2kJ7C{_{lmv~aVxvVlr9d4s} zyiy%LkfpN8A6VI0gX_DK+bU6QQvkl}PtYely~UOm?(O`o$@4sHQM7ju_`1UC%oJON zK%H;MJlYvP;mLST3Wi!DJbnf+aD7G z$tcC245kM}sFQSA6?FX9nF4t+XDL1aXv)CNJ7-K1j181YO-WIXGJItP|wj z>bKEV<2)Uw&R#?H!!JDJweBF=u_7N5&)skRjRx2nn>{klGLva{E&MiT+de_la;u%5 z-PQ+vhFmJ@T3c2XOI2WLMhttoQka@4tWfcL@2q&kWf>?2c%E_|9hC$mDMwhkZ!P z&0a*`IGjded{3UKv9iK%ggjpZJbpgxF|rD-3OYMZ_Y{Mm6KgGEEL`6z$3Cj8Zwa~P zwdPxhYd8M>v`irB9HY>Zzx#3fG&*eSKsrK?>96@RKhe-j%e>qiZv*HnYi7Lru?P$+>jOTN>&rVTiw^jem$MmzAW^w+UT0%8_ zs`vR3uwPU&+~Za?6xz}CD>%vPWqroXT6**2CIn~oAR;-;o1vVWTmxpZmu4`;^*YBC zBl%OsKWiy2?an5_@<08r`?r>}xDv0=e};D+$mhngr<%qu7CQEGAf545qR_wAnPWc= z4%q8YNaoG{`@u*0_gF3N(x_UU(=q=N&ilZ7S$tjFLNLLPDpRg1c6{O2f#%f2mL@+m z5ja0Sc2R0`SiRN0^}t`dbW01{a#*;3>G1TpPLbTTtALh?ejxg-A-D` z6}hl=g({7)@v|_BuPeU~ke78Z=H{&~wP9>M;C@;E%t5W4ouv)!vQE9$jaZKn<~fdn zP9UqkCc(MlSCLCvAFx|;_H;^vxa~#wvp`_Xc|j*0_})!r%kg>S7RlqYBG!NKqmKn* z-!8IzXZ$0uIBZqRTC*{0NQ`*5ni#H|$Wu0XFdyAolV{C`6^IJNT)y}ec(7OgejMM5 zl@994DXy5bviG7tnlGZev4s0>dfSW(Q?9ssSO*(tx9XgA9CfgL=ISf*NEC(dE+uq` z^Yl`=z!OTlmSw$yGvVg#d|+ju@L8!J-`cmy&#%QxgDYQMk+$4x58=u22W8YJ{5cl< zS_OKhPA8IgpCP1T9>SXZl>q**G zml8AN2L$#R>hxoe?J|HX@7oY5Y#mZyQ==W;xI56xy`}hf#W{NU2Lt_Bq#qmSe$`^Z z6$^rp8er-lLg_Ne!xf0&{$MJ5fWZD%MDf#h0a>ktf0G2bz*(hrBhdx?iEZ* zX>`PK#|`0y7^E-_1~blN6e61HqDKU;StXb8P1B8q)98+6?4f|6R8zL_cS3S7bulpe zK&lsy=|fH^BN&^9AOAz>*H^j`<6&UC5FikaaGH-iqV;`B7%QF6tIS9$r+@-`?-S=V z>K46fMQ}m(k8=*UYvPOuK!!$O8P!R-jif38)Y0|o)5*LS6zNIJZSqeAAUz#7S#$R+ zor+dQq~0m$%bN&yMHc<3M=MnjnVE9$!#pW|d~CWv^!T*86*HiIq~)F!feiR=j$zD? z>7DzP1X))9wmyagP%+Mn8T5iN-uVaVA`f4s7O>5jH+ruF5b079XXIj)yv^DvQuAlW z4OwY_^0*8gnQxsam7Vz3MKyxo2Sp9x-Tp4%{eBy_CQJICJ0@-uDq|-)%5@shH@=Uf zpyV4GPAm19;%IDv_awD>4?W^C0W%~KAol%>Zl9qvO2{FGhGhDJ|w0^q?eLQHT zxye~tE%ub7y=bHm=ybwtl^~;+#Ps738z@R*p@gKg-b*ZIezeMt@G{z1Rmw?wP|H%| zpR!e({zoy6+#)2Nfbd$7CdNCEaY_B_+vB28gHx5YjLL~2c&YY*fc->YK^9m47{B&g z)fk}Xu}g{s+uE^*6)K^d)|QFMaRgr1Hc+y3Yyev_)m)wmX95{S!` zKjL;0_EGiukcysu`EKxldXEWkN%%X#6|hc2m!_#VKO04*i7rnq!SxZ~?RZ2|dQy%y zqZtlVznwSRK{yFYL7rgMt)UA6w`3^E{9{z?UBv~&#_o)lb9GHMLqj-zP8+`N2#%!e z$g8`TEGy)VB2nEMm2wlo99e_J zOwpl2rVVf;;8M-(pxpV|kcE!_cn^5aKe01?FZ>@!xnxgO!@jlbTSz7bo_<5=yy#;A zm+X8WVThNAb;4)VfhjGvw8EtyC{H5vI;DFMRKO!=gn!D1KvtHql&l{4jd4<1%~uku z6aAZD{0t2NYZ*#Rh?p9}RbH-aO?X8q4jy-E>H!MfTPVp*SzGA1fyO0E3B%tQ8Zdwg z%=xTYj1pM781%Tr0!|-Qa=Y$FiRvJvGID2i+m50@3q5{=>`PYdv8cdN<*#(Ta$0~6 z$=`O!y{GRs6GO{!xMXj-lG&qx>W`o`O9TDCms;%_oLD}uV@UVUA)^^>giGR5Ae7!z z^=mt(Qy<%)_lm00^zrO~bUR=UGCyi<%?gOdeFd>sOhv)46|q-x?dlnSf=S{j8Ec0b zIk{8|V9>vxfQ}8S!;z;x9XABzsc{Mpzbo$HgEg72lFD>aB3hCletYEnwrI6uh@8uu zew&+F+1Hnaj;$1P7!DT&oy)>#S<@X_5KT2J-mFlA7g1d$h}(HIpc>746q#=12Ah(m z@FiL$PSipZnv(#sw;LIKl&1QVQoS9Zm7Dh${sZW5|AnXo@tj%>jvjEZS^*FsoGpqA zrN%kzq`H-4g0asx?<3T?TD^zH_gx_N7RcoD9&p2nm_weu5SA%sDaBAnJQ?K(0BrFm zaOseZy<26eg;aGx92s#*9_=JQ54aGTtK1fCUMO|`vEUG*ZnDai1hG9 zvKY8W2gsKD$ET3x>Yf7pTHxAjIwVKe8=(#tktzfV`jf6xXq3G;GGY?{#+p2bGi7|O zNBnp8Yt$fN)FgprL<^yg-^b*=*U(`4pY!FvtMcat>YmMY15m2-DvWf0h}ajnp@#jl zcm+REAr?+yLX723<*8Wkw^$&OL=`Q<9X;x{4qDwypwcCQEQl_QLGrv&n2K`$`%^c@ z->4L^yY((#gc)$iDAKZ6j1;0k+^bGO`e4nTQgMpyZ>UDMI>4fFVy}chrLov~4 zI3A1uzp+s}#@D>@l+Y5!_Kp^hm-6QKV%(U8L-qf7Yl4~M-80tT7h(;h8&Vk@epaM+ zmbmek*pJh&QrxF#@7|)9`v?#avmC0oupFDTk-34EnDe4fF$03qNNB$SANBNZ9STs^ z4E19z+7Ihi4B;uTh!b)`q$j!smq$QW?1eTafRcLZq1f~kcQ1i1gm!l^K&|ZsaX?nX z_n5fXP6%imHs&NB`!yfgG>P+)u2o(Y;j2N)x+KDOT>0aA6gNLMR~(@GbJM`!BjC}g zqZBzb>mc{E3eb)6eHgo0ODm6@;~Y&&RsFINqqi(x);Me}QWaq_=t-PK6JlIYy>8$Z z7eKub|B$~NwR4F7R@$^zYts5gjG9R72dXuvU1G54Lb}VHGBkE@*lN%9cW%n>$;y^z z(Od)Hngc<1?NOr}1v_pslm9$PjPGrWf|(%PpniW?hHm*MxL46|!ezH2gr2Sds>$?wm=wo*xmv&;c&G2qa#WBw~lpIqQ`uoo+AF0Hmiz z+{0lfzxM$*6E=4MofoRLd`9>f<12?|nDO`nQEG4_kvVA|j5&f>;OF%i~jc39A5Y6G^+&y<@>qC{}b z%+57;2?sJRIRDe74Ph47gUL7N3%;Q5wXPB~h z=#Cn;4Xv7As^*>9iC=tfn4xIQWx0QxI{}~uNe}5zZ%3`X8CQBgS^#fWhk`{a7kvQk zc1n#5fHL;}*>(P;it4Qd^~kgdT%p6 z_*q}rSapY^~0jM3d0s$k7^7r6O3!I6dD5g z5t{2&7-ID5GiID((y1EonzlTNX+yE^nvkp2omK0{lH$?F-xy^yYI{42nayy(#R3ZW zHvZp=xl+UaZD#)JaWBm<1gVa_u%^az6^t8FQsEGf<(RW({N42pg~|$L(u~fLTPRS7 zxlPHegm76%h3C?o2z{nX&3_Qb3L$S3jbaR(F;}aCBJ3#JpPh1?9}{QyRSRDttlQb* zXlmXqE#p$k_X$ObVZ2dgNq>okLg4ia@*RLXaT%6>00A^D3MWg2n!ibg<^KBJbSyv0 z5+vq-t(;NIE-WCLy0i%3c`llAiG61LV;G3_td897j{uN;w8T z9nx?ddXSGbED@69675D}$=d=m@g?L@dh-fSEU`s@pwo-70z@dKKw5hL#fZeVS@)=x z|0aB+BRj1{_iF+bfvW#%rLaN8koUAFedWZ`Tvk7pi3*2OQ6y@{XecL)9_b`NJDK}} zAn1Ep(0Y0&a+Q~ibE|R&&D3^{M79`FBaPou8z3O&Xhot=J}SK%a2J3TK8)QpV-W#V zf2PL1oo|eC!?}(7qNzp6H=wr>PbW%1)g_tTzJqs$E()PT^}E z@+B})mPMN@Fs7P>Su2lWjZ60I5(T(Ib0Ri8fSIyx0}%v9d6c0fXG!KLM~s2J5MB^w zzsPVng*dhUN&S|BIAGys;Sp}A)T*f@Dn>!67Ak^*I_q{L^Xd6aV_YNb$UD2dV|*;r4!51{yt0J zlgY%%p!~I+512_G-T#(q89jEGkRE{cSA}ZoR4)5aPyc2ge>1&8X}t?o>m$1?NG$4c zFIQ6-wQ)FgPq92w;zs&C;E(?IMOyyWFs?(o-p&6)x+E`T2$CsD!HpuOV>}JE42*$f zhJXW^Y;oYlr%2V@PA}K5W8gs%C7UbtTPjwSJx8OGg#e?;M%cgeur18}CmF)S;~ik; zmQAB$V$D*c-t(GSVyRVzm{=8*JBwBKenqOqB%4(gHr#r6s^{HOqcgq+JJj~2KgGH} zxh!{N(~Qy9mzDJR_aIpz{@C|1xGI<+p;8%(htkA1TOfT@-chAI z=~*Pn&gx*8RMmZ*xcP9{?i>EN$bJPexnW3r`3&!n{Aq|Kj|@E$pC}FH*`57!IM#E@ zX)}5Qaq#=^!9|Pg$m|{ObHTmO4m(V{>vvM*!NG&C)N4}7;R6A^pDPXD-}IM~R^-^T zJ^zQ5F%eh4D)xCS;LS7(_%-KQ+2j|nqm{o>-L`yW{lkKYBj>_&sx^dO^+x=~C=hM9 zGe-T`O8@t@{8CA^3%OzBkaeGYE75~C?9|5kq3GDaBtvj!htD3n+YS``=U>+5!cQ9> zEu|$d3SvCr*~WCa*9_d8%gO1qr$aVn@Tu@@1l`gX1HYjIvQN8w&pNNF!GtQW`7Vj1 z{f8wFOP}*pgpxTgCwIK#M%74PE$3E;w1RDF=CSgK!%BJe=n}SdS5jfG^GWM=rfkX` zZt*>&>B)9hqc>TK!;JBqUmu+CTBTWQjyl6!Cp^#Uo&K(QNZPZFG)d9DV6A$f?|y48 zReJ6#Qkb-jJ?wb!dQHF!LwA4v;lPpa1OE+aRJcGq{^tR{W92C>8hbPxgFMfh^#Chmf19nZY{@lmr zQ~kQ;+fM$#_F5N=&If$(hyPLi2-@UEgKdeheS8VrFrAa9msq~`p&_=8zg_*o>f!(J zidl8uizHmq>5>D@etb>8Q>x6hrLE0yqYbNSd4Z1J%;m57lxf?7hlzq5+lU^59VsJ( zr@EYjOWU{15K-+za)t47#Gf9&02<<=R%HdAnXSNHnT_{b7x5~RzEorgyg zDtY8u>ag}t9917Z)b9-Qj$5!R_%+(8`n`ZwUSrZF{IcyJ@_)sA~aDgLTw-uLB7Q9Qj*R*y2Tgrv89Y8cj3>RVSvU;7D3 zoUY=aO$JT;$xpbm%l+AtL7GjvMM7)okzlB@i0jL6UN=8ZUz|plqvG53JGz>6I8JL1 z-rF0@WW*f*OJ)#SF*ho);;lST^5un7t9~i(X-|5Yd9}89=nv?!hx#4~<_7Nx$vZ#A z?QVMauNC-^4d5|klkp+_k`0PWfbXn12awTA5=T5-Hmlh0dtrw`7*McP{Rvr88OwBL5e!|Skzeh)n@Gz?$&$NZF1u4>HAFg(2x4HM7p zqk=Z41s~`o zu?6e#tP%4ST#<^rS>VdQ95i>q>AAtK;ZsGn1QGt&un|r5 zT$GwvBYtIEeie5D6g!T~?0y}mFZVAmS@*`7mgPi|-AOQW90i(wV7v1fngX$(9NJ<- z#lhfc#97|Uqr<1Ij<%a<@lJXGsd_ zy8r9l>>I_?AD}a-`V6N3T|at1#vceDm8YCaZPu8c2Fq4wq_GPI*6Ej)pB|}Z?bX1z zj9Bb{vRE#T6qAmCGc@Hl>pdLbd@WSt`}UDfJ+nk114h&T?6|{hO6tFl-xM1g+4x>( zr2*r!@SCNb3{He!S%?m=%}={jP8G6vm!=ebxoT}L-Qb}Q*UdqFV2zrktlUE~hGfRe z0QP8e{)#TSIse;1&xtwvcKy6Xu3u;0Masn4rU)^r|GPh#J@eFF01K)TofA!JAKH0) zw`5m0cSF+&8>xkS2T36bo7IxwY_z!Q-nHi0v}fzalK)R zZP<2+gq06+Fu$F58fzRD1twpq1AXEBhisHieLg>}JAX6$cW4f3(zAt>e_S<#W`TL8 zPYoP@#o5U69n&~;9bo?Jgx}Ddin*#AT#Dq3uIf)yOvMDi>OQG12YTd+p9TIC+EA(j z?;lI0_HXtZI(Rkc4w@T}=;;^744*!P`pH@P)>LN^@nsrMd`JQtH_U#&Mfvj^9PoPN z+TUfr7erH;!7{Gh-j{?XYJ^iM3p?dvjn43f*u~Lo3H072*~m9%6J1+5RUc|&D8beR zS6|uZD#}EsS-xbs9DY0zHLOy3@$$S>=1x!cYzO9)f+R4?nA~PG$#0*&v#4Y;K*JO$ zPsXdJ+&>1{r+=|2g*otCd_|gN?-4yCJs|RHBFb%1pT>)$_xW%GE+x_v)2q&l)nM` zOsD19dH9VU?O&~~?Q2dc*z)}wH$O1_()q}GLd#%}&N%13 z5N^e-z1U(*xU%~Z8WxlzCQ>yyWnLQ-?3bv$u`uP#7K@O*fH0;2+pdrkd|nx}(-ggU zVp)Uj&CDhH_c^34I_r4w?%b8a6gxhhlN|K4c+A@e93AylO;xNiPR@5yxGROF`--wP z^g|vWW?_{XGbQm{vU+I6s81LcfsFM7dw`) z1$+!%4yFc8$bbB=z@pJ!O|y0ENWEVX3Q2=MPxqSr{jMzXSf2h3w^+8It~dII)ntWK zSg4V%Kkb!QO{ROBY^(=%cb0KDn&_kbSy|A2*tZuAzOmAGfczfx)eqLMtvdHev=6M6 z2XnNa8u*CyGza}k^&*=rv}F%?nrL6p9A^-k(}y!agRial(zUCnn>VM^vLrSWeaQT} zPnGGDN8I`cd>+@=IwO>h&9$c-&ep?in}ytorc3@ge%xPg3sRj{ewvAs+Jfz#5-&8M zdNwY!vk;Z{ZJ$1H+Lfd2%{xq9?g)5_iYq*o;ef~W>>wX2cwUE61dg|W-#rb$bBmH$ zoydpeng~zB&W+PNXupncBz1Yj6ncd3S3X;*yl#xMEs)04KTz5FMECIg=3wSs33*Ua znDh4oh1-40|Et}~w4xQBRjg?@CLDH>W!zwgn>pr{{B)AJK3DT!$7G%E=&)beN`et6 zZRIw%7`*lRQ0shHzj4V&n&Bi?ISPA^*eJiMePff{@Oyr?%Q&!el=VXo-+P74=YFhi z$d$;|XdcMO_fOV_E`yO&rGlGR^6|KyUXc4d9eI>t%mgo8#&auf zDp+BDDSukSe`^y5J}PffTjlg!j(-ii@9UYiAD|8<${jBRicg+B4}06jS&of&@VpLD z=kkeR>lu*d_oS^JXMM^5-Y#(S*O85T2g(4U!2bE8GRXO=3i63JyDaU;3AxA5-Ttqx zt6t2mMQmY0)0IJ}yB$W>2Fwu8Kg`e0A$nKN?2Sv%B>vSsS2)z#uG2y0CSxVbR`=L9D`AX@pnAJIV@Yl{iku-xjnaW(zdCeTqSOF~jd2ddms{Gx#0@UMEO<{S( z&)vHO9E04HLdYj5>4VaG*FY^<3Vc7+>Gw;V9G7VI>pc7%UBp$L9bX|^C7DVK) z8_x6t#eSAA%X{KcKUMPF;`WRdPC)IC;vIr6S@Olfc_jVT5&PD1eG>vZZ@kNGLA}?( zAaF{?c~#>ZbdxL8&(VE2mOk@+4D73Ba@N1R+^nnkcJ<#raJKBgYwyGx7v^`Vl%D9h z!k|a_lNZyg)%6-98A26tjyYc6l%3ifp=jHOf2wd`M;uzPOQY<=y^GVsx7y)I&J!n` zZUrz84EeHOW#0O}7$&pHe|5iT81(3t#S>6@EsD277nzw#UoC!J+9gLd6qi9p_&ejr zWq#`~hRdh*s-R?Y*i!jcyFPg6^Q22_w8Q$~zz6rM0!|vA7yHGz4(p84>MGeAEZRXI z_)7D}xc$;oQ#|k4SppN1TWnG@IIorIUmgsY33p?coK2T)Or`&R2Pv+meQ5VjN1&g( zA1-S78N;d8(+O+CYyQ+>i;>j{{}V6g%qVWxb$_~XrRMbfci4zi$qBi0O5DWqmb5Rk zVGPEThkC974kgS}HrNW>5-9|WG>)uK6pLrHV|=vrTlh)=_d!bEjC(jZx-8G&hHq3l z+ob(oYc{-984#8ucv)cmWOk;Rsyc{f!ukgyUf~pBnj#O=XC$7d-Qw5=nLOE$D79~8 zV9a42EJ$OB1>N``qACxkdvfJ?U$bRHUuS=E*^S49vbD3ogHNKl*Pkw%3{vR-2~Bor z=o7@$`t?p!%_xvPw>E0j@I;$y2`dwI$wHbA=BxhIib0Ei(dBR*SZ|1iVMp8-RDztA zm{&Ljtp^|jO$Zn!ymQA!9Em+%RtDx*M$2E=_!`-P(_H@qC~vQXHDPFt6gxe)BrLv~ z;3lf{;Q3GBMg4Y73*lHFp7)_Dj+uh8;7#<{k7xY+ejAU|H;D@A!JbQ>Zv)-uzewWA z{g&Cx60)%DI+iDIvwH?z7g&VcbO`@iRRM5J44yR_^e;}-w2vmM`~c>gXR5ZVFVwPf z>Sc_=2PuWDregW%Kmc~tSEp2iB@Cb?c$$0)K(E>& z_((5|@JF%Fr0pgb$+ywZR>yeP@wKm`cc$FZvq;<(>>YYe?GvMF^mde_^{g!$WWwht zCu{ltj05TiznnMu3+v9-eBvwwHk$Z#v1yfVImIYhhyekcw_N{qL-mxACJc}k*y}GR zKeR^ipLTT$yBQx3tR^j64`S^;qW+Ti*ZcV$;bJP?fqR}36`P$lE^x{rnQpzcEMjuK znC@+XUJvV7cgoADQ+(h=Gu8Ze{amqhL}miX8;(r0VJ?AHX}%QAisL1hc1r0e*3 zvL;D;Ud((uIOZQ#>69Nx#IpW_?e@N`E5Ze;NW#bN?*2Ms8-3T(DB@d;fv#LLA#Fsf zvDc`PJMPYEqdYV)+PQ(GX_aYIjAAm(mu(T8yo}5cam*|6B~Rq>XF4#_Sbuk`Rd{dj zAuZ^bwK#68$0?Vq&GxI1KL!2sar?LD^`@I&tlV_`37xM$fv7!o6R23ag6BGrAGv`S zmkUiRX)@IxE(_MD{cHuG`6-tuL%YPf78>a5+j~VmepW{nw38{|HGg1dzIMp4Q&<|i zztO+`kw(s8gdEgMO$!|l#iZtMTLj)rZ}#Bjb?%mf{0 z$6n=jjj1*Lyl(qwKA&X-j+_wW`Y2_{KxVjLtKbRyZ?gTEJ5?FzHCNW2s6f?S z7{2~qg9Mp)?uBW&cVz-2I`aC2zmAvm$uYIMP-KNbP6 zv|O=5Z8?}#<5I|E>p8UwZX#Yg{)!^~1bkxH7+8{Px`VD60n8fqR;|Y%P4bOp`{$Hw zgX>+!pMBA8idO*BNyaffsVT93A$^WW&C%;E`d5Egb&iFgPuH?sS=0C*4VnM*%-LSP zWay?GRw4VNaAd;d(q`CeJJ!RdVlekqVi^iZbBT5clQh!O({h@>IRs}j^XT!^`@OY zFuiX;fB%Onz3rcDlAWzO=n{iUladNNW1cM;=j-?e8kv(|nW`E|h_C>-Huw^xF9YA} z=dl5Lz9Ad=q`zd6Tx*!Cy>D=0j|P^=KGL(8F`?^J0j{kyRRckRv2U{Xo497MM}*@b zZtYH&)HW1o4#dORl*c|m*)I~I@6o`%5dyg8p(J}qk7(*8?Xz_yQ++r#*x_;-R%f$N zHO+$j4r!wp#M0z!L%VpEzEtcRFq&Vsnh=Y?`c;k*RU`reB87;00j~Fv7DZ)qQ<|mp z;1q5zWe(z~%MJ+J%_ttMIx<^lI0uVJ%iy^rkzI$>QmpL(A0ueUq9ma1jUjaf&y%f$*dZZ@)V|O78pf>I z*HjuMN`(;hTPnn;bO@Fr`VL)=5fBikoPkP}p|RUUjD3v(>Lx_mSA;Ezg(R~9PB)RZ zVIjx6<{^_ZZ;QoLGdxHxb*FBbhM&)xRTxmE!?jq}fRDG5%Eau$WNz+(j-?smxqxPh zJ=P%r*b3cFD3+y!B5}Oa1*K$9CfNqJHSf*y#$TzYJO!kU{`~K)^h=VHn1KHWP_$$j zj2oWJB!Xhqv)LTgh#!JcG=g7mjIJait_VWxUDB{5LAYsZ_^gSrqssJBi+EQSWyes) zo6Gogjp?w2NzEY;Djbb)YaH6P9)hFvo6tW_?evN!1c;OV=Kphf-)pD! zri0?>s;W1WP=GTg!i`i!7K=!M5VPf4Me&90YoP++Q@a~ z>q2pVX(IQ(#+r!q8h;0j;H&rJeBZG_sI#H~Z&BYU=&eet5xVJq`1PiG%A^ElMBTLm zmj6TJIHcg-l;2BLgNa*@*#d(|e^^`q#F#Bt@tF_ctV8NGB`_1jfFR^I@%-xqf-8K! zOPI)1vpqeOr1WlLIVDqG)hI7TRNL%l5e}8}Tj)bMcD|}KoCrOHRpzD~h5(h{e^3_< zZxbZoOg_W`1_j27hyvn0Gg`0{{nZi9m}pd8VxLPEZLwgrq3sd5@;T^q5N6h~_rD155U|hq$+BetwqL|Fm@kv!VNRb;HT}dg3(k)d7Io%~i#NuEz z*cg_D8I@c#l7_4MPL9l~a){WA5T$U5b|-c}>65NbNZ<^BO4UI0vD~9D%7M?^JZOn# ziVeU=NDaoN($wmaIaxi-7M9r`!oGiFEo^9~KKK9(nc#-b_97qv8G4~O6L0MtlG%Jn zJG3{hh#54^C@F^S#nuYX-qomxr@n0{=ZZt%lD!bbuSxKzNt*OHRBOj17^!u)ioP-` zGakj^CS+u}WYEfp2>D7pSTCO%@MXtP-TBpz87)Kz?NegH7l3NioV+CN2>?aEyOsNZe5o-y$T zv!D+4uWvS$1=2)O0PL-aG#x%cc@4;78P}i)B<4C5jOa9KLw3<*!&Tn7xjCvPoi2xq>jM_wGWF zO;eTWu`5Ny0A&vR4jW24WpQ{51#M4W2v!BwsFEk!jMbMg1}TRucDp2cz|-B_QF?u& zuG!lTPY8iQz8`wxh2;NQ)CeMrHPe9s_&!v(-q=Ftod7P`1#XOxY{!L=xPP0--~$u@ zr8c0ByD%XhX(kb|S7C4h#wDj3sC1XYtXaWcI-qVSR)gqzfEz;1lwOYbk4L7HnPvOi z{HM^Uby=Aj@tpuML*dj6jgVQ6KWI~^thNXV!4r92ec78J@hZ9syrMQ7Ci{`1j z!M4&OCP=(tz=-Nr1zS*I{n`~hMvMXR-g?PAR<5rar$057@Qv5-$pVZdM;A#&FlPqY`G@0ts0wQpbyV@asY>-dhzhz&L7itCTMy6!| zKu2!f*qvztZhErD;X}Yd>=#`tbr7BDxqK>zcm6FeX%L8a(i+ zrrs)p#_!zSm%Hck*_B5BMUyCS-O z+a!7S#lD+*hpcss#^(e{zz^Zqm*&(-vzlr2i%mxVirxSo#au1p3cx77x)N7PDN!nH zzImW^Af%0_X+WI%4LN_^#D3eo-m2P78Q@rH%?C=1Xe?_9UPF@&^XVt@Rx(YxZehUC97j*NkPF=#$}!d3)IJ?2ln~m_sS^U1$WaIv*hO2o zMr8$azRaN=qtT%@t(-=KeR}GSzA*8s{xv@%LTr0Xw@Y}-T3#1Q;>>PfZP!VCBl-Mm zqC9Bc>aZ+a`@K%fhqrgiUnsnc!;zBg24XG9LBMi>#H1~S$woIx0Wst|MzrnW_CP~s z>`fnp)Ba#dOU1#OcP#rzHw%QRQZVM?k(9qoN+IlZZkwoYQ#rf~yLrqo!~OIa<5xV* zR8T>cw0#dlwCKLM^ocJa0sySOTNFBqr+#&V4WpY@0!as|Ak3(v^*U%e86YQBPRDko zps*#sE&b`Ak^#7|4@(7sIE>HGaZ&QDgq|4*|wl|H$(|_m9xOx}&V}dP3kIi_dyxidH|~EbL{JV=V(X1@L}wXEAyAyyK{yAr_}4Od4sRM#3Fgw ziWKG0*8Z51K7(|Daw0gIGUpR*5T*ub)2RU}h4hr(P7qK{0n`s8Mv!(OB{Ts}AEKRb z4x8280qy?3aR>A?EI6G%@`PIK-o}<(If(VP=GUbR?=2f*qd~YY#R|ow-avOR$_!>* zWM|flHWSWE$_kVJ%-r%w)paSjVNDHHrTBt_RFH3sQ_L*Y&*%fHYIh0fpEaXvQbUMG z>UngNe)=%Sc=H2X-R6m-Ns8S371bON10>c708heRs%dMLsI#SEBt)xYd2;)d1V9lg zgeMwiERL3uQeQ&L=rRVW8)8@npB}>9Cg-D2lwn)Jdg)|f=-b;<_%>lst_Y&o^tXIH{e-!1h$C7a{X2 zgA_3|#Lt?FtgXoB%C2sRgEeJ`b+@$!s!rLIs{4X!?Wy=8YKQ{n?|V7$w+<036(?I| z<}qm=Qe;1@>4ZF4e(23gS*~az-l8_wt}w*0feB2sQBZ51bgR7EE~5p!U`X70GQP@; zrZJiTeB)PyrB^g-fTBwE7(1Ql@3-kqEHgCraS(R$4yiSfZH7ei3J?eDsFU*~PM8Yi zoXcuxz7Y%vHq5{L!wd;7G`Dgllbw{O{G%FL@xv7%KQ1E~=g$4vF1 z(I51+P-Jw`9CW*O?~6+@ruZ0L1pXSC(N%X8u!g74ORSvyffhNh z784Ars>fTQx81l1FS2@;>t0c^y#lV~p#@;=_3C#bx2ewKxUUZ&8c5&@4!m3*? z;SXQb-A`f|#GeC49S3Evq%Sd`sv%|fc;-+%x>~X`2u}iD3PtD55iTv`ddA-OU|n&T zGB)D}*?nns>rY)y?V1w{U7@R7>YnDoElZf4nLl8{&GtxZ6T?I@Dm7r1<&_Tf)|;4W zJVSP-Mn5{*^u#l`YhEcdw*2ndH-?UER!ufgS6voSMxvZr5jfdtDG!e)a8N;(5&p=n zIYy~zxAek7z+Ug_8Au|!nN$aSpI|yS!^88uA(4&MA@w$c08$=IO)&8wwoF&Cp*umP zTJaVQskD@9E&?cj)ov7^!U&H!oL~zz2k@=ESB`KZW=*=O8+7?h2P)?F2i`^;pb@e~ zgmgOYbo)hSJl&UoPg|2ka%A7+R)4|((@bqahMfC5fWk<3PX5;hLJLr~YaYfQ zwMnUVCEzs=4Xq>KEt9(E)5-T^D95YQ;jADagp3&CBM0Hy$y&P_C2DhXw8VY^Lb`yd zgY_TK$*elP4>>YCa-G^A4uzS?Q_VBHc>}ZRO1#=KI|37Hp&f zlFBHgwy5_2=6AiLcC8M8dxr0>g-OUgiC{!^&IF}ss4oj>W(;k`(;BzV4Y!&YM>KG| zgj_dtMDgF;709L3MyX#X@gyQ%+j(GI6_v3F4s!(^n+1x}^Ja`9HW(f9;70iHO!Hnky{oUbZ= zc@IB2eu~TOV9;5DCoyK~a!VG@klmD85-^>*?|b;Xc{AF(60F&Xxavg3#h{#hH6wvLBuwjGv7`^ta-=khSR~7ZVuBJq5~49813r z)Uyl@cq)dd5s1q(LmBECUNzt`^GCjbIlpG#rALl0#-6=RSNk4(!}43}+`pG-lt}?3 zw1&J}$u3I;ic_&7|0}71qPNbD)h1@>8!g21!a3@2d!ZcmX_5`#0!aML+$ih-Z zu-@XauT+LWkBUpMp+@SMM1=0tS(d?X(_0HIHKw?XZZ24c z<`ygJeR_}Q+bIod89tw zit-O~=q_8xHzDL+R1g31?Bm@+%^z^|L0&0#u^4^}F^gN5SwGQePHZ(}Mvxf6R;C8B zf3|s`-hkjl!kh*i6C1hxNB*K=x~#&WybyH;f;t>2eVRLU0=dj7wmH|}+!i;L8dU4@ zSjf;1UK$6%+SHMk4xObK>al{BDt&dWrG^-WZ4uD=k=uFNr4?cxBPlmg{?(eExII&I z{o5z#0sZb^(HhNvPD#!vlmWwHC5h%mtnGUBhy;+`4WXQOeV{cPo?u_adHI$<|V z-F+R@o*+C}mkW?3`zNa-cb_hPVK;Eg`goFyIPz5VgcB>wf0J1P@*poQoa3R1ItRY# z7?CR(9v(WTH(;pah)j7Lyy4Y&d$qERg+a*Rs}@f2ymKB6WgkZzCaRB}_Y46L@^O7h zRFhK7jhsg3HDbQ<5NQ|cNqX)|$_A&)?Z$^bjTRpko7(FAOtDHaf``h&mQT#}HPssh zJu{KLV@ur5#)4om!coayj6Qyg86zwvk^D?6oN8iBqmThRC(rX=9$n&5R01?hjN<0H3S28gOY#n?$};nuN1#dX5p4-_iVx`x&U>BLDu*R zAToxY5XhCsGbC#sSOGJcQu{%)l_JOjR1H4mA&k7k0y+;ug?uhez#vMfne;o&k8*GQ zmPADW_qp^fnV&6YLy%Cmy%8_8aOkYbwJ$E!Odn*6$`F}7q3lj8Nv&saSHC;UY(w)3 zl4@gYkQ1qSRd1Zjw_&D~)CQP5wr3k?)CDcvR^#J0bOGLL*bH6OSa z_xn0r{FqJM3)U-1tZb+~BAx#|j||26$Kk(5Qtb-Wh*h{ZwVApXKFHR%FI=T(o3)^4 zFzMc7dF{4f1KF)EXez@*{9X-y6Jh4~1K*4ADx6)>VBN3M2y=$?*ELHj@8+%*YKKR# z(=zH8BTDQR2~-&USyB}Y+)F(QDF;?df{U7|X>p~_C*Mwd$QmZ9($z=sx)Y6<8XN(p zr%CWuR+t#vQwED@Tr}Yd8m^eH%om(nc%#n9_e*&}y z&(+|LV)%!${J11x;aC%sJ(4e0riUrRE!}^8RxF~IPFvLS|E#!r-eJ?}G2xB3n-N-@ zi@iY2i3Y4|-Vg>JX_E99@;+#xbwG%y)0-~MVB&J;ldG4#?9vj{1bo`c)Z#!VOW~Py zMo_ut)X&ObG&s+9j1^-;sZuiT6B&Yej#Qtj5TFyBGLmtHx?S0 zni;H>6nH9NUt^J7U;vQJ^H*JJpyKP9W#NOf7jXF;1VzbKi$FcG1pRHu7UIe*%9-%+ zU^PDAuG0F%LxK(38~?DZ5|`n4=S4)T{7DIUu&goDpZIHT2)~^^@(+CB_rMESQ6v9O zp;6Jo84dye2WD#8+Sd@8N+Ya%F?yI-5&V6y&*xwxSa8#8*>FZ_$GS;*`r1w=B(g>? zC6W0eC?UZOxAR6hc+M8J0Ewv9q0w4kbL<**o?mgkcV!^VMACLUe}lKJq+^Qk#*9 z8INON!Yoqsv!iHmlMLF*vuKG>Hc02o%w|OQ4}+q#Qk39PESOy=hm-z7jU_?LW@F2l zx~1>CggXnIi-@>eQBtC-Y)3{qDKTQ+6BST!9bDWANo{uwMP->%hl=MNK6eL@%!ZEy zYG;kstfMC#F-+)hRj1vn-LRs%0E1vFQ}HXGDvmbKENnQY|; zQmOpHjFg2|_TUF8(iIs3PfW~Z-$hH}P9RmsZ~qrY6P`}_#J!uoH~}jx@+QI5K#S6( zgyt<~k`A6ptRW+37J~5Wo&XiT{kycje5t*RSR{X?C~k2t)Gd!ja>BCa#eixY8qJo? zrP|(UMhxi=_Iax%K{II??&(A8+)A(HwN17(incGOK3u$xsESB-#qyDvv=JJ-j`GZ~ zG)JELUc~jU(rvsnqPGm_rEvn?Dr`~^xM9^b^osN8iDAIeX&=87mXi#UL6vcJF{9av zpQsw%ReyH0HOL4EB_?n;f5oi2nxUc3&4Z5oBg>{HD?Uh;rgYFSco5*Sh{QX|!>jsa zM!>}Y6yaRqJ#UGCnp@Yj`u&W}1tc=bQD%O!&}FW;_wu98%wyrFI$p$urnB5()jJ9n zLN$O>4a_!~MeNh0`#8Mgh8OLl7|QJ5g$S(%A4$RUUCM@eGqkc%W~@sj0L==@+R+RA zBMbmHu-J3Kh*989g5G0pUL>*SKSufgz^sfMpEPz~lBV&!!WuYX>B#INnQk5F%rd(@ zfSgsrZ>D6Numa7Ehll#|4qsg>duf3X6xt~3C~le)H`oi0Ityn4MnvKqz#|J1-51JEkqK*l2Tttd`-Ih^SRIF8;Fv-LIrlEA5 z|0z5RVyUVGX5a7LzD)~K2P1#~@v2R_-3!n7S$H{;nnID$N~x*HD=Oa+&3<|x&j`o(l7O^&<@@urhpv=SyLwXH3 zr9N&HG~V`K+)?)i(IGYa$lh`3jETqFLZ`N%4CFZ)-p?`5QS;q&plr7Hr*coJr{cbr z?nn2S1aoDJ?H9F1)??SE7M`;x_xgQGuwzF@|}n~l|X*bGVSP<9O&zny&@BwR6) zlL@Lzn58qUu&lXl_)DiY!0YYxaoJKpzx=f3yKS40e#UN;!^m{a zzO7+dLc`uj$*J-b4PtT~GHZ}eQc@f0kLb7v(DJ(RY7W^z~}gyQs(Fqg9D9-=y86F@-A~-pBi}?~lXj!{ketf9$2_BQ=TQ>NN z`qL%nXKcfH>^WC*>w&B2XK{fP{H!NJ0$W{a z4^aArV~-Xz#Y|1OrJ}x=$mcJ~HEQDib_mn)e?M2Ax;hZ5zXk1!>d$WX530S{geY){ zIwclg48LRl*~m_L+$J%qGx}n-ZOSF7@(YH_HLgnkkF1{u^$M?3jUk7pXgXczZ4xKV zQYWhHb<7k!t985S(7mb4l#$Pu(pRNA>499r8MZZb(6ILb%RuX zZq=4*Bz?0f<&E3gzWE!IP-Q5spKl&JarXIa@=vP*j;jh?l6$py^Ljz)!cP9BUUdt$ z`M?5By%zm}TLg}P&;ET|j%{XCs6C{OL&^R~bFl=~Gq`(5h<+dlgRw6HaX(^%AUT*f zm8$|Ax6SeCot3?h5ezL~rvIm%NWBJq*ZFZDVGF#ae5^V}`z-Hw({x%v{KT@s zto@;H4Yb+4)u`_3KdM_v$8>tZ$8M;7(5T&rWF*yca%a#G@HxhKetmJ?-c9s8GC(Nf zb%@Ws?d5=O6Z4q4=N{L9c3uC6Fo!Vri#mf$j^dIiNnyz8b#2Snhx4}KUCGg_-yTsp zeVr$ic|Rxcw4)nUU)<=Dsda>q%KGdJ-yWoDyt(pKE8T0>K3!R|hGu*?(>|H{lRkns zJifng>s}en)PSex&N{x^E-V!Do&ACzBa(V&IiFW=fB*i-m7!pCHoCm zEy?WU%DK!tV4Bbk$=PL_n% z=JVL|jh(?F`lA_PfqrfA=uO8unaAaAY^k;Kt>fC{KQ&_?+s!>RE6Qtr-wY`+_Ncu5 zmsQVxk?xxicIIAnVN`%D$xE@Z%o9tVuDNdXlzzC;Yg@5mnchQw4Q6uRUq;cB8^-y56#BwtBn~HPpWNHasqOC+8H;<5^QTbwxF9N`mSg zlA(QuNZfRMI%WSLS3~((Lcf3akK!JZcOEW|*9=N*b&&M@&{1ymjFgUJ%v5{1&`6C^ z+M42nexcr1uF~k%-*>A{iBa_{&9Zz;{e-zrJdio|H&+wr2bEVtRw-K-90I-FHFT-p zV(N^Xg1#G!~Y*Wek6|E3?Ggl56#t31`}G6&|6vZ2g*k$S}2Y`t8-gr=w5h zbt1DRraW95>>OiV>(1WN;`G&G(^Es(6e?U6=)G24v)fX4;KhEcR^M-pt493xk};FY z37f?WCVc@J<-wELx6a8VUCN?YwS~CIo9O?B5eZZL-!mdsPJg?0H;m;;m)`^kD6#RfYy6CmNLy6+Ye>k20+xp2TOWt$U zisC|S^owJUl%%&u%;YD5RsDlv)rvwFu zB0}kwzvQP?i+n~ew694oGjFtL2Mgk>Gx~27ctjJ!v;`^6vQrB zY)jLCyTtmnVUK?@Nw}C}Hlv|9hmOie0f@QXo_UP;(y_$$^v>VEbNlROL+B%s=tOPmX!)l{8&UbP z?rAeC^cs;8>zp@`Gml=5XQqgY#$Md!qdocQ3X8XiBK<_>RMF(Uo?aGg56E3D5x|$qX+!_e;;H*hr8(S!%8=3l+ypQ3pApMaMx2fX&>9)~)1 zHH7*W50>L~(!LqaBufj`2MMdHnO;|YL52f34Nqe7EmL)@F}fvXT|>lE|hSZQ`W zI_NUvPYn`mgl($#R1tO^0j%Tu*?}7(j+~TtHze+37nOpdrZ*e2YI8q=WjKcz$6dG< zzg@PSD^>J#b{{Pt*8O|=Gl@Q39543q+~Bv-lkwA-%cq1?`K2dW#sK4w76CyeX*;V7 z;&&_SB@857etL^7#wnZv?v_DAegx$T8@$gnZsvUjpHdQpZi@?j<1>bHhyGnBDq!#7 ze%)TSdEs0n!|10RO7B$LRgX>_cXD;AyUwS!v= zS1csTSrnW%P&N}a;*8bGQ`jP(Wvg~jp-5_~& z%f~cwM&TBoU6oc(o_kb*bE=@c_C=zuLYwsPib1=0iA7O*%WCi?H^<;_(~Y@7%}ItF zMh(l14Pq1cVDk6mgN54gT*Q5UV*4y3_I4Z1dcIfBjas~fSE zO7(@e>628futHxNoYyL#zA}}I>2~n^jptLtNg3H!-~m?4b8naL4}FcQl&Sf zYl%jx#?&q;6Z!eg?xWpwl4QT=25&x`Ztc*BdRl63n5P2F;UV!IxqdIncKVr^#+RGD zq|AN|fs;BN3b(A}GK(~twVn<|9#1;YIuten705(eJ@Zlyk;YW3BqwNK3a3dmKI){y zre`A21oom$zi2_m^BvGIMQ+9j{J@|sYfFM;kS~s9D}Y4ST?F+e4#ro=VtMfa=HyXi zsRC$v%f~B1zQlqCU)7kS0k^U`~beTX2(d z)1Q`aLd+d7eDRCVki{}iG-02*!*WK9ZviDOw%Nwst34&FH`Fn|(qt~>C+$`=JfUc- zn%?V+oWTGAz($9tGo6d`Z#*orkpp`r%l~7_MDBNVj}2N@k-UuCYw$|W&mDbcD5^P? zP9T){*NhVW^mmkF;t_g8_L^n63Nhe2)b)G5yex^Vnsz1w^NpI;Fw(dc_5vo2vgRp* zvh~Bi0V_HRJES@i^r#C)hE?q}^BepVr0N$k-0n#)@Jlow;_%uNz_84s)=?D0sH6dZ zS_e{CI%@k!VuBZyIW=4M())k6U&;0|`!Aj`3QDa?U`csNuLdbbv%k%zb^``gst$tP z+EBOK@H%W>@#RCpuLx`wxB;3EVTb{gebPYX!Hz{=9hBv)+D3V)MG9Hyq!#YKsw6HtY03(eg*7q<|!Np_o7M)ArNXexZ?(Vy6H#LaiT3(VR4<>;}Ud z)ZxMt_hS7*e5-7Bj{9 zCoWA!!;iQ~7E zLUyPs)nBF--BDor1l=m{dNu)v;o0+Ov;x`JkN$nSk$((Z<)~hYG+=dRK|HoUi%fya!0)Dr3^ZaU=4h48H8;=- zhZ#_SGzMjnwHIidB%*fCEl`qooo5om8iJE3xr<#%`ZGF_6MuYWr9VSxSls2YIx93j z8|ro93V3o6(>`tKSNBbL*~lK6c|}mBR&G0~Uz8H?xsUR@do%H6$^b|TZS zjwNo-r#;PLL(EmbG=r=w%C_;zIwX+c%b+8VE9Z`)Qjx*^2_BtKDR3{DU4dWclj%>4 z798cuaXhKz%O))*&hX(Jbg965(PGky;q0poc-5f%v%=z(?>dU3wBPlDJP>u!bSXCv zmua|2Ls97pMo>DJ^12!$nyx_p{8@l6tT=hGY;HM zSN$`r|SpnNHnaKGTm8!Eu^fE|>YPN{4|jK`KoW zANckS2SM4dO-h@(1-9jOu87v}i7XVQwuUH18{;O4#bS@iTcTwP0q==WZ}VuLJ$<7j;t#BDL2XT?~ane=%0t zUsNW%b~3^jSGB#&&050(AwxHpw1HLOgl=wh49!~x*SaGEbF-~cN*~S!QN#9RzOtI_vu8I5&{h1Mnw`{I z%$a#F+mcb)8A5v-Pk&izH^-x;>IdEyLDQMvrC0Y``zE_T)Ky9p$!u|3!vm+|95>SF z2`Yp-NiSpr`@-r|VJOFb!5NjWFt5Ju(Xvnmn;y0V0*7s=ot(iBR;&qc({_~0#AP=G zzuK^{<@;z<2^32P*8bL6;qeyhdZ+2RNI%T+4Rk~WYUIG0VwQG$W3uv=;^I94ZNBWm zNVX%227Hl+uU`86Jx$8Ab+bSZsO)c2NR;{>zBXi)8&LqUp8 z%DGmi9;n$!C1^3j&L7IXgc2)L+u^tdeWWNnQ)PDf>3Io3t$Zq+nFp2mj?iUxm8t0f zR4mBuZs-r3Y(c(kU##pS>^w=~>_h&_z#%&Xo#t9NQq{{A)|%dA?6=1G774KMT<7x$ z9f*ufyo5Br1l!_Y8604pM@~>f;;tt?b^q<4V^TS3Lu39yX?;Y%U$^im$lO|odJUBw z(YNMAYY0+7({os$z6J5Qd9=~0w2_$wYDAk$<=b&h$P4J80Ov&wzsA{S;5^UGAklc9ILu z_hj45w)QgOtK_q{ReRuCyiZ|IQPN`I?=;PaHJx-8lppjuzk%DGa&EbL5{}RQdF!Eh zNX|QR276S5{&{*;i4`g;J=yv`UoGd5rW;TeLtm$_q-s!0;%FuXzF{~g#7Br&hZ=H8 zi9v9pgTS5DajvpySt^TMXP|qh&WXIN>W1W_h~0D@KKPqX#lIji$^6*) z$rOaP(k>HyB$K|p#%C4}{v}xG!xqCp$&w`Y49z!Z_ql-*l%16%XGZ5)`wOti6{^#N zjeHUz(c<$zUyyM3jhIgv+P@&AO60Z_hKCCkt$&3cChr zc!qzJk%%2&1E2t1MG$$#oX7}Xo$sD*?Zv`U>B8JR_}6;yZ5+=60iD|L2cH-5iED?m z5u@UnMQXu(7HqJCy#1?R!-E^9&pLG}3Z2&Q?;3F1BRhd7<<%5?I9cHwST^b~37%nh zx)QeKbEj6}*0t*MnxXGfM*^Q9`j+mZz@rGbOG<*!j0O45FXN8ck5A$rOl7$? z$W|naE(pNw^lq>^wZ$;0Rq9WpxK34sS6(RABk29DRoKVzF$~5ELxy>C^0I9YS(GH! zc)+L{1Nya(suA&|E1d^A7L1)WtQBXJLdVavqt|plyl^$oM7;CycZ8cS&TtrrM=X1p zP;fcj|C2iZngLC@)Niz_aBNmra{cgW7B`q?10~E2ch{#Fr6Voar?P%HeCx7e?Q)St znl}>gas@u3#MLQn$_6>2YuU<-I?0OpZ_6l`Y5zJe&;DA*oW7c26S-YN6X2DCvSwH0 zt!;a7qK=T}U4M(VB1`b#RaiFzh6&nE4t^a;m%WgcyHHr|_64^~e#i0R&d)e@|C&eS z`CeCN*y-46;o|MZ$pqPw*&GqtN}wrY&$~?~I{YZ)R}I#Dq_9bc)|>Bm$3Y4hsGbBZ zh`U*rV>BG%^ZUL)j@a*snr8Sou9Md2OWTg-1{F@(JBwLC^^3kU^CZ;}zH~j)vX0I_t>8*WOoWk6m!0lbHtas>P`-RTW3V%aUD}rVk zqJhVNar#b+0~p?MX0T9cb+9;ookL-ok@Yf_tg(PSo&+l=PO?ii>fp16>F325ewAM= zy$5X23FZT*w~g7P*p>RI)By>UcpaHCjm>jA7LHF_JPYY~$9_EHZN{z!fgSH~rH=3) zbXn5geH~gz0C$pM4uk&qdeUZg56#PO%+L)NaB!F!qXsdscc=bjp8oeDfs13#*`8|n z-+ffa&*zk}XXa=7oYsc5;Nx7ajEEHaNTGn9cjVTI@#P_d$XmF2bI0!)ygz9Q&-7D2_^To zujaXi6AEtE=g%(KMfG(`HV(Dyf5E>#&`xGPto)FiY76a| z%*RNHst{968zP@>csUhA)cchZ9zdDoLT^{nS|KZ7}-kAwirx82ZHbz9wL)}ZN10T}W+*cE*8G4^Fb->uto_;)z>e$Nldo*Oo0 zfABNhTh)J%G+T8wYIH4jQS8Afb|yoy{Jb^jvaTvK_GX-Okzp(Xa&d%hNcNca;K6@y zq(3#oY$K%n3Io@+BArfk&}fx!sGmgIemuyo-fo$>fn%%1$h-n+fBUj!`*(j^!nMKG z(WR|93C9UuxO(V4^J}viLf{hWb94}cSP#|So**FUzSBPiGew+jG|NoXiu`QoTx3;#IL1qk#|wcbtHj$gf;Z@| zqcC<7_h0%7KmVmED-!+nCE~b#a1e7`Ii%U8U8MUPp7h!q*Dn1Re?xc@oMv|jF$^ms z7|6{;-I*gc%LH&-rysa+p6ZilJ+E4-XTA$!)HHZz`p>wm2FZBp?mrhY{YKQWtUqfL zgq-6TP5rjV%2A@rZ`@zvY2mJ?@Gul_SMw-H(J+MA)Ak&Z89BM9pB8ppcc9#I*_Kc5%O`EvM_ z@p0O}g=8hKq7 z-zY2y^=T93{^@kW)z2&2xVv*ecv}6;GorjzoWf%IDf=VY@xOJZhNG97YY&CRKKJY< zt$6xIerRamA;<8cpt>=aXb59qlf6tbt_^wX8uA^QYH3?8Y^eLjSA=kk8L7wH3%i|i zJxbBJpHY4)zTeI{2pGThfVQpQQov^<0&TTROL)^sJ=Sn(g0*AzN(OHF#4^Q88H` z8J{Fd5CxA`v0USyu{Kg$?>2JqEC=)owzhWOl1y$YsnV2AS?!A4jx#ctv?!B?>Cs^) z^YiYYpMFD3#JKKLQfwAe_d3GoUOhRSqtQaAo!sUIS9Gn|S9ryXbf$He?hy>J>x@;a z>iP+{!;%ACH@{~*D#ks=EQack=90lZU9W|fUyoj8#>cgBdl&gz!aaSj6~&grvS%W! z<^TU2`TzMGSxoQ$4LP#@<^M^Jd?4`u=^VMK^uOiEE)?xIaZ+|)&teOz1_3 zPO~|?*e$5GEObbB5=$=bM-#2e8g-@+Ru5J!mGx?J#gi@6%753(9Tsoyr*5V7rarw1 z`qgrL%(wNy(k648+h#En{?w&2YobxdI?XC+Y~SH3zY5(~@fvv^@GsLXwzsXbdB~

7&aT3#FD2VFeHV0A0cNkm)30rXHQ6M;-A{ts@P!wT5$*oDt)_dI~;cJR=X^rPCfo zujQ5y7L=A-u)?{%j&0%GLb!3nulsV!v58OhT@fMf0g#87rupXnhiuo>zIzqOh}A|4 zRcdJe{${D(o0Qj0JHvcubCPxXT~CwQ$zqQ|8uMJYsdJ12Ba%mx-t`Pkhv)Hv&ANk3 zhVYWngIoWKM?0dq!tc7=9XGzGC!9sylGc;wP5u{Ro&~PbDL1|oVLcJc+upW{xrA00 zh`ov-;udV>QAhp2)NdDKj4Ykc>^UE3TE1%zt65arU~>VI357m$w<1)bcG;qrei zd{Dlir!ZqgPTTL~zp28=h4gAb27+GnVN=F%;eTkTihGb6s)YY&s5bbFdxn3Rx$68Y z)HJEjX}!Oq$|)FEp+OqP)eH+}>T@UdU!_P?TVM>#U-!84Hl7>RvoRgo@8(;m+}|@n zCdH5!jX9Z-M_g8U!HC#n)eeT(fq*2Z{e&-J@^k86j`=}}ahm~A6^sY1s7<4JET1j* zn4t*0;_CURUXR80q44DH{^Ew|*Xv5t%Yvf#7Fh9CyBvQl9jxk`G>_VhokfXx8^6V0 z*ETSbxXkNXTxI*#2#8936Qzu`^}c|Mu)PvYrR&R`r*HF+8MoH0D=YCmENxB`^DN>{ zK1HIc8-8%@ zDFHqru6n;_^@&7e#WIbn>ZlN7jv?W4Qqy4k(POzJx4?*gLyJcEJfG9gD{*&6Yl~@A zPDBB;q?=S^$<$+Oe5nu1XFh3Urs*|rUlgk#R(xYyQe2#T zMf#XryA8;WyW;KdL!JPrDQ)YIZc& z!#Cdo?GvvB4jYQB;Y<+p_Z5V?`=9)a9m9r26iZzq!OUVq5t#_5!t2DfJ41h;&cTm; zuVyOo@e86(xPA&@WXaje3&(qGi|7DIdy`ua$fTDiDQMtKYp_mk^%b*3WOY4VyBqw` zb2`m~_Kun3(*;?lyb!9cLjD0eM|rH_-oJ)#w?bXGn#cWQ^FtBOlz>EhzUdqj$9MCH zkbY9d=pjdd2a7>9x7!dcmWDb2!XiQBM@0t{d(DiFq=(daga@OA8~h07|L4ot>%osf z&08v6(;!+G0@_CPA|aa}7DS-C2*gRTbu8>(cwCEOnRY$d6=$^5IwUX340{w&rosl!zIP}u{HE7hN;Wwq(pt{J zJX2Y;e4aF;#%&hzGvuLr1HFmlqRPbdQ@;@&|78qJ9Axc<0J+V*Ad*886>%EWO%ixp+V|@d5#v-3hirlfxp)s!`TwMK|MF=69<+_cqVg za#@6T7QEH93aTo#FSBUJv{&yu+O(e<|x!vCv}|d`#!xlYQ)APj#F$0JBEXGD{8yC~ zcw|cw>qzpUmxxu%p1O_)e1DV%9Ve01Rg+0F=eMwMoDlk%&j!I}s5SKeB7@{^NwdpW z_~Jgcli#3M)`ezLv%~*h7!^xF1-gb683o^ViB@$r=7R@C>DnaFzvcx?laLp-BB z0i2hHXA-Ec#$Rwq3Am>zO_!rn%=~6*))D0A7=1Jn%tM8uFg0{6 z*^G%b8WiKH;*GzN2Q&v3k~(v&;Z5kO)nCdG2vhAtP$f&+?aN*A{Lc15pM2ae_HfH4 z+KnI)LIP;L%_jmId6IRgR?82(6%C=S2%^CU6xI<+{n{F$H06UoQ9`Lpps&pnP)UH8j+)uVBIQjmvN3fB#%3 zeDwzX{M8zj+m3sK|B27%NZpDUOSbB9otp-5%`zut9JBffXlJ;?U7zJecXe}pABO6QkkMA#3z^>!K~haTXlF!#FM@Y-{u!k&GDPUVVUka%aQpJSk zBg4h;42JoadK-C2_%V1p%%)^jD4Q@tlHx7i6 zJ@2yV!6|o8enP`?RqJzln;x{z=NvxImFizs=zIt6`~-(xhptg5wmZX#-(*0rrESN&}Z@G7STH*PpibXVD^lA-290mesw$MqW-pUZBEh}($~0Qw?l2ii+~W{EQ3Q+pPiISwwjL5G%5MQT2> zh!GAgRJJ`td4@08CoZT4tnp!8{+f9A%h3@G^o%Svyq(=4gPnnu?sI{z-x4cJ!PH6y zM)BlaP+`?nkAU4oJ3g+l55BCa7sf9--G!A?WkOu>-HWuug8`??y~hQE9#{xze3v*7 zXe^svC$*}fg#Wm9=tS$`kmGSaGh<90yWThC9-O>KXJR_cBSt0YfE^RI3KF7r9%{s% ztib!@iwPt+vad19F6WND(2*O83I+*rR1flh2kdhI6NTZM?7|v{JO+b^?LYf^YtsZ< zDe9m7wrl(g*QF;`)i-JM5TyT8WoH!@Rrt5-H3JBUz@U`UAl)F{EunOG2+}280|*SQ z(jf>)mvo0RbShmE(hUOx47KO~ecwLV`)Kcz_h6m8*R>ATde?e>&wWRoiIm(k$<+ut zR4>|jcwt#|YIKB{Fu6v@m_yvFv0-9;8!G*zf0ckOqTk1A;FH4I4R-eK1rIGOZ zULo2DD}~jg1f3fP!9T+81Bkh}0E?$I#N(P9`d}n8G0w0e^t#aF2es#4x!4;p9W=`e zSW30B5>+wh*HI_JHR1|->rm) za>~Z1bX82`D{JykAUbW^;6B-Sx|Se8h!+k{f-LVo6D1QmPm+?mcwO)3Qij)UjRj~k z>=~Dez>|X`h>fX2e{gS_VsxxF;5Q!7wlBw!7j0$rIFEc{5)h-?~Rs( zZGZP*4_nK{l2+GrZ|H~9SUdd1V50suK(9h>d=n4*Fwrj z#5Sk=iuR`!VQ^qz09M8ion>lhNCwbd`!MYw>!0VIU}y#=5JIN5tntkNTk|r0?>Y2@ zOozJn-PrT6YW#F}ZnjR@&4-(~<}VKFrMHR0Fbs4C4f7|m{2byTeNXD{!V*y}n;kAY zc&D`4fm7uA4gL>n()$}qTltgedXC)tq!pQSXHTHU86Nl0zmeGs^`!^9TYh=YOn(-S z(M=7I5Y7pwMTv5oRG{e_Qe%G13^= zWZYY^*MBf<`G4RziLzeTa;WA&->m+PGHakH73ksy#D!CiOMrG)i|AqkPK(#_F6-oh zG{6rLMy^ZFll7@3UN*bLxl6h18`WL{oGi6dO)gqN+1^TCX}xU!NAiJj!cu5A=G9b= zOo>?!HJatRu$n%&t#N%7HkhlIx-KIBHs?@)ns1X8@cvp>3~TWM-G1)b_^Ekk23EZ{ z1pUBRKhXV%ZrW2(dJ2Zt!wYrPTqrct-d>1w_AX#^%~qt}JciW~^_^V9{sN4&orPP2 z8X{6r_EPB4J@SHL;(LbLiSh0&k&gs`U0h8u0gE7EhG*aAb4ZOugw*C)y!Bar$!USl zs4tG?CFQ5_C(_4b)8;C-?3{4qQwTN*ZP0hOx{_cmLDV_@SUw9t36L<;BAHwK_^_bn z93WKK+-%KYmSzxie1kzl!nyg*z0@&wPcEvDSm&!CBR+&{T?b~l>Au8`eC(uOhe)Oy?_2W7PMBrmk#)G1aa^xXSO8b4&y}m*Z)X9Lm(DHG{YH86@n*LPV;6X^lke zQ9b9od(O6X!7(Os*`5LJHvTrIMhN0nc8cD{k-n0&LW!XJ_;f(HHYy}pO{BoX1`|ig z2|wn zl0($rd!w@}%j+|%%l#T9+k;LP^RjaX*2PV;l@T{ishoc<`~}NH#5m6CIVSs~E`n_9 zHrb?%2avIKo2BC(Zvd-k6o*xj8s*>fNQs!7(xyV#4ILgQf#wQA{ zE-c|5maA)@Y;WTj4VfS#|GCk@_^IrOEpJ+4x`1Shk$GjWW>@H0PRmpU^naSiOaEye zhh<%kFs-GSf7FZ@KSHI>JM@uM28vf&V9+LL1pN%qujff@frU%)#I{`P{s)5%qKGsoaJuEan4^q zjS79lvDxPrQhj`?20uYA;Qo?}WGRJ}rhe#>bk$4(DSqLJx!V(64aNM|GW(VB1-(tx z&#tr(N6R@ugmU^HQ^&)7&v4dG(c_;GpKtTa`?;_6Qbe=h(<*Mla#JqVphYYGN9iM+ z-Wjx+cMb}nz>{P-Ue|w&{7crFsiRzn_fhEItlakkATl*S7D3x%dbJNQMvrG;WUw+b zgzVK*eEc8vJ^CE)%%U9e81-nOZJeDh)pc`@iI>ZviAmTaFXp+PeV~HyYi<$aaVq-9 zg=RJke@0++>6c+%tT+%E?6nQkKYJu(L9L{?Io18HSh%yLu%Bev@!_x<+Tm?8e1)tG zipeF9cZ?!Wuc2df3_AQGJh7$mF^-_}dI|LFNy5xZIr?MS4*1m3TgDWr{;)H|O+PQ- zjOiG+`UJqFrVE?HV}~4J>|Xoqu5zijk=#t+TkRd{Om(=n`FsUMpVXW!9G@BPe~ygy z?&D3hQoAzs={N(ZmdMx>+-t=ucp_Mq9rXumc7xUHuxjIdf_vtt@7zWbdH_l9`oN~Z zcJmO{05XP&Nwf;jf8XnWtMI&-U;UQ~ulD)EgY7@to&Q^fw?weK`1XVNX~6^@VHRkB zzH6-irYZbn?oGBZ@Beo?{YshHlH*oB@(+GH{Fx!2X4SLT`dckewC|9kESWc5%rh6u zY<_O84Z`Z>*JZx@t7F^P|D`$;HyB7-#4Z$1vG-b~${vtSMP*_rC7#<|`yv7kXyghA z%Io-&$b?$R2YgV2E6MgXdgUJTONasW{E7JQ6t<<0Zzst9s0T_7o<$#DDIW>?Wc|m4 zH*e+YVf5c7yfd*fn%`=3|F0%I`~TB~_jWz@d+_CdoA7+|ppJ&Rh;dVjN8-)XjV+xs3A1}Ku z4Oy^}5xAzgE;^RH-szdIo!PziYyG6)b^T=$yWA_YHQ~Nq`P_hevNu0y=ihNn^Wsi8K68!~t9?u`cT6^8T^ZHvpxa>o2x-*>&Q;_JPY*B`abpXW<(avQy*@qP?^C(Y&voO%hk_ zjN=hxEI^&R_6UQlfnu$xsA&$DjmUw-NP`}8 zfG)>YM0TMR>h?mbpJl;r?+74r$Vfr;-u#=f$mnGvfzVQU-yw5t=3uQ! z3I&JX#3V_Lkxm!aWPZB+b`I(EPYP^u^Rt7JRD@M0dfRpf&C(n(rJDx@Ba(*gjCb!4 zhwVSxqhhBAyArrM?H_r5;Yuq-OcB2a3C#cYFYC#X`qZn8*a+g;l?%$4Hmv%oj_LE)N?DFi2OAvxaXJudm|w<7=4&4?{WIow4nRdO!KhQioK;m&(YA) zYfN{;KRx!k^%Qc^HYkxWDADZYCOR&iSoKxwE`PuLg!@1WQ7WCT&i+m!1(9Us%|%x3 zWH#zWaE|rCbtWqh^G{i5(E^{}SYfk;-t_C%1T20|a=#7vH+)`3Rhd7WiiQ?uhUo*^ z*McUhC!?Yo<_!|BPOll!hMhtkO3gWGrX z&%&rfks{?oy_vmGfoad}tapM@e0BeyC$?UC(~9;*TNj^-9J-#ugoS^nU8+t z@%epTz%yQNHJe^1$|K`g04KCTB6iFCa&x#l6W+}Rcm##>N$hrP zRNU11t*Aop6)VlT8eEu>jI;ZA7eR_Oxwt|vSZdj9PB|2=dg|)`c0_i!ES?GZaNX~* zJXz*ZS$_Q}=kdQt;gPUcYF{|snlM){PbSga*sKvsHBNgAtmceTd|1ZRp$c7C-C|aM z=iFC+v$u7G*i>lvY&ar1Hd@8?hg*ODx`2Dq$i2E^bP_e`Z|R&Hqf;IrksjG^y3&fc zuDl4ZujqMCAQP~*W;a$jHY54la;8Zk29u=hDaHgkS&W5YL3{>sgTW zQhSpwh!?DdvAQDFmY&bPPG=673Z+HX3B-6(juV^zwY44R zy2;Clp+i&;~VE~~$UnsS!* z1>cND+NL&U#rqdbC1+>y*=rZEerak$nWRv<246I8y}EB{+N8*eYM(Fn)^qi)w%_v@ z?K^R4d26(rSvh2WKe_0b_%PWaxA!5UNZ*y7FUj2W&gOnXB=LU9c2%O1KGinOKVK>5 z`KdyGk=5muzGqrmo$STcu#%~k74@Yjd)zrF1deSPGSYn4B>{-GX}#+;Y+QHUVxBiBp*>}>tx4;Onb z_uEJ@QwQ#IZG>_YO6^hf;0ya>3$_6&bN1p7i=B|Q|1_EBYI2(16nCoM7guqH?Gji# z%}}E@4oKPUvgG)1v~={?Z0Va%%1OJcnh^fDP4}(TU0~N8WhdIN_*Yy$a>e^d=YlVL zp}h7a_`2+V?54AaMaZ1s)+7D*a#uoA>F8J#1tcXZ*XNCpm6rk`x@aA&zg*If?mh3h zdrrsuFPV51P@}8R=u0;LSzaxb^rNr-XE7PqEo$Q0Dx``?%{n@Y2zh@5Syt+D9;w=0ypV?j?&1r{f5@L*&kHF%liKSPM9vA^E#0yET(mxnjb>g*35mtssfGyn@ZniJFPc4b%Q;@&*&x!=EvS+Mn&x1yW^`s(@SMY#_>bDc*J zFnXjm*4y<4gMU0E`r!_7p7^ydQ@-uh!HoI~2_6GW`Zs^lJ9ptu2~E*~TDx4Y>`&6T z$JCN?)UshWy=1OUhqg?)IT;@^E)(iXgS=W!e7Q>-1827IGr5G7eRLv{$Ey~nL=LlK zMX;>@@V~fI9fdQUiXTp`or%sqCvDkDAm?p~W%|0lhp%vDcvX0+qkrsmqt72$@Y>J& zf;2-$DC8^hQP3`_>x0Xqz(3Dd@D+bJqt0kN^`h)gJ+39Wp+RjLb7>2m5np36C-rMB z=IQ2|M{x<%xZKh{@P9TH%Xj+oqRTAJ!ZS`~!D@Zs>bWYyvQ7N#t!CYvK^OdG-H_S3 zYtGx?oK8gASn8Ih4^l0so{8sf;>l6gj8+=+q-a^IYpqM}hZdP=_F|M-(zk%(m%f|B z+ZF`5rG%|I0SEp^H8jkfVyCxrK4B~^T7w^U{a1AS&9Vo-$0~7PLN1K2|1#d4%U=2u z6w%Qf9E1|QDWIHcJantf!~Ud6kY7NV9xE)I=r7H*sqy;Pn)OC_uxU5l1rcKP&5&hm za<~wQ3@M}LF~hn^8~oxQI$EMB=nC`Wc?TGH5{qaO@E20^krTZWI)_@9iL+q$`>SP% zDXK^9VP8NDVrcTIcZMadrZDC(v5d&%2p<(v%W?r{gt%wy!2Sl?syK@c0uV$A1SKV~J5lHLjN!+lTY@{1pfn{;wi5Bzo8hZu)vj56EHp?sA<tDbFE@giw2%3$tSL*B6?+g^tINy2CR@0)E^4kqr8Q5i_z=*^FOn9afo6%0K^sDqC zPo;0o=v;+Nr)mM?$`~{1@Y%cfvBjUD!fe25=%4(K(~RIHtR2=~LCxC$P>ck9tOyyH zcJ|BjEIoNK8N(<>#8*6kP`s~Eb4w5OVFAOvF7&(lrP!KbH0 z&%{Vb|5*ii9E4dzMYt*eTwJywaADGkJX=lT{LNbjZ2YrFSSpo!TAJ~hLx4l5$3*smk2YAZ@Chv7c}I?J}VfJO+p02e6v*w4NKn7etG-qJiwK5}R$U9l-8| zi$tdjzX1gbhtVj`6U9`NHZwK;kfM`rufT?7u-xeAP#PNk`Y>fsh6#5e?tII0NF`u~ zpApDHuOtP&;?T)y1^4EC`FTnT9676*rw%-|+u^<2CeM+wTK~=4tjKpwG9;>WFnk3N zL@XHNh32tKQP2Kzk16XdA}rz7^o({q^Q0&VBAWM(JrAVXYx=xOwpqf$skJ znseVAFIo34%dghLnSM`48e*s_H$a&&ob9S|zzi5*XXK7lPHRA$E5TQtJDD~7W&P?P z7%Q9f2u1k1Z|?ID62RDiHz*e4mq7BZgv-)!@y7CVX)1mNzrZJ)Cc+>g_v&m za@O81xrA6Sd|61EYk3qKxPT5RD5o0OGJ5xx(1K9IF(c-y98;0v>IAtB1510&_{JYd z&=91BvE+6b((TzyOR;U3nU&WP3Y(*tj_Ss;_yk@}Qek4&4LyHVYf-J?r$yIohY37v zgAbf~HzHX@FWZ|z_PCdNz=IiZl|X7^r1sGv+}WVuFJ|NmXTWosP-Aj zoHG_3IkwVDZG`hcB68!TkLA~Xu@#Lz2A%o$BOXX%+!1EI@^}%QAjhNQ;w$b@Uq zMbPcd28r6^KV{7C7Z006q_;}iqOEnSZ{%Gt!d$&Ltq;?md(xu_qEfGD$JbFC=k;uW zcTs&@OG)}$qc~=;?Sayu91$ReMt+Nc+i4n#=xxWG?Ui$^4;Y%FdJ!e@#86*o7^kX6 z95mB9*S?>Xk|C^mGbgXFQeDo{Cd-|Dox@e9irdRIX^6c|ttu*u8M@lo9MrBM@v5Zp z(-*NljCH%Czsk6)|4JyO%C_nA2@;3jv7*IB2ys5#n0GA_ffxWa1S+s?xREPz9i}rz zhq*QZYpIrwM_AcX8i9q$r~azG$MvBbdn!AnhzpRR3Em}u8^B_gl8L>;e~k{$)iEGV zRQz5lAS-A2Bck3aGN?f_*rJtJV1X{2yCwo%8V-kft~VdAhY53FhA6EGw**zGLe%3| zAC=Div9#j8d~vOv<@XD=5XpO*fzf?S91t_0@Es|<+V>46u?MW)K(=4fUp)t{sz5Fh zrmj8@i{yJOL><``@JKOM$)8tCA28DWDDBxW=s&T5nWaivbeaHCa+O^_QXY)?uBCJW zbpc=bJ-?YL(SLb2Q?N`b9d5nHMG70znk|?vZ$ZptMK&mi&A8@JP6~Kc543KKH5p7h zrl=2yn}D~lKF=!E@H65NGNASf#ss&KZ8h^4Hfo(TO~Dat`bYC$lPR+y5dBs)wJ=c% zBOX63Or(OF0d;p%;!hgEfGPaZ>az5!!rJ!?8ZG=^2J^o8w`h7aP!&~aTW<_lQGNsF zWT3*Ed8Z6m*0|7YEP$U5*;RUKaRP>iqw^wd&l0Bv%(Ch_FSCE?)OSRaeeMp0ngK;z z&@5Ka?ThuLA^_q@rY1=|okS&Ip2K+m7O+xvxx;!Fg5eb$ZHIfT!P@|KV=O^DKmsV~ zp3SBJ6^F*>DPIP=^k}K+fjJV(j3mXcu~dBPqHO9!{O(e^MRXRUEr2;C|7LWF&lwP? z`d)+-1_Uw*B~DGm6ODN^!6H*;NPZqRgHya7*LqVT(o26GN<8<(i?~>&0~Le%VInJxIIgXv zS7E#X9Z%t&T4#c2s<#epFfC@qNo5Wq9Cm7B_(-%iVLwNVwQI+e)|##!wn+Wx{) z^pz7pzvcXWqMjS)ot>cfVw*q&qE9W+hU~GybU-EEY|^w7$eDquiGj+u-il#Aq5*)lTx? zVjMO@xL(L-YdQD{CzUr7PZKoQl^d`v-)0T3U6&26O~N<>Xl()XaBtXHFRZb!$s5i z4e*Pm#Iw|jiF9-Q;@8s^*-drr{B>F0u^J}_w~@xj>%ob`*5NVIIDP+icdb4RVD!Lj|-bf zo1*lv490bq*Z>p0hN3W|@IBxbH^O?=Lk0FX6xJ>$Pe7Zm-;8<1r9&;We%m?s^m1^z z@bO3NX1gZs7G=v*DpQT@ZR42V&8LzLxiQ!$KVb+KRsjoM*m#p)FlD|{--Q2D*R?)W+ab^e*v#bu=GA!tde@@UmYnnafi>PDxVeMVp>FzC??t7InmevV$oE8l#!6WXzqk?+iz(&GuaYp{&??|39>DIRy##|5T%l&Q~eNC{s z-$Wf%XsFOy_!Yyr?12W)`ZVtx8&DyH3$rgdwYB#f)AVAa)9%EAGFPRGdk;=2tI*(s z){iiu+~2M^9gYbSqnRNsR)UUI5U)3|c1{&a=&Fws(hm=iB>#*td`QSGegCTtzNr7L zSqGR=#fdSuf;^CdtUQN0<1I#>_tMazuo%V$sh`>1s8JK?9@FqxgOYDqkg|XkZOoBA zbglqAN1_wY6sEqq<>84E_=ZZq0SGw2tKXh($dxv-+IBMMhd_MBf*{qhr}InvCH)&u zl%=19@966RS0H)(1`Jq#%LS~YLVa8OV zd0y-ptuLjUGqv~gfVuIaxYC=Vk{9hUA}g@_379y#gC5I#LpSOSXx0a>w}jrfN2%wud$}15 zP4LIW zQnKQhKZ{fUdS15CJeyPfqQ7Z--_X0UN^f}aVq4dbX`c|WcUtPir5&qlq#)zDhLS0h zy7`G8QV~LuSpJ6a6^*%Hws01AnP1Ic6kOr%ZSUtr6R}sO#v=boq#ydXXo!{%OA}7c zefWYJu9<%)kve)Q_k`@?nFNJqp>@TIo;@_@m0BVKX?s%IE)%A^d-f{N5F2VMCLQ@9 zzaNXbOY#$DbiU(q(*3ugA7n2q@So_o&FI?iGf)TDt<_iHc?i2TmtuGS+-Ow*G15NckAPoW6a$5 zL~rU*u-~qZ~h$hcG44_)prN>wI6PacLGDEw1E^YcotWdh;>E<~J8< z;v1#AW1By09i5hEJym(JZm5=fiRc>?U(1=s5J+Virh5( zY$D`?L%NSmhph$fGIbAVuo!fW1DKVz(!P{c*dgql~ce?l-`DEYh|492Q#Tx!(|30Gijbyk> zmpNs?O87ghmHY6Y_>TPZ!RjOI6PY;=F^wAAVF8u?l?M{u7m6 z>(0R@=7PI6i)YK0tm#)^o0H2#n{-xa>scSz&XQEiC{$HCj+hKaQA=I8hRUEEd*-^30kp|p!! zkreO!s>Zex(&#zZ<&&F#vckwMbRK0uAWoDlwWi!&UNL=KvcGQe+mo$(aSP`05XkYF zE#A1uC}YTQx71%T<6DL1=Uqp-H+SqF)iPVFjxW{g7^#;^RgE{sPTyQ9H2KgQW^Wrz zjQX>@|9lL52q0N}T=VfU12V?0S^?yy9e58WM&?&=2*_^ovsmK~rYf|AXhr39{|-fV z;z>^}*xK<%-p-S7IssX3ESRt@AGMiCA>qI?dDqyg^7;zf=|I-%Bdsla#tR{RLAIL} zr=^nYYW+hALWzDvqRI9^e3yIn;?IpnAra)$S+ArXTcCEJ`t&p}U@Yw8Rta?{4Xf%gQ5Tc literal 42839 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 D;W=>R diff --git a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py index 9d6704972..6a4545348 100644 --- a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py +++ b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py @@ -1,12 +1,15 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe import itertools +import json import pytest from unittest import mock from openmmtools.multistate.multistatesampler import MultiStateSampler from openff.units import unit as offunit import mdtraj as mdt +import numpy as np import gufe +import openfe from openfe import ChemicalSystem, SolventComponent from openfe.protocols import openmm_afe from openfe.protocols.openmm_afe import ( @@ -472,17 +475,17 @@ def test_get_estimate(self, protocolresult): est = protocolresult.get_estimate() assert est - assert est.m == pytest.approx(-15.768768285032115) - assert isinstance(est, unit.Quantity) - assert est.is_compatible_with(unit.kilojoule_per_mole) + assert est.m == pytest.approx(-3.00208997) + assert isinstance(est, offunit.Quantity) + assert est.is_compatible_with(offunit.kilojoule_per_mole) def test_get_uncertainty(self, protocolresult): est = protocolresult.get_uncertainty() assert est - assert est.m == pytest.approx(0.03662634237353985) - assert isinstance(est, unit.Quantity) - assert est.is_compatible_with(unit.kilojoule_per_mole) + assert est.m == pytest.approx(0.1577349) + assert isinstance(est, offunit.Quantity) + assert est.is_compatible_with(offunit.kilojoule_per_mole) def test_get_individual(self, protocolresult): inds = protocolresult.get_individual_estimates() @@ -492,8 +495,8 @@ def test_get_individual(self, protocolresult): assert isinstance(inds['vacuum'], list) assert len(inds['solvent']) == len(inds['vacuum']) == 3 for e, u in itertools.chain(inds['solvent'], inds['vacuum']): - assert e.is_compatible_with(unit.kilojoule_per_mole) - assert u.is_compatible_with(unit.kilojoule_per_mole) + assert e.is_compatible_with(offunit.kilojoule_per_mole) + assert u.is_compatible_with(offunit.kilojoule_per_mole) @pytest.mark.parametrize('key', ['solvent', 'vacuum']) def test_get_forwards_etc(self, key, protocolresult): @@ -521,7 +524,7 @@ def test_get_overlap_matrices(self, key, protocolresult): ovp1 = ovp[key][0] assert isinstance(ovp1['matrix'], np.ndarray) - assert ovp1['matrix'].shape == (11,11) + assert ovp1['matrix'].shape == (15, 15) @pytest.mark.parametrize('key', ['solvent', 'vacuum']) def test_get_replica_transition_statistics(self, key, protocolresult): @@ -533,8 +536,8 @@ def test_get_replica_transition_statistics(self, key, protocolresult): rpx1 = rpx[key][0] assert 'eigenvalues' in rpx1 assert 'matrix' in rpx1 - assert rpx1['eigenvalues'].shape == (24,) - assert rpx1['matrix'].shape == (24, 24) + assert rpx1['eigenvalues'].shape == (15,) + assert rpx1['matrix'].shape == (15, 15) @pytest.mark.parametrize('key', ['solvent', 'vacuum']) def test_get_replica_states(self, key, protocolresult): @@ -543,7 +546,7 @@ def test_get_replica_states(self, key, protocolresult): assert isinstance(rep, dict) assert isinstance(rep[key], list) assert len(rep[key]) == 3 - assert rep[key][0].shape == (6, 24) + assert rep[key][0].shape == (251, 15) @pytest.mark.parametrize('key', ['solvent', 'vacuum']) def test_equilibration_iterations(self, key, protocolresult): From 00a46d5f086b2141aa1602b4cf0a6d829de69717 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 17 Oct 2023 05:32:07 +0100 Subject: [PATCH 68/74] some docstring fixes --- openfe/protocols/openmm_afe/equil_afe_settings.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_afe_settings.py b/openfe/protocols/openmm_afe/equil_afe_settings.py index b77ef892b..6a473f73d 100644 --- a/openfe/protocols/openmm_afe/equil_afe_settings.py +++ b/openfe/protocols/openmm_afe/equil_afe_settings.py @@ -4,13 +4,15 @@ """Settings class for equilibrium AFE Protocols using OpenMM + OpenMMTools This module implements the necessary settings necessary to run absolute free -energies using :class:`openfe.protocols.openmm_abfe.equil_abfe_methods.py` +energies using OpenMM. +See Also +-------- +openfe.protocols.openmm_afe.AbsoluteSolvationProtocol TODO ---- * Add support for restraints -* Improve this docstring by adding an example use case. """ from gufe.settings import ( From 1f9853ace2eed4c2ae3be51116b752fec0f6de1d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 17 Oct 2023 05:33:27 +0100 Subject: [PATCH 69/74] fixup type ignore comment --- openfe/tests/protocols/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/tests/protocols/conftest.py b/openfe/tests/protocols/conftest.py index 64c8c33c9..2f46d9704 100644 --- a/openfe/tests/protocols/conftest.py +++ b/openfe/tests/protocols/conftest.py @@ -160,4 +160,4 @@ def afe_solv_transformation_json() -> str: fname = "CN_absolute_solvation_transformation.json.gz" with gzip.open((d / fname).as_posix(), 'r') as f: # type: ignore - return f.read().decode() # type ignore + return f.read().decode() # type: ignore From 4ff48720996a98c09e3a99b7e6ef5835582a4741 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 17 Oct 2023 05:42:52 +0100 Subject: [PATCH 70/74] add results tokenization test --- .../test_solvation_afe_tokenization.py | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/openfe/tests/protocols/test_solvation_afe_tokenization.py b/openfe/tests/protocols/test_solvation_afe_tokenization.py index a34604e59..2f7c8a16c 100644 --- a/openfe/tests/protocols/test_solvation_afe_tokenization.py +++ b/openfe/tests/protocols/test_solvation_afe_tokenization.py @@ -1,8 +1,9 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe - +import json import openfe from openfe.protocols import openmm_afe +import gufe from gufe.tests.test_tokenization import GufeTokenizableTestsMixin import pytest @@ -41,6 +42,15 @@ def vacuum_protocol_unit(protocol_units): if isinstance(pu, openmm_afe.AbsoluteVacuumTransformUnit): return pu + +@pytest.fixture +def protocol_result(afe_solv_transformation_json): + d = json.loads(afe_solv_transformation_json, + cls=gufe.tokenization.JSON_HANDLER.decoder) + pr = openmm_afe.AbsoluteSolvationProtocolResult.from_dict(d['protocol_result']) + return pr + + class TestAbsoluteSolvationProtocol(GufeTokenizableTestsMixin): cls = openmm_afe.AbsoluteSolvationProtocol key = "AbsoluteSolvationProtocol-fd22076bcea777207beb86ef7a6ded81" @@ -75,3 +85,16 @@ def instance(self, vacuum_protocol_unit): def test_key_stable(self): pytest.skip() + + +class TestAbsoluteVacuumTransformUnit(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteSolvationProtocolResult + key = "AbsoluteSolvationProtocolResult-8caab27e7ad1bd544a787ac639f5f447" + repr = f"<{key}>" + + @pytest.fixture() + def instance(self, protocol_result): + return protocol_result + +# def test_key_stable(self): +# pytest.skip() From f938430777b67d55442457e213b4a117f0cb4b59 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 17 Oct 2023 05:43:28 +0100 Subject: [PATCH 71/74] cleanup docstring --- openfe/tests/protocols/test_solvation_afe_tokenization.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openfe/tests/protocols/test_solvation_afe_tokenization.py b/openfe/tests/protocols/test_solvation_afe_tokenization.py index 2f7c8a16c..2f8ff7697 100644 --- a/openfe/tests/protocols/test_solvation_afe_tokenization.py +++ b/openfe/tests/protocols/test_solvation_afe_tokenization.py @@ -7,10 +7,6 @@ from gufe.tests.test_tokenization import GufeTokenizableTestsMixin import pytest -""" -todo: -- AbsoluteSolvationProtocolResult -""" @pytest.fixture def protocol(): From ebfa4fdd0d24ba5d5e26dea6266271d146549d8c Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 17 Oct 2023 05:48:03 +0100 Subject: [PATCH 72/74] remove temp comments --- openfe/tests/protocols/test_solvation_afe_tokenization.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openfe/tests/protocols/test_solvation_afe_tokenization.py b/openfe/tests/protocols/test_solvation_afe_tokenization.py index 2f8ff7697..8e364f449 100644 --- a/openfe/tests/protocols/test_solvation_afe_tokenization.py +++ b/openfe/tests/protocols/test_solvation_afe_tokenization.py @@ -83,7 +83,7 @@ def test_key_stable(self): pytest.skip() -class TestAbsoluteVacuumTransformUnit(GufeTokenizableTestsMixin): +class TestAbsoluteSolvationProtocolResult(GufeTokenizableTestsMixin): cls = openmm_afe.AbsoluteSolvationProtocolResult key = "AbsoluteSolvationProtocolResult-8caab27e7ad1bd544a787ac639f5f447" repr = f"<{key}>" @@ -91,6 +91,3 @@ class TestAbsoluteVacuumTransformUnit(GufeTokenizableTestsMixin): @pytest.fixture() def instance(self, protocol_result): return protocol_result - -# def test_key_stable(self): -# pytest.skip() From 694e421ea195b8305c223a0afb9526eab47c6690 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 18 Oct 2023 07:45:03 +0100 Subject: [PATCH 73/74] fixup docstring --- .../protocols/openmm_afe/equil_solvation_afe_method.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 1341f3ce9..645f24108 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -60,15 +60,7 @@ class AbsoluteSolvationProtocolResult(gufe.ProtocolResult): - """Dict-like container for the output of a AbsoluteSolventTransform - - NOTE - ---- - The following items have yet to be implemented: - * Methods to retreive the overlap matrices - * Method to get replica transition stats - * Method to get replica states - * Method to get equilibration and production iterations + """Dict-like container for the output of a AbsoluteSolvationProtocol """ def __init__(self, **data): super().__init__(**data) From 6231c00919ea088c49c2b0207bd44fe6cb665357 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 18 Oct 2023 08:10:38 +0100 Subject: [PATCH 74/74] Fix up naming of ProtocolUnit to something more unique + shorter --- openfe/protocols/openmm_afe/__init__.py | 8 +++---- openfe/protocols/openmm_afe/base.py | 4 ++-- .../openmm_afe/equil_solvation_afe_method.py | 14 ++++++------- .../test_openmm_afe_solvation_protocol.py | 21 ++++++++++--------- .../test_solvation_afe_tokenization.py | 16 +++++++------- 5 files changed, 32 insertions(+), 31 deletions(-) diff --git a/openfe/protocols/openmm_afe/__init__.py b/openfe/protocols/openmm_afe/__init__.py index def779d7b..40c10e8aa 100644 --- a/openfe/protocols/openmm_afe/__init__.py +++ b/openfe/protocols/openmm_afe/__init__.py @@ -9,14 +9,14 @@ AbsoluteSolvationProtocol, AbsoluteSolvationSettings, AbsoluteSolvationProtocolResult, - AbsoluteVacuumTransformUnit, - AbsoluteSolventTransformUnit, + AbsoluteSolvationVacuumUnit, + AbsoluteSolvationSolventUnit, ) __all__ = [ "AbsoluteSolvationProtocol", "AbsoluteSolvationSettings", "AbsoluteSolvationProtocolResult", - "AbsoluteVacuumTransformUnit", - "AbsoluteSolventTransformUnit", + "AbsoluteVacuumUnit", + "AbsoluteSolventUnit", ] diff --git a/openfe/protocols/openmm_afe/base.py b/openfe/protocols/openmm_afe/base.py index c551be008..e66508771 100644 --- a/openfe/protocols/openmm_afe/base.py +++ b/openfe/protocols/openmm_afe/base.py @@ -5,7 +5,7 @@ Base classes for the equilibrium OpenMM absolute free energy ProtocolUnits. -Thist mostly implements BaseAbsoluteTransformUnit whose methods can be +Thist mostly implements BaseAbsoluteUnit whose methods can be overriden to define different types of alchemical transformations. TODO @@ -63,7 +63,7 @@ logger = logging.getLogger(__name__) -class BaseAbsoluteTransformUnit(gufe.ProtocolUnit): +class BaseAbsoluteUnit(gufe.ProtocolUnit): """ Base class for ligand absolute free energy transformations. """ diff --git a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py index 645f24108..c259562b4 100644 --- a/openfe/protocols/openmm_afe/equil_solvation_afe_method.py +++ b/openfe/protocols/openmm_afe/equil_solvation_afe_method.py @@ -53,7 +53,7 @@ SettingsBaseModel, ) from ..openmm_utils import system_validation, settings_validation -from .base import BaseAbsoluteTransformUnit +from .base import BaseAbsoluteUnit from openfe.utils import without_oechem_backend, log_system_probe logger = logging.getLogger(__name__) @@ -322,8 +322,8 @@ class AbsoluteSolvationProtocol(gufe.Protocol): openfe.protocols openfe.protocols.openmm_afe.AbsoluteSolvationSettings openfe.protocols.openmm_afe.AbsoluteSolvationProtocolResult - openfe.protocols.openmm_afe.AbsoluteVacuumTransformUnit - openfe.protocols.openmm_afe.AbsoluteSolventTransformUnit + openfe.protocols.openmm_afe.AbsoluteSolvationVacuumUnit + openfe.protocols.openmm_afe.AbsoluteSolvationSolventUnit """ result_cls = AbsoluteSolvationProtocolResult _settings: AbsoluteSolvationSettings @@ -499,7 +499,7 @@ def _create( # Create list units for vacuum and solvent transforms solvent_units = [ - AbsoluteSolventTransformUnit( + AbsoluteSolvationSolventUnit( stateA=stateA, stateB=stateB, settings=self.settings, alchemical_components=alchem_comps, @@ -511,7 +511,7 @@ def _create( ] vacuum_units = [ - AbsoluteVacuumTransformUnit( + AbsoluteSolvationVacuumUnit( # These don't really reflect the actual transform # Should these be overriden to be ChemicalSystem{smc} -> ChemicalSystem{} ? stateA=stateA, stateB=stateB, @@ -554,7 +554,7 @@ def _gather( return repeats -class AbsoluteVacuumTransformUnit(BaseAbsoluteTransformUnit): +class AbsoluteSolvationVacuumUnit(BaseAbsoluteUnit): def _get_components(self) -> tuple[dict[str, list[Component]], None, Optional[ProteinComponent], list[SmallMoleculeComponent]]: @@ -642,7 +642,7 @@ def _execute( } -class AbsoluteSolventTransformUnit(BaseAbsoluteTransformUnit): +class AbsoluteSolvationSolventUnit(BaseAbsoluteUnit): def _get_components(self) -> tuple[list[Component], SolventComponent, Optional[ProteinComponent], list[SmallMoleculeComponent]]: diff --git a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py index 6a4545348..14a7b5c9c 100644 --- a/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py +++ b/openfe/tests/protocols/test_openmm_afe_solvation_protocol.py @@ -13,7 +13,8 @@ from openfe import ChemicalSystem, SolventComponent from openfe.protocols import openmm_afe from openfe.protocols.openmm_afe import ( - AbsoluteSolventTransformUnit, AbsoluteVacuumTransformUnit, + AbsoluteSolvationSolventUnit, + AbsoluteSolvationVacuumUnit, AbsoluteSolvationProtocol, ) from openfe.protocols.openmm_utils import system_validation @@ -222,9 +223,9 @@ def test_dry_run_vac_benzene(benzene_modifications, assert len(prot_units) == 2 vac_unit = [u for u in prot_units - if isinstance(u, AbsoluteVacuumTransformUnit)] + if isinstance(u, AbsoluteSolvationVacuumUnit)] sol_unit = [u for u in prot_units - if isinstance(u, AbsoluteSolventTransformUnit)] + if isinstance(u, AbsoluteSolvationSolventUnit)] assert len(vac_unit) == 1 assert len(sol_unit) == 1 @@ -264,9 +265,9 @@ def test_dry_run_solv_benzene(benzene_modifications, tmpdir): assert len(prot_units) == 2 vac_unit = [u for u in prot_units - if isinstance(u, AbsoluteVacuumTransformUnit)] + if isinstance(u, AbsoluteSolvationVacuumUnit)] sol_unit = [u for u in prot_units - if isinstance(u, AbsoluteSolventTransformUnit)] + if isinstance(u, AbsoluteSolvationSolventUnit)] assert len(vac_unit) == 1 assert len(sol_unit) == 1 @@ -313,7 +314,7 @@ def test_dry_run_solv_benzene_tip4p(benzene_modifications, tmpdir): prot_units = list(dag.protocol_units) sol_unit = [u for u in prot_units - if isinstance(u, AbsoluteSolventTransformUnit)] + if isinstance(u, AbsoluteSolvationSolventUnit)] with tmpdir.as_cwd(): sol_sampler = sol_unit[0].run(dry=True)['debug']['sampler'] @@ -408,9 +409,9 @@ def test_unit_tagging(benzene_solvation_dag, tmpdir): dag_units = benzene_solvation_dag.protocol_units with ( - mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolventTransformUnit.run', + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolvationSolventUnit.run', return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), - mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteVacuumTransformUnit.run', + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolvationVacuumUnit.run', return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), ): results = [] @@ -434,9 +435,9 @@ def test_unit_tagging(benzene_solvation_dag, tmpdir): def test_gather(benzene_solvation_dag, tmpdir): # check that .gather behaves as expected with ( - mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolventTransformUnit.run', + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolvationSolventUnit.run', return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), - mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteVacuumTransformUnit.run', + mock.patch('openfe.protocols.openmm_afe.equil_solvation_afe_method.AbsoluteSolvationVacuumUnit.run', return_value={'nc': 'file.nc', 'last_checkpoint': 'chck.nc'}), ): dagres = gufe.protocols.execute_DAG(benzene_solvation_dag, diff --git a/openfe/tests/protocols/test_solvation_afe_tokenization.py b/openfe/tests/protocols/test_solvation_afe_tokenization.py index 8e364f449..576655c2a 100644 --- a/openfe/tests/protocols/test_solvation_afe_tokenization.py +++ b/openfe/tests/protocols/test_solvation_afe_tokenization.py @@ -28,14 +28,14 @@ def protocol_units(protocol, benzene_system): @pytest.fixture def solvent_protocol_unit(protocol_units): for pu in protocol_units: - if isinstance(pu, openmm_afe.AbsoluteSolventTransformUnit): + if isinstance(pu, openmm_afe.AbsoluteSolvationSolventUnit): return pu @pytest.fixture def vacuum_protocol_unit(protocol_units): for pu in protocol_units: - if isinstance(pu, openmm_afe.AbsoluteVacuumTransformUnit): + if isinstance(pu, openmm_afe.AbsoluteSolvationVacuumUnit): return pu @@ -57,9 +57,9 @@ def instance(self, protocol): return protocol -class TestAbsoluteSolventTransformUnit(GufeTokenizableTestsMixin): - cls = openmm_afe.AbsoluteSolventTransformUnit - repr = "AbsoluteSolventTransformUnit(Absolute Solvation, benzene solvent leg: repeat 2 generation 0)" +class TestAbsoluteSolvationSolventUnit(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteSolvationSolventUnit + repr = "AbsoluteSolvationSolventUnit(Absolute Solvation, benzene solvent leg: repeat 2 generation 0)" key = None @pytest.fixture() @@ -70,9 +70,9 @@ def test_key_stable(self): pytest.skip() -class TestAbsoluteVacuumTransformUnit(GufeTokenizableTestsMixin): - cls = openmm_afe.AbsoluteVacuumTransformUnit - repr = "AbsoluteVacuumTransformUnit(Absolute Solvation, benzene vacuum leg: repeat 2 generation 0)" +class TestAbsoluteSolvationVacuumUnit(GufeTokenizableTestsMixin): + cls = openmm_afe.AbsoluteSolvationVacuumUnit + repr = "AbsoluteSolvationVacuumUnit(Absolute Solvation, benzene vacuum leg: repeat 2 generation 0)" key = None @pytest.fixture()