From 43d60073540a84f2338709932e0de9d4450c5fe0 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 5 Dec 2025 13:18:52 +0000 Subject: [PATCH 01/91] Migrate validation to Protocol._validate --- .../protocols/openmm_rfe/equil_rfe_methods.py | 434 +++++++++++------- 1 file changed, 260 insertions(+), 174 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 514237634..f272631b8 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -117,161 +117,6 @@ def _get_resname(off_mol) -> str: return names[0] -def _get_alchemical_charge_difference( - mapping: LigandAtomMapping, - nonbonded_method: str, - explicit_charge_correction: bool, - solvent_component: SolventComponent, -) -> int: - """ - Checks and returns the difference in formal charge between state A and B. - - Raises - ------ - ValueError - * If an explicit charge correction is attempted and the - nonbonded method is not PME. - * If the absolute charge difference is greater than one - and an explicit charge correction is attempted. - UserWarning - If there is any charge difference. - - Parameters - ---------- - mapping : dict[str, ComponentMapping] - Dictionary of mappings between transforming components. - nonbonded_method : str - The OpenMM nonbonded method used for the simulation. - explicit_charge_correction : bool - Whether or not to use an explicit charge correction. - solvent_component : openfe.SolventComponent - The SolventComponent of the simulation. - - Returns - ------- - int - The formal charge difference between states A and B. - This is defined as sum(charge state A) - sum(charge state B) - """ - - difference = mapping.get_alchemical_charge_difference() - - if abs(difference) > 0: - if explicit_charge_correction: - if nonbonded_method.lower() != "pme": - errmsg = "Explicit charge correction when not using PME is not currently supported." - raise ValueError(errmsg) - if abs(difference) > 1: - errmsg = ( - f"A charge difference of {difference} is observed " - "between the end states and an explicit charge " - "correction has been requested. Unfortunately " - "only absolute differences of 1 are supported." - ) - raise ValueError(errmsg) - - ion = {-1: solvent_component.positive_ion, 1: solvent_component.negative_ion}[ - difference - ] - wmsg = ( - f"A charge difference of {difference} is observed " - "between the end states. This will be addressed by " - f"transforming a water into a {ion} ion" - ) - logger.warning(wmsg) - warnings.warn(wmsg) - else: - wmsg = ( - f"A charge difference of {difference} is observed " - "between the end states. No charge correction has " - "been requested, please account for this in your " - "final results." - ) - logger.warning(wmsg) - warnings.warn(wmsg) - - return difference - - -def _validate_alchemical_components( - alchemical_components: dict[str, list[Component]], - mapping: Optional[Union[ComponentMapping, list[ComponentMapping]]], -): - """ - Checks that the alchemical components are suitable for the RFE protocol. - - Specifically we check: - 1. That all alchemical components are mapped. - 2. That all alchemical components are SmallMoleculeComponents. - 3. If the mappings involves element changes in core atoms - - Parameters - ---------- - alchemical_components : dict[str, list[Component]] - Dictionary contatining the alchemical components for - states A and B. - mapping : Optional[Union[ComponentMapping, list[ComponentMapping]]] - all mappings between transforming components. - - Raises - ------ - ValueError - * If there are more than one mapping or mapping is None - * If there are any unmapped alchemical components. - * If there are any alchemical components that are not - SmallMoleculeComponents. - UserWarning - * Mappings which involve element changes in core atoms - """ - if isinstance(mapping, ComponentMapping): - mapping = [mapping] - # Check mapping - # For now we only allow for a single mapping, this will likely change - if mapping is None or len(mapping) != 1: - errmsg = "A single LigandAtomMapping is expected for this Protocol" - raise ValueError(errmsg) - - # Check that all alchemical components are mapped & small molecules - mapped = { - "stateA": [m.componentA for m in mapping], - "stateB": [m.componentB for m in mapping], - } - - for idx in ["stateA", "stateB"]: - if len(alchemical_components[idx]) != len(mapped[idx]): - errmsg = f"missing alchemical components in {idx}" - raise ValueError(errmsg) - for comp in alchemical_components[idx]: - if comp not in mapped[idx]: - raise ValueError(f"Unmapped alchemical component {comp}") - if not isinstance(comp, SmallMoleculeComponent): # pragma: no-cover - errmsg = ( - "Transformations involving non " - "SmallMoleculeComponent species {comp} " - "are not currently supported" - ) - raise ValueError(errmsg) - - # Validate element changes in mappings - for m in mapping: - molA = m.componentA.to_rdkit() - molB = m.componentB.to_rdkit() - for i, j in m.componentA_to_componentB.items(): - atomA = molA.GetAtomWithIdx(i) - atomB = molB.GetAtomWithIdx(j) - if atomA.GetAtomicNum() != atomB.GetAtomicNum(): - wmsg = ( - f"Element change in mapping between atoms " - f"Ligand A: {i} (element {atomA.GetAtomicNum()}) and " - f"Ligand B: {j} (element {atomB.GetAtomicNum()})\n" - "No mass scaling is attempted in the hybrid topology, " - "the average mass of the two atoms will be used in the " - "simulation" - ) - logger.warning(wmsg) - warnings.warn(wmsg) # TODO: remove this once logging is fixed - - class RelativeHybridTopologyProtocolResult(gufe.ProtocolResult): """Dict-like container for the output of a RelativeHybridTopologyProtocol""" @@ -612,21 +457,204 @@ def _adaptive_settings( return protocol_settings - def _create( + @staticmethod + def _validate_endstates( + stateA: ChemicalSystem, + stateB: ChemicalSystem, + ) -> None: + """ + Validates the end states for the RFE protocol. + + Parameters + ---------- + stateA : ChemicalSystem + The chemical system of end state A. + stateB : ChemicalSystem + The chemical system of end state B. + + Raises + ------ + ValueError + * If either state contains more than one unique Component. + * If unique components are not SmallMoleculeComponents. + """ + # Get the difference in Components between each state + diff = stateA.component_diff(stateB) + + for i, entry in enumerate(diff): + state_label = "A" if i == 0 else "B" + + # Check that there is only one unique Component in each state + if len(entry) != 0: + errmsg = ( + "Only one alchemical component is allowed per end state. " + f"Found {len(entry)} in state {state_label}." + ) + raise ValueError(errmsg) + + # Check that the unique Component is a SmallMoleculeComponent + if not isinstance(entry[0], SmallMoleculeComponent): + errmsg = ( + f"Alchemical component in state {state_label} is of type " + f"{type(entry[0])}, but only SmallMoleculeComponents " + "transformations are currently supported." + ) + raise ValueError(errmsg) + + @staticmethod + def _validate_mapping( + mapping: Optional[Union[ComponentMapping, list[ComponentMapping]]], + alchemical_components: dict[str, list[Component]], + ) -> None: + """ + Validates that the provided mapping(s) are suitable for the RFE protocol. + + Parameters + ---------- + mapping : Optional[Union[ComponentMapping, list[ComponentMapping]]] + all mappings between transforming components. + alchemical_components : dict[str, list[Component]] + Dictionary contatining the alchemical components for + states A and B. + + Raises + ------ + ValueError + * If there are more than one mapping or mapping is None + * If the mapping components are not in the alchemical components. + UserWarning + * Mappings which involve element changes in core atoms + """ + # if a single mapping is provided, convert to list + if isinstance(mapping, ComponentMapping): + mapping = [mapping] + + # For now we only support a single mapping + if mapping is None or len(mapping) > 1: + errmsg = "A single LigandAtomMapping is expected for this Protocol" + raise ValueError(errmsg) + + # check that the mapping components are in the alchemical components + for m in mapping: + if m.componentA not in alchemical_components["stateA"]: + raise ValueError(f"Mapping componentA {m.componentA} not in alchemical components of stateA") + if m.componentB not in alchemical_components["stateB"]: + raise ValueError(f"Mapping componentB {m.componentB} not in alchemical components of stateB") + + # TODO: remove - this is now the default behaviour? + # Check for element changes in mappings + for m in mapping: + molA = m.componentA.to_rdkit() + molB = m.componentB.to_rdkit() + for i, j in m.componentA_to_componentB.items(): + atomA = molA.GetAtomWithIdx(i) + atomB = molB.GetAtomWithIdx(j) + if atomA.GetAtomicNum() != atomB.GetAtomicNum(): + wmsg = ( + f"Element change in mapping between atoms " + f"Ligand A: {i} (element {atomA.GetAtomicNum()}) and " + f"Ligand B: {j} (element {atomB.GetAtomicNum()})\n" + "No mass scaling is attempted in the hybrid topology, " + "the average mass of the two atoms will be used in the " + "simulation" + ) + logger.warning(wmsg) + warnings.warn(wmsg) + + @staticmethod + def _validate_charge_difference( + mapping: LigandAtomMapping, + nonbonded_method: str, + explicit_charge_correction: bool, + solvent_component: SolventComponent | None, + ): + """ + Validates the net charge difference between the two states. + + Parameters + ---------- + mapping : dict[str, ComponentMapping] + Dictionary of mappings between transforming components. + nonbonded_method : str + The OpenMM nonbonded method used for the simulation. + explicit_charge_correction : bool + Whether or not to use an explicit charge correction. + solvent_component : openfe.SolventComponent | None + The SolventComponent of the simulation. + + Raises + ------ + ValueError + * If an explicit charge correction is attempted and the + nonbonded method is not PME. + * If the absolute charge difference is greater than one + and an explicit charge correction is attempted. + UserWarning + * If there is any charge difference. + """ + difference = mapping.get_alchemical_charge_difference() + + if abs(difference) == 0: + return + + if not explicit_charge_correction: + wmsg = ( + f"A charge difference of {difference} is observed " + "between the end states. No charge correction has " + "been requested, please account for this in your " + "final results." + ) + logger.warning(wmsg) + warnings.warn(wmsg) + return + + # We implicitly check earlier that we have to have pme for a solvated + # system, so we only need to check the nonbonded method here + if nonbonded_method.lower() != "pme": + errmsg = "Explicit charge correction when not using PME is not currently supported." + raise ValueError(errmsg) + + if abs(difference) > 1: + errmsg = ( + f"A charge difference of {difference} is observed " + "between the end states and an explicit charge " + "correction has been requested. Unfortunately " + "only absolute differences of 1 are supported." + ) + raise ValueError(errmsg) + + ion = { + -1: solvent_component.positive_ion, + 1: solvent_component.negative_ion + }[difference] + + wmsg = ( + f"A charge difference of {difference} is observed " + "between the end states. This will be addressed by " + f"transforming a water into a {ion} ion" + ) + logger.info(wmsg) + + def _validate( self, stateA: ChemicalSystem, stateB: ChemicalSystem, - mapping: Optional[Union[gufe.ComponentMapping, list[gufe.ComponentMapping]]], - extends: Optional[gufe.ProtocolDAGResult] = None, - ) -> list[gufe.ProtocolUnit]: - # TODO: Extensions? + mapping: gufe.ComponentMapping | list[gufe.ComponentMapping] | None, + extends: gufe.ProtocolDAGResult | None = None, + ) -> None: + # Check we're not trying to extend if extends: - raise NotImplementedError("Can't extend simulations yet") + # This technically should be NotImplementedError + # but gufe.Protocol.validate calls `_validate` wrapped around an + # except for NotImplementedError, so we can't raise it here + raise ValueError("Can't extend simulations yet") - # Get alchemical components & validate them + mapping + # Validate the end states + self._validate_endstates(stateA, stateB) + + # Valildate the mapping alchem_comps = system_validation.get_alchemical_components(stateA, stateB) - _validate_alchemical_components(alchem_comps, mapping) - ligandmapping = mapping[0] if isinstance(mapping, list) else mapping + self._validate_mapping(mapping, alchem_comps) # Validate solvent component nonbond = self.settings.forcefield_settings.nonbonded_method @@ -638,11 +666,78 @@ def _create( # Validate protein component system_validation.validate_protein(stateA) + # Validate charge difference + # Note: validation depends on the mapping & solvent component checks + if stateA.contains(SolventComponent): + solv_comp = stateA.get_components_of_type(SolventComponent)[0] + else: + solv_comp = None + + self._validate_charge_difference( + mapping=mapping[0] if isinstance(mapping, list) else mapping, + nonbonded_method=self.settings.forcefield_settings.nonbonded_method, + explicit_charge_correction=self.settings.alchemical_settings.explicit_charge_correction, + solvent_component=solv_comp, + ) + + # Validate integrator things + settings_validation.validate_timestep( + self.settings.forcefield_settings.hydrogen_mass, + self.settings.integrator_settings.timestep, + ) + + _ = settings_validation.convert_steps_per_iteration( + simulation_settings=self.settings.simulation_settings, + integrator_settings=self.settings.integrator_settings, + ) + + _ = settings_validation.get_simsteps( + sim_length=self.settings.simulation_settings.equilibration_length, + timestep=self.settings.integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + _ = settings_validation.get_simsteps( + sim_length=self.settings.simulation_settings.production_length, + timestep=self.settings.integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + _ = settings_validation.convert_checkpoint_interval_to_iterations( + checkpoint_interval=self.settings.output_settings.checkpoint_interval, + time_per_iteration=self.settings.simulation_settings.time_per_iteration, + ) + + # Validate alchemical settings + # PR #125 temporarily pin lambda schedule spacing to n_replicas + if self.settings.simulation_settings.n_replicas != self.settings.lambda_settings.n_windows: + errmsg = ( + "Number of replicas in simulation_settings must equal " + "number of lambda windows in lambda_settings." + ) + raise ValueError(errmsg) + + def _create( + self, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: Optional[Union[gufe.ComponentMapping, list[gufe.ComponentMapping]]], + extends: Optional[gufe.ProtocolDAGResult] = None, + ) -> list[gufe.ProtocolUnit]: + # validate inputs + self.validate(stateA=stateA, stateB=stateB, mapping=mapping, extends=extends) + + # get alchemical components and mapping + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + ligandmapping = mapping[0] if isinstance(mapping, list) else mapping + # actually create and return Units Anames = ",".join(c.name for c in alchem_comps["stateA"]) Bnames = ",".join(c.name for c in alchem_comps["stateB"]) + # our DAG has no dependencies, so just list units n_repeats = self.settings.protocol_repeats + units = [ RelativeHybridTopologyProtocolUnit( protocol=self, @@ -816,10 +911,6 @@ def run( output_settings: MultiStateOutputSettings = protocol_settings.output_settings integrator_settings: IntegratorSettings = protocol_settings.integrator_settings - # is the timestep good for the mass? - settings_validation.validate_timestep( - forcefield_settings.hydrogen_mass, integrator_settings.timestep - ) # TODO: Also validate various conversions? # Convert various time based inputs to steps/iterations steps_per_iteration = settings_validation.convert_steps_per_iteration( @@ -842,12 +933,7 @@ def run( # Get the change difference between the end states # and check if the charge correction used is appropriate - charge_difference = _get_alchemical_charge_difference( - mapping, - forcefield_settings.nonbonded_method, - alchem_settings.explicit_charge_correction, - solvent_comp, - ) + charge_difference = mapping.get_alchemical_charge_difference() # 1. Create stateA system self.logger.info("Parameterizing molecules") From 2cd56ba75a7f5b602521a94def4a72a11750dee7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 5 Dec 2025 13:25:43 +0000 Subject: [PATCH 02/91] some fixes --- openfe/protocols/openmm_rfe/equil_rfe_methods.py | 6 +++--- .../tests/protocols/openmm_rfe/test_hybrid_top_protocol.py | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index f272631b8..be6c1d39a 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -485,7 +485,7 @@ def _validate_endstates( state_label = "A" if i == 0 else "B" # Check that there is only one unique Component in each state - if len(entry) != 0: + if len(entry) != 1: errmsg = ( "Only one alchemical component is allowed per end state. " f"Found {len(entry)} in state {state_label}." @@ -686,7 +686,7 @@ def _validate( self.settings.integrator_settings.timestep, ) - _ = settings_validation.convert_steps_per_iteration( + steps_per_iteration = settings_validation.convert_steps_per_iteration( simulation_settings=self.settings.simulation_settings, integrator_settings=self.settings.integrator_settings, ) @@ -710,7 +710,7 @@ def _validate( # Validate alchemical settings # PR #125 temporarily pin lambda schedule spacing to n_replicas - if self.settings.simulation_settings.n_replicas != self.settings.lambda_settings.n_windows: + if self.settings.simulation_settings.n_replicas != self.settings.lambda_settings.lambda_windows: errmsg = ( "Number of replicas in simulation_settings must equal " "number of lambda windows in lambda_settings." diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index f5ea92cff..1f178991a 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -30,10 +30,6 @@ from openfe import setup from openfe.protocols import openmm_rfe from openfe.protocols.openmm_rfe._rfe_utils import topologyhelpers -from openfe.protocols.openmm_rfe.equil_rfe_methods import ( - _get_alchemical_charge_difference, - _validate_alchemical_components, -) from openfe.protocols.openmm_utils import omm_compute, system_creation from openfe.protocols.openmm_utils.charge_generation import ( HAS_ESPALOMA_CHARGE, From f3305622f3d147bd22464bb8726cb12551c41c4a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 8 Dec 2025 13:34:41 +0000 Subject: [PATCH 03/91] move some things around --- .../protocols/openmm_rfe/equil_rfe_methods.py | 74 +++- .../openmm_rfe/test_hybrid_top_validation.py | 391 ++++++++++++++++++ 2 files changed, 445 insertions(+), 20 deletions(-) create mode 100644 openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index be6c1d39a..59e33ef52 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -635,6 +635,55 @@ def _validate_charge_difference( ) logger.info(wmsg) + @staticmethod + def _validate_simulation_settings( + simulation_settings, + integrator_settings, + output_settings, + ): + + steps_per_iteration = settings_validation.convert_steps_per_iteration( + simulation_settings=simulation_settings, + integrator_settings=integrator_settings, + ) + + _ = settings_validation.get_simsteps( + sim_length=simulation_settings.equilibration_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + _ = settings_validation.get_simsteps( + sim_length=simulation_settings.production_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + _ = settings_validation.convert_checkpoint_interval_to_iterations( + checkpoint_interval=output_settings.checkpoint_interval, + time_per_iteration=simulation_settings.time_per_iteration, + ) + + if output_settings.positions_write_frequency is not None: + _ = settings_validation.divmod_time_and_check( + numerator=output_settings.positions_write_frequency, + denominator=sampler_settings.time_per_iteration, + numerator_name="output settings' position_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + + if output_settings.velocities_write_frequency is not None: + _ = settings_validation.divmod_time_and_check( + numerator=output_settings.velocities_write_frequency, + denominator=sampler_settings.time_per_iteration, + numerator_name="output settings' velocity_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + + _, _ = settings_validation.convert_real_time_analysis_iterations( + simulation_settings=sampler_settings, + ) + def _validate( self, stateA: ChemicalSystem, @@ -686,26 +735,11 @@ def _validate( self.settings.integrator_settings.timestep, ) - steps_per_iteration = settings_validation.convert_steps_per_iteration( - simulation_settings=self.settings.simulation_settings, - integrator_settings=self.settings.integrator_settings, - ) - - _ = settings_validation.get_simsteps( - sim_length=self.settings.simulation_settings.equilibration_length, - timestep=self.settings.integrator_settings.timestep, - mc_steps=steps_per_iteration, - ) - - _ = settings_validation.get_simsteps( - sim_length=self.settings.simulation_settings.production_length, - timestep=self.settings.integrator_settings.timestep, - mc_steps=steps_per_iteration, - ) - - _ = settings_validation.convert_checkpoint_interval_to_iterations( - checkpoint_interval=self.settings.output_settings.checkpoint_interval, - time_per_iteration=self.settings.simulation_settings.time_per_iteration, + # Validate simulation & output settings + self._validate_simulation_settings( + self.settings.simulation_settings, + self.settings.integrator_settings, + self.settings.output_settings, ) # Validate alchemical settings diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py new file mode 100644 index 000000000..62c8e5d23 --- /dev/null +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -0,0 +1,391 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import copy +import json +import sys +import xml.etree.ElementTree as ET +from importlib import resources +from math import sqrt +from pathlib import Path +from unittest import mock + +import gufe +import mdtraj as mdt +import numpy as np +import pytest +from kartograf import KartografAtomMapper +from kartograf.atom_aligner import align_mol_shape +from numpy.testing import assert_allclose +from openff.toolkit import Molecule +from openff.units import unit +from openff.units.openmm import ensure_quantity, from_openmm, to_openmm +from openmm import CustomNonbondedForce, MonteCarloBarostat, NonbondedForce, XmlSerializer, app +from openmm import unit as omm_unit +from openmmforcefields.generators import SMIRNOFFTemplateGenerator +from openmmtools.multistate.multistatesampler import MultiStateSampler +from rdkit import Chem +from rdkit.Geometry import Point3D + +import openfe +from openfe import setup +from openfe.protocols import openmm_rfe +from openfe.protocols.openmm_rfe._rfe_utils import topologyhelpers +from openfe.protocols.openmm_utils import omm_compute, system_creation +from openfe.protocols.openmm_utils.charge_generation import ( + HAS_ESPALOMA_CHARGE, + HAS_NAGL, + HAS_OPENEYE, +) + + +@pytest.fixture() +def vac_settings(): + settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() + settings.forcefield_settings.nonbonded_method = "nocutoff" + settings.engine_settings.compute_platform = None + settings.protocol_repeats = 1 + return settings + + +@pytest.fixture() +def solv_settings(): + settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() + settings.engine_settings.compute_platform = None + settings.protocol_repeats = 1 + return settings + + +def test_invalid_protocol_repeats(): + settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() + with pytest.raises(ValueError, match="must be a positive value"): + settings.protocol_repeats = -1 + + +@pytest.mark.parametrize( + "mapping", + [None, [], ["A", "B"]], +) +def test_validate_alchemical_components_wrong_mappings(mapping): + with pytest.raises(ValueError, match="A single LigandAtomMapping"): + _validate_alchemical_components({"stateA": [], "stateB": []}, mapping) + + +def test_validate_alchemical_components_missing_alchem_comp(benzene_to_toluene_mapping): + alchem_comps = {"stateA": [openfe.SolventComponent()], "stateB": []} + with pytest.raises(ValueError, match="Unmapped alchemical component"): + _validate_alchemical_components(alchem_comps, benzene_to_toluene_mapping) + + +def test_hightimestep( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + vac_settings, + tmpdir, +): + vac_settings.forcefield_settings.hydrogen_mass = 1.0 + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=vac_settings, + ) + + dag = p.create( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + ) + dag_unit = list(dag.protocol_units)[0] + + errmsg = "too large for hydrogen mass" + with tmpdir.as_cwd(): + with pytest.raises(ValueError, match=errmsg): + dag_unit.run(dry=True) + + +def test_n_replicas_not_n_windows( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + vac_settings, + tmpdir, +): + # For PR #125 we pin such that the number of lambda windows + # equals the numbers of replicas used - TODO: remove limitation + # default lambda windows is 11 + vac_settings.simulation_settings.n_replicas = 13 + + 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_rfe.RelativeHybridTopologyProtocol( + settings=vac_settings, + ) + dag = p.create( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + ) + dag_unit = list(dag.protocol_units)[0] + dag_unit.run(dry=True) + + +def test_missing_ligand(benzene_system, benzene_to_toluene_mapping): + # state B doesn't have a ligand component + stateB = openfe.ChemicalSystem({"solvent": openfe.SolventComponent()}) + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + + match_str = "missing alchemical components in stateB" + with pytest.raises(ValueError, match=match_str): + _ = p.create( + stateA=benzene_system, + stateB=stateB, + mapping=benzene_to_toluene_mapping, + ) + + +def test_vaccuum_PME_error( + benzene_vacuum_system, benzene_modifications, benzene_to_toluene_mapping +): + # state B doesn't have a solvent component (i.e. its vacuum) + stateB = openfe.ChemicalSystem({"ligand": benzene_modifications["toluene"]}) + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + errmsg = "PME cannot be used for vacuum transform" + with pytest.raises(ValueError, match=errmsg): + _ = p.create( + stateA=benzene_vacuum_system, + stateB=stateB, + mapping=benzene_to_toluene_mapping, + ) + + +def test_incompatible_solvent(benzene_system, benzene_modifications, benzene_to_toluene_mapping): + # the solvents are different + stateB = openfe.ChemicalSystem( + { + "ligand": benzene_modifications["toluene"], + "solvent": openfe.SolventComponent(positive_ion="K", negative_ion="Cl"), + } + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + # We don't have a way to map non-ligand components so for now it + # just triggers that it's not a mapped component + errmsg = "missing alchemical components in stateA" + with pytest.raises(ValueError, match=errmsg): + _ = p.create( + stateA=benzene_system, + stateB=stateB, + mapping=benzene_to_toluene_mapping, + ) + + +def test_mapping_mismatch_A(benzene_system, toluene_system, benzene_modifications): + # the atom mapping doesn't refer to the ligands in the systems + mapping = setup.LigandAtomMapping( + componentA=benzene_system.components["ligand"], + componentB=benzene_modifications["phenol"], + componentA_to_componentB=dict(), + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + errmsg = ( + r"Unmapped alchemical component " + r"SmallMoleculeComponent\(name=toluene\)" + ) + with pytest.raises(ValueError, match=errmsg): + _ = p.create( + stateA=benzene_system, + stateB=toluene_system, + mapping=mapping, + ) + + +def test_mapping_mismatch_B(benzene_system, toluene_system, benzene_modifications): + mapping = setup.LigandAtomMapping( + componentA=benzene_modifications["phenol"], + componentB=toluene_system.components["ligand"], + componentA_to_componentB=dict(), + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + errmsg = ( + r"Unmapped alchemical component " + r"SmallMoleculeComponent\(name=benzene\)" + ) + with pytest.raises(ValueError, match=errmsg): + _ = p.create( + stateA=benzene_system, + stateB=toluene_system, + mapping=mapping, + ) + + +def test_complex_mismatch(benzene_system, toluene_complex_system, benzene_to_toluene_mapping): + # only one complex + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + with pytest.raises(ValueError): + _ = p.create( + stateA=benzene_system, + stateB=toluene_complex_system, + mapping=benzene_to_toluene_mapping, + ) + + +def test_too_many_specified_mappings(benzene_system, toluene_system, benzene_to_toluene_mapping): + # mapping dict requires 'ligand' key + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + errmsg = "A single LigandAtomMapping is expected for this Protocol" + with pytest.raises(ValueError, match=errmsg): + _ = p.create( + stateA=benzene_system, + stateB=toluene_system, + mapping=[benzene_to_toluene_mapping, benzene_to_toluene_mapping], + ) + + +def test_protein_mismatch( + benzene_complex_system, toluene_complex_system, benzene_to_toluene_mapping +): + # hack one protein to be labelled differently + prot = toluene_complex_system["protein"] + alt_prot = openfe.ProteinComponent(prot.to_rdkit(), name="Mickey Mouse") + alt_toluene_complex_system = openfe.ChemicalSystem( + { + "ligand": toluene_complex_system["ligand"], + "solvent": toluene_complex_system["solvent"], + "protein": alt_prot, + } + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + with pytest.raises(ValueError): + _ = p.create( + stateA=benzene_complex_system, + stateB=alt_toluene_complex_system, + mapping=benzene_to_toluene_mapping, + ) + + +def test_element_change_warning(atom_mapping_basic_test_files): + # check a mapping with element change gets rejected early + l1 = atom_mapping_basic_test_files["2-methylnaphthalene"] + l2 = atom_mapping_basic_test_files["2-naftanol"] + + # We use the 'old' lomap defaults because the + # basic test files inputs we use aren't fully aligned + mapper = setup.LomapAtomMapper( + time=20, threed=True, max3d=1000.0, element_change=True, seed="", shift=True + ) + + mapping = next(mapper.suggest_mappings(l1, l2)) + + sys1 = openfe.ChemicalSystem( + {"ligand": l1, "solvent": openfe.SolventComponent()}, + ) + sys2 = openfe.ChemicalSystem( + {"ligand": l2, "solvent": openfe.SolventComponent()}, + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol( + settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + ) + with pytest.warns(UserWarning, match="Element change"): + _ = p.create( + stateA=sys1, + stateB=sys2, + mapping=mapping, + ) + + +@pytest.mark.parametrize( + "mapping_name,result", + [ + ["benzene_to_toluene_mapping", 0], + ["benzene_to_benzoic_mapping", 1], + ["benzene_to_aniline_mapping", -1], + ["aniline_to_benzene_mapping", 1], + ], +) +def test_get_charge_difference(mapping_name, result, request): + mapping = request.getfixturevalue(mapping_name) + if result != 0: + ion = r"Na\+" if result == -1 else r"Cl\-" + wmsg = ( + f"A charge difference of {result} is observed " + "between the end states. This will be addressed by " + f"transforming a water into a {ion} ion" + ) + with pytest.warns(UserWarning, match=wmsg): + val = _get_alchemical_charge_difference(mapping, "pme", True, openfe.SolventComponent()) + assert result == pytest.approx(val) + else: + val = _get_alchemical_charge_difference(mapping, "pme", True, openfe.SolventComponent()) + assert result == pytest.approx(val) + + +def test_get_charge_difference_no_pme(benzene_to_benzoic_mapping): + errmsg = "Explicit charge correction when not using PME" + with pytest.raises(ValueError, match=errmsg): + _get_alchemical_charge_difference( + benzene_to_benzoic_mapping, + "nocutoff", + True, + openfe.SolventComponent(), + ) + + +def test_get_charge_difference_no_corr(benzene_to_benzoic_mapping): + wmsg = ( + "A charge difference of 1 is observed between the end states. " + "No charge correction has been requested" + ) + with pytest.warns(UserWarning, match=wmsg): + _get_alchemical_charge_difference( + benzene_to_benzoic_mapping, + "pme", + False, + openfe.SolventComponent(), + ) + + +def test_greater_than_one_charge_difference_error(aniline_to_benzoic_mapping): + errmsg = "A charge difference of 2" + with pytest.raises(ValueError, match=errmsg): + _get_alchemical_charge_difference( + aniline_to_benzoic_mapping, + "pme", + True, + openfe.SolventComponent(), + ) + + +def test_get_alchemical_waters_no_waters( + benzene_solvent_openmm_system, +): + system, topology, positions = benzene_solvent_openmm_system + + errmsg = "There are no waters" + + with pytest.raises(ValueError, match=errmsg): + topologyhelpers.get_alchemical_waters( + topology, positions, charge_difference=1, distance_cutoff=3.0 * unit.nanometer + ) From 1e0153e949fbeb4a947ffb7beafd614eefaebc80 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 15 Dec 2025 10:26:58 -1000 Subject: [PATCH 04/91] add validate endstate tests --- .../openmm_rfe/test_hybrid_top_validation.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 62c8e5d23..50a518606 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -61,6 +61,51 @@ def test_invalid_protocol_repeats(): settings.protocol_repeats = -1 +@pytest.mark.parametrize('state', ['A', 'B']) +def test_endstate_two_alchemcomp_stateA(state, benzene_modifications): + first_state = openfe.ChemicalSystem({ + 'ligandA': benzene_modifications['benzene'], + 'ligandB': benzene_modifications['toluene'], + 'solvent': openfe.SolventComponent(), + }) + other_state = openfe.ChemicalSystem({ + 'ligandC': benzene_modifications['phenol'], + 'solvent': openfe.SolventComponent(), + }) + + if state == 'A': + args = (first_state, other_state) + else: + args = (other_state, first_state) + + with pytest.raises(ValueError, match="Only one alchemical component"): + openmm_rfe.RelativeHybridTopologyProtocol._validate_endstates( + *args + ) + +@pytest.mark.parametrize('state', ['A', 'B']) +def test_endstates_not_smc(state, benzene_modifications): + first_state = openfe.ChemicalSystem({ + 'ligand': benzene_modifications['benzene'], + 'foo': openfe.SolventComponent(), + }) + other_state = openfe.ChemicalSystem({ + 'ligand': benzene_modifications['benzene'], + 'foo': benzene_modifications['toluene'], + }) + + if state == 'A': + args = (first_state, other_state) + else: + args = (other_state, first_state) + + errmsg = "only SmallMoleculeComponents transformations" + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_endstates( + *args + ) + + @pytest.mark.parametrize( "mapping", [None, [], ["A", "B"]], From fbc455416c5b3eb9683c31515deec80b2d664da4 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 15 Dec 2025 10:52:30 -1000 Subject: [PATCH 05/91] validate mapping tests --- .../openmm_rfe/test_hybrid_top_validation.py | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 50a518606..241be3ebd 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -106,6 +106,59 @@ def test_endstates_not_smc(state, benzene_modifications): ) +def test_validate_mapping_none_mapping(): + errmsg = "A single LigandAtomMapping is expected" + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_mapping(None, None) + + +def test_validate_mapping_multi_mapping(benzene_to_toluene_mapping): + errmsg = "A single LigandAtomMapping is expected" + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_mapping( + [benzene_to_toluene_mapping] * 2, + None + ) + + +@pytest.mark.parametrize('state', ['A', 'B']) +def test_validate_mapping_alchem_not_in(state, benzene_to_toluene_mapping): + errmsg = f"not in alchemical components of state{state}" + + if state == "A": + alchem_comps = {"stateA": [], "stateB": [benzene_to_toluene_mapping.componentB]} + else: + alchem_comps = {"stateA": [benzene_to_toluene_mapping.componentA], "stateB": []} + + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_mapping( + [benzene_to_toluene_mapping], + alchem_comps, + ) + + +def test_element_change_warning(atom_mapping_basic_test_files): + # check a mapping with element change gets rejected early + l1 = atom_mapping_basic_test_files["2-methylnaphthalene"] + l2 = atom_mapping_basic_test_files["2-naftanol"] + + # We use the 'old' lomap defaults because the + # basic test files inputs we use aren't fully aligned + mapper = setup.LomapAtomMapper( + time=20, threed=True, max3d=1000.0, element_change=True, seed="", shift=True + ) + + mapping = next(mapper.suggest_mappings(l1, l2)) + + alchem_comps = {"stateA": [l1], "stateB": [l2]} + + with pytest.warns(UserWarning, match="Element change"): + openmm_rfe.RelativeHybridTopologyProtocol._validate_mapping( + [mapping], + alchem_comps, + ) + + @pytest.mark.parametrize( "mapping", [None, [], ["A", "B"]], @@ -330,37 +383,6 @@ def test_protein_mismatch( ) -def test_element_change_warning(atom_mapping_basic_test_files): - # check a mapping with element change gets rejected early - l1 = atom_mapping_basic_test_files["2-methylnaphthalene"] - l2 = atom_mapping_basic_test_files["2-naftanol"] - - # We use the 'old' lomap defaults because the - # basic test files inputs we use aren't fully aligned - mapper = setup.LomapAtomMapper( - time=20, threed=True, max3d=1000.0, element_change=True, seed="", shift=True - ) - - mapping = next(mapper.suggest_mappings(l1, l2)) - - sys1 = openfe.ChemicalSystem( - {"ligand": l1, "solvent": openfe.SolventComponent()}, - ) - sys2 = openfe.ChemicalSystem( - {"ligand": l2, "solvent": openfe.SolventComponent()}, - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - with pytest.warns(UserWarning, match="Element change"): - _ = p.create( - stateA=sys1, - stateB=sys2, - mapping=mapping, - ) - - @pytest.mark.parametrize( "mapping_name,result", [ From c2f49d25af7ead72a8f31ad22224bf5510e6b500 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 15 Dec 2025 11:45:04 -1000 Subject: [PATCH 06/91] net charge validation tests --- .../protocols/openmm_rfe/equil_rfe_methods.py | 4 + .../openmm_rfe/test_hybrid_top_validation.py | 186 ++++++++---------- 2 files changed, 83 insertions(+), 107 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 59e33ef52..e20c7360b 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -608,6 +608,10 @@ def _validate_charge_difference( warnings.warn(wmsg) return + if solvent_component is None: + errmsg = "Cannot use eplicit charge correction without solvent" + raise ValueError(errmsg) + # We implicitly check earlier that we have to have pme for a solvated # system, so we only need to check the nonbonded method here if nonbonded_method.lower() != "pme": diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 241be3ebd..f2b64d292 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.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 logging import copy import json import sys @@ -159,19 +160,87 @@ def test_element_change_warning(atom_mapping_basic_test_files): ) +def test_charge_difference_no_corr(benzene_to_benzoic_mapping): + wmsg = ( + "A charge difference of 1 is observed between the end states. " + "No charge correction has been requested" + ) + + with pytest.warns(UserWarning, match=wmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_charge_difference( + benzene_to_benzoic_mapping, + "pme", + False, + openfe.SolventComponent(), + ) + + +def test_charge_difference_no_solvent(benzene_to_benzoic_mapping): + errmsg = "Cannot use eplicit charge correction without solvent" + + with pytest.raises(ValueError, errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_charge_difference( + benzene_to_benzoic_mapping, + "pme", + True, + None, + ) + + +def test_charge_difference_no_pme(benzene_to_benzoic_mapping): + errmsg = "Explicit charge correction when not using PME" + + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_charge_difference( + benzene_to_benzoic_mapping, + "nocutoff", + True, + openfe.SolventComponent(), + ) + + +def test_greater_than_one_charge_difference_error(aniline_to_benzoic_mapping): + errmsg = "A charge difference of 2" + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_charge_difference( + aniline_to_benzoic_mapping, + "pme", + True, + openfe.SolventComponent(), + ) + + @pytest.mark.parametrize( - "mapping", - [None, [], ["A", "B"]], + "mapping_name,result", + [ + ["benzene_to_toluene_mapping", 0], + ["benzene_to_benzoic_mapping", 1], + ["benzene_to_aniline_mapping", -1], + ["aniline_to_benzene_mapping", 1], + ], ) -def test_validate_alchemical_components_wrong_mappings(mapping): - with pytest.raises(ValueError, match="A single LigandAtomMapping"): - _validate_alchemical_components({"stateA": [], "stateB": []}, mapping) - +def test_get_charge_difference(mapping_name, result, request, caplog): + mapping = request.getfixturevalue(mapping_name) + caplog.set_level(logging.INFO) + + ion = r"Na\+" if result == -1 else r"Cl\-" + msg = ( + f"A charge difference of {result} is observed " + "between the end states. This will be addressed by " + f"transforming a water into a {ion} ion" + ) + + openmm_rfe.RelativeHybridTopologyProtocol._validate_charge_difference( + mapping, + "pme", + True, + openfe.SolventComponent() + ) -def test_validate_alchemical_components_missing_alchem_comp(benzene_to_toluene_mapping): - alchem_comps = {"stateA": [openfe.SolventComponent()], "stateB": []} - with pytest.raises(ValueError, match="Unmapped alchemical component"): - _validate_alchemical_components(alchem_comps, benzene_to_toluene_mapping) + if result != 0: + assert msg in caplog.text + else: + assert msg not in caplog.text def test_hightimestep( @@ -286,78 +355,6 @@ def test_incompatible_solvent(benzene_system, benzene_modifications, benzene_to_ ) -def test_mapping_mismatch_A(benzene_system, toluene_system, benzene_modifications): - # the atom mapping doesn't refer to the ligands in the systems - mapping = setup.LigandAtomMapping( - componentA=benzene_system.components["ligand"], - componentB=benzene_modifications["phenol"], - componentA_to_componentB=dict(), - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - errmsg = ( - r"Unmapped alchemical component " - r"SmallMoleculeComponent\(name=toluene\)" - ) - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=toluene_system, - mapping=mapping, - ) - - -def test_mapping_mismatch_B(benzene_system, toluene_system, benzene_modifications): - mapping = setup.LigandAtomMapping( - componentA=benzene_modifications["phenol"], - componentB=toluene_system.components["ligand"], - componentA_to_componentB=dict(), - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - errmsg = ( - r"Unmapped alchemical component " - r"SmallMoleculeComponent\(name=benzene\)" - ) - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=toluene_system, - mapping=mapping, - ) - - -def test_complex_mismatch(benzene_system, toluene_complex_system, benzene_to_toluene_mapping): - # only one complex - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - with pytest.raises(ValueError): - _ = p.create( - stateA=benzene_system, - stateB=toluene_complex_system, - mapping=benzene_to_toluene_mapping, - ) - - -def test_too_many_specified_mappings(benzene_system, toluene_system, benzene_to_toluene_mapping): - # mapping dict requires 'ligand' key - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - errmsg = "A single LigandAtomMapping is expected for this Protocol" - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=toluene_system, - mapping=[benzene_to_toluene_mapping, benzene_to_toluene_mapping], - ) - - def test_protein_mismatch( benzene_complex_system, toluene_complex_system, benzene_to_toluene_mapping ): @@ -409,31 +406,6 @@ def test_get_charge_difference(mapping_name, result, request): assert result == pytest.approx(val) -def test_get_charge_difference_no_pme(benzene_to_benzoic_mapping): - errmsg = "Explicit charge correction when not using PME" - with pytest.raises(ValueError, match=errmsg): - _get_alchemical_charge_difference( - benzene_to_benzoic_mapping, - "nocutoff", - True, - openfe.SolventComponent(), - ) - - -def test_get_charge_difference_no_corr(benzene_to_benzoic_mapping): - wmsg = ( - "A charge difference of 1 is observed between the end states. " - "No charge correction has been requested" - ) - with pytest.warns(UserWarning, match=wmsg): - _get_alchemical_charge_difference( - benzene_to_benzoic_mapping, - "pme", - False, - openfe.SolventComponent(), - ) - - def test_greater_than_one_charge_difference_error(aniline_to_benzoic_mapping): errmsg = "A charge difference of 2" with pytest.raises(ValueError, match=errmsg): From c50f99cad7daf09272bd81033d080d10cc5193af Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 22 Dec 2025 10:23:47 -1000 Subject: [PATCH 07/91] more stuff --- .../openmm_rfe/test_hybrid_top_validation.py | 102 ------------------ 1 file changed, 102 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index f2b64d292..d0452eb9a 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -297,23 +297,6 @@ def test_n_replicas_not_n_windows( dag_unit.run(dry=True) -def test_missing_ligand(benzene_system, benzene_to_toluene_mapping): - # state B doesn't have a ligand component - stateB = openfe.ChemicalSystem({"solvent": openfe.SolventComponent()}) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - - match_str = "missing alchemical components in stateB" - with pytest.raises(ValueError, match=match_str): - _ = p.create( - stateA=benzene_system, - stateB=stateB, - mapping=benzene_to_toluene_mapping, - ) - - def test_vaccuum_PME_error( benzene_vacuum_system, benzene_modifications, benzene_to_toluene_mapping ): @@ -332,91 +315,6 @@ def test_vaccuum_PME_error( ) -def test_incompatible_solvent(benzene_system, benzene_modifications, benzene_to_toluene_mapping): - # the solvents are different - stateB = openfe.ChemicalSystem( - { - "ligand": benzene_modifications["toluene"], - "solvent": openfe.SolventComponent(positive_ion="K", negative_ion="Cl"), - } - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - # We don't have a way to map non-ligand components so for now it - # just triggers that it's not a mapped component - errmsg = "missing alchemical components in stateA" - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=stateB, - mapping=benzene_to_toluene_mapping, - ) - - -def test_protein_mismatch( - benzene_complex_system, toluene_complex_system, benzene_to_toluene_mapping -): - # hack one protein to be labelled differently - prot = toluene_complex_system["protein"] - alt_prot = openfe.ProteinComponent(prot.to_rdkit(), name="Mickey Mouse") - alt_toluene_complex_system = openfe.ChemicalSystem( - { - "ligand": toluene_complex_system["ligand"], - "solvent": toluene_complex_system["solvent"], - "protein": alt_prot, - } - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - with pytest.raises(ValueError): - _ = p.create( - stateA=benzene_complex_system, - stateB=alt_toluene_complex_system, - mapping=benzene_to_toluene_mapping, - ) - - -@pytest.mark.parametrize( - "mapping_name,result", - [ - ["benzene_to_toluene_mapping", 0], - ["benzene_to_benzoic_mapping", 1], - ["benzene_to_aniline_mapping", -1], - ["aniline_to_benzene_mapping", 1], - ], -) -def test_get_charge_difference(mapping_name, result, request): - mapping = request.getfixturevalue(mapping_name) - if result != 0: - ion = r"Na\+" if result == -1 else r"Cl\-" - wmsg = ( - f"A charge difference of {result} is observed " - "between the end states. This will be addressed by " - f"transforming a water into a {ion} ion" - ) - with pytest.warns(UserWarning, match=wmsg): - val = _get_alchemical_charge_difference(mapping, "pme", True, openfe.SolventComponent()) - assert result == pytest.approx(val) - else: - val = _get_alchemical_charge_difference(mapping, "pme", True, openfe.SolventComponent()) - assert result == pytest.approx(val) - - -def test_greater_than_one_charge_difference_error(aniline_to_benzoic_mapping): - errmsg = "A charge difference of 2" - with pytest.raises(ValueError, match=errmsg): - _get_alchemical_charge_difference( - aniline_to_benzoic_mapping, - "pme", - True, - openfe.SolventComponent(), - ) - - def test_get_alchemical_waters_no_waters( benzene_solvent_openmm_system, ): From 9e0d29be83df2a7f747d6bf385d0853c1d92589e Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 24 Dec 2025 00:43:36 -0500 Subject: [PATCH 08/91] remove old tests --- .../openmm_rfe/test_hybrid_top_protocol.py | 260 ------------------ 1 file changed, 260 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 1f178991a..148f32fe1 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -192,21 +192,6 @@ def test_create_independent_repeat_ids(benzene_system, toluene_system, benzene_t assert len(repeat_ids) == 6 -@pytest.mark.parametrize( - "mapping", - [None, [], ["A", "B"]], -) -def test_validate_alchemical_components_wrong_mappings(mapping): - with pytest.raises(ValueError, match="A single LigandAtomMapping"): - _validate_alchemical_components({"stateA": [], "stateB": []}, mapping) - - -def test_validate_alchemical_components_missing_alchem_comp(benzene_to_toluene_mapping): - alchem_comps = {"stateA": [openfe.SolventComponent()], "stateB": []} - with pytest.raises(ValueError, match="Unmapped alchemical component"): - _validate_alchemical_components(alchem_comps, benzene_to_toluene_mapping) - - @pytest.mark.parametrize("method", ["repex", "sams", "independent", "InDePeNdENT"]) def test_dry_run_default_vacuum( benzene_vacuum_system, @@ -989,189 +974,6 @@ def test_hightimestep( dag_unit.run(dry=True) -def test_n_replicas_not_n_windows( - benzene_vacuum_system, - toluene_vacuum_system, - benzene_to_toluene_mapping, - vac_settings, - tmpdir, -): - # For PR #125 we pin such that the number of lambda windows - # equals the numbers of replicas used - TODO: remove limitation - # default lambda windows is 11 - vac_settings.simulation_settings.n_replicas = 13 - - 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_rfe.RelativeHybridTopologyProtocol( - settings=vac_settings, - ) - dag = p.create( - stateA=benzene_vacuum_system, - stateB=toluene_vacuum_system, - mapping=benzene_to_toluene_mapping, - ) - dag_unit = list(dag.protocol_units)[0] - dag_unit.run(dry=True) - - -def test_missing_ligand(benzene_system, benzene_to_toluene_mapping): - # state B doesn't have a ligand component - stateB = openfe.ChemicalSystem({"solvent": openfe.SolventComponent()}) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - - match_str = "missing alchemical components in stateB" - with pytest.raises(ValueError, match=match_str): - _ = p.create( - stateA=benzene_system, - stateB=stateB, - mapping=benzene_to_toluene_mapping, - ) - - -def test_vaccuum_PME_error( - benzene_vacuum_system, benzene_modifications, benzene_to_toluene_mapping -): - # state B doesn't have a solvent component (i.e. its vacuum) - stateB = openfe.ChemicalSystem({"ligand": benzene_modifications["toluene"]}) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - errmsg = "PME cannot be used for vacuum transform" - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_vacuum_system, - stateB=stateB, - mapping=benzene_to_toluene_mapping, - ) - - -def test_incompatible_solvent(benzene_system, benzene_modifications, benzene_to_toluene_mapping): - # the solvents are different - stateB = openfe.ChemicalSystem( - { - "ligand": benzene_modifications["toluene"], - "solvent": openfe.SolventComponent(positive_ion="K", negative_ion="Cl"), - } - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - # We don't have a way to map non-ligand components so for now it - # just triggers that it's not a mapped component - errmsg = "missing alchemical components in stateA" - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=stateB, - mapping=benzene_to_toluene_mapping, - ) - - -def test_mapping_mismatch_A(benzene_system, toluene_system, benzene_modifications): - # the atom mapping doesn't refer to the ligands in the systems - mapping = setup.LigandAtomMapping( - componentA=benzene_system.components["ligand"], - componentB=benzene_modifications["phenol"], - componentA_to_componentB=dict(), - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - errmsg = ( - r"Unmapped alchemical component " - r"SmallMoleculeComponent\(name=toluene\)" - ) - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=toluene_system, - mapping=mapping, - ) - - -def test_mapping_mismatch_B(benzene_system, toluene_system, benzene_modifications): - mapping = setup.LigandAtomMapping( - componentA=benzene_modifications["phenol"], - componentB=toluene_system.components["ligand"], - componentA_to_componentB=dict(), - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - errmsg = ( - r"Unmapped alchemical component " - r"SmallMoleculeComponent\(name=benzene\)" - ) - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=toluene_system, - mapping=mapping, - ) - - -def test_complex_mismatch(benzene_system, toluene_complex_system, benzene_to_toluene_mapping): - # only one complex - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - with pytest.raises(ValueError): - _ = p.create( - stateA=benzene_system, - stateB=toluene_complex_system, - mapping=benzene_to_toluene_mapping, - ) - - -def test_too_many_specified_mappings(benzene_system, toluene_system, benzene_to_toluene_mapping): - # mapping dict requires 'ligand' key - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - errmsg = "A single LigandAtomMapping is expected for this Protocol" - with pytest.raises(ValueError, match=errmsg): - _ = p.create( - stateA=benzene_system, - stateB=toluene_system, - mapping=[benzene_to_toluene_mapping, benzene_to_toluene_mapping], - ) - - -def test_protein_mismatch( - benzene_complex_system, toluene_complex_system, benzene_to_toluene_mapping -): - # hack one protein to be labelled differently - prot = toluene_complex_system["protein"] - alt_prot = openfe.ProteinComponent(prot.to_rdkit(), name="Mickey Mouse") - alt_toluene_complex_system = openfe.ChemicalSystem( - { - "ligand": toluene_complex_system["ligand"], - "solvent": toluene_complex_system["solvent"], - "protein": alt_prot, - } - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - with pytest.raises(ValueError): - _ = p.create( - stateA=benzene_complex_system, - stateB=alt_toluene_complex_system, - mapping=benzene_to_toluene_mapping, - ) - - def test_element_change_warning(atom_mapping_basic_test_files): # check a mapping with element change gets rejected early l1 = atom_mapping_basic_test_files["2-methylnaphthalene"] @@ -1745,68 +1547,6 @@ def test_filenotfound_replica_states(self, protocolresult): protocolresult.get_replica_states() -@pytest.mark.parametrize( - "mapping_name,result", - [ - ["benzene_to_toluene_mapping", 0], - ["benzene_to_benzoic_mapping", 1], - ["benzene_to_aniline_mapping", -1], - ["aniline_to_benzene_mapping", 1], - ], -) -def test_get_charge_difference(mapping_name, result, request): - mapping = request.getfixturevalue(mapping_name) - if result != 0: - ion = r"Na\+" if result == -1 else r"Cl\-" - wmsg = ( - f"A charge difference of {result} is observed " - "between the end states. This will be addressed by " - f"transforming a water into a {ion} ion" - ) - with pytest.warns(UserWarning, match=wmsg): - val = _get_alchemical_charge_difference(mapping, "pme", True, openfe.SolventComponent()) - assert result == pytest.approx(val) - else: - val = _get_alchemical_charge_difference(mapping, "pme", True, openfe.SolventComponent()) - assert result == pytest.approx(val) - - -def test_get_charge_difference_no_pme(benzene_to_benzoic_mapping): - errmsg = "Explicit charge correction when not using PME" - with pytest.raises(ValueError, match=errmsg): - _get_alchemical_charge_difference( - benzene_to_benzoic_mapping, - "nocutoff", - True, - openfe.SolventComponent(), - ) - - -def test_get_charge_difference_no_corr(benzene_to_benzoic_mapping): - wmsg = ( - "A charge difference of 1 is observed between the end states. " - "No charge correction has been requested" - ) - with pytest.warns(UserWarning, match=wmsg): - _get_alchemical_charge_difference( - benzene_to_benzoic_mapping, - "pme", - False, - openfe.SolventComponent(), - ) - - -def test_greater_than_one_charge_difference_error(aniline_to_benzoic_mapping): - errmsg = "A charge difference of 2" - with pytest.raises(ValueError, match=errmsg): - _get_alchemical_charge_difference( - aniline_to_benzoic_mapping, - "pme", - True, - openfe.SolventComponent(), - ) - - @pytest.fixture(scope="session") def benzene_solvent_openmm_system(benzene_modifications): smc = benzene_modifications["benzene"] From 2fe8ff937683e2d237cddb0b54ea9ace79be1cf7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 24 Dec 2025 01:49:58 -0500 Subject: [PATCH 09/91] make hybrid samplers not rely on htf --- .../openmm_rfe/_rfe_utils/multistate.py | 61 ++++++++++++------- .../protocols/openmm_rfe/equil_rfe_methods.py | 17 ++++-- .../openmm_rfe/test_hybrid_top_protocol.py | 27 ++++---- 3 files changed, 65 insertions(+), 40 deletions(-) diff --git a/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py b/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py index 8c6b4eddc..299a846f6 100644 --- a/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py +++ b/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py @@ -26,14 +26,15 @@ logger = logging.getLogger(__name__) -class HybridCompatibilityMixin(object): +class HybridCompatibilityMixin: """ Mixin that allows the MultistateSampler to accommodate the situation where unsampled endpoints have a different number of degrees of freedom. """ - def __init__(self, *args, hybrid_factory=None, **kwargs): - self._hybrid_factory = hybrid_factory + def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): + self._hybrid_system = hybrid_system + self._hybrid_positions = hybrid_positions super(HybridCompatibilityMixin, self).__init__(*args, **kwargs) def setup(self, reporter, lambda_protocol, @@ -73,15 +74,17 @@ class creation of LambdaProtocol. """ n_states = len(lambda_protocol.lambda_schedule) - hybrid_system = self._factory.hybrid_system + lambda_zero_state = RelativeAlchemicalState.from_system(self._hybrid_system) - lambda_zero_state = RelativeAlchemicalState.from_system(hybrid_system) + thermostate = ThermodynamicState( + self._hybrid_system, + temperature=temperature + ) - thermostate = ThermodynamicState(hybrid_system, - temperature=temperature) compound_thermostate = CompoundThermodynamicState( - thermostate, - composable_states=[lambda_zero_state]) + thermostate, + composable_states=[lambda_zero_state] + ) # create lists for storing thermostates and sampler states thermodynamic_state_list = [] @@ -105,16 +108,20 @@ class creation of LambdaProtocol. raise ValueError(errmsg) # starting with the hybrid factory positions - box = hybrid_system.getDefaultPeriodicBoxVectors() - sampler_state = SamplerState(self._factory.hybrid_positions, - box_vectors=box) + box = self._hybrid_system.getDefaultPeriodicBoxVectors() + sampler_state = SamplerState( + self._hybrid_positions, + box_vectors=box + ) # Loop over the lambdas and create & store a compound thermostate at # that lambda value for lambda_val in lambda_schedule: compound_thermostate_copy = copy.deepcopy(compound_thermostate) compound_thermostate_copy.set_alchemical_parameters( - lambda_val, lambda_protocol) + lambda_val, + lambda_protocol + ) thermodynamic_state_list.append(compound_thermostate_copy) # now generating a sampler_state for each thermodyanmic state, @@ -143,7 +150,8 @@ class creation of LambdaProtocol. # generating unsampled endstates unsampled_dispersion_endstates = create_endstates( copy.deepcopy(thermodynamic_state_list[0]), - copy.deepcopy(thermodynamic_state_list[-1])) + copy.deepcopy(thermodynamic_state_list[-1]) + ) self.create(thermodynamic_states=thermodynamic_state_list, sampler_states=sampler_state_list, storage=reporter, unsampled_thermodynamic_states=unsampled_dispersion_endstates) @@ -159,10 +167,13 @@ class HybridRepexSampler(HybridCompatibilityMixin, number of positions """ - def __init__(self, *args, hybrid_factory=None, **kwargs): + def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): super(HybridRepexSampler, self).__init__( - *args, hybrid_factory=hybrid_factory, **kwargs) - self._factory = hybrid_factory + *args, + hybrid_system=hybrid_system, + hybrid_positions=hybrid_positions, + **kwargs + ) class HybridSAMSSampler(HybridCompatibilityMixin, sams.SAMSSampler): @@ -171,11 +182,13 @@ class HybridSAMSSampler(HybridCompatibilityMixin, sams.SAMSSampler): of positions """ - def __init__(self, *args, hybrid_factory=None, **kwargs): + def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): super(HybridSAMSSampler, self).__init__( - *args, hybrid_factory=hybrid_factory, **kwargs + *args, + hybrid_system=hybrid_system, + hybrid_positions=hybrid_positions, + **kwargs ) - self._factory = hybrid_factory class HybridMultiStateSampler(HybridCompatibilityMixin, @@ -184,11 +197,13 @@ class HybridMultiStateSampler(HybridCompatibilityMixin, MultiStateSampler that supports unsample end states with a different number of positions """ - def __init__(self, *args, hybrid_factory=None, **kwargs): + def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): super(HybridMultiStateSampler, self).__init__( - *args, hybrid_factory=hybrid_factory, **kwargs + *args, + hybrid_system=hybrid_system, + hybrid_positions=hybrid_positions, + **kwargs ) - self._factory = hybrid_factory def create_endstates(first_thermostate, last_thermostate): diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 514237634..c1ddab71e 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -1128,7 +1128,8 @@ def run( if sampler_settings.sampler_method.lower() == "repex": sampler = _rfe_utils.multistate.HybridRepexSampler( mcmc_moves=integrator, - hybrid_factory=hybrid_factory, + hybrid_system=hybrid_factory.hybrid_system, + hybrid_positions=hybrid_factory.hybrid_positions, online_analysis_interval=rta_its, online_analysis_target_error=early_termination_target_error, online_analysis_minimum_iterations=rta_min_its, @@ -1136,7 +1137,8 @@ def run( elif sampler_settings.sampler_method.lower() == "sams": sampler = _rfe_utils.multistate.HybridSAMSSampler( mcmc_moves=integrator, - hybrid_factory=hybrid_factory, + hybrid_system=hybrid_factory.hybrid_system, + hybrid_positions=hybrid_factory.hybrid_positions, online_analysis_interval=rta_its, online_analysis_minimum_iterations=rta_min_its, flatness_criteria=sampler_settings.sams_flatness_criteria, @@ -1145,12 +1147,12 @@ def run( elif sampler_settings.sampler_method.lower() == "independent": sampler = _rfe_utils.multistate.HybridMultiStateSampler( mcmc_moves=integrator, - hybrid_factory=hybrid_factory, + hybrid_system=hybrid_factory.hybrid_system, + hybrid_positions=hybrid_factory.hybrid_positions, online_analysis_interval=rta_its, online_analysis_target_error=early_termination_target_error, online_analysis_minimum_iterations=rta_min_its, ) - else: raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") @@ -1247,7 +1249,12 @@ def run( if not dry: # pragma: no-cover return {"nc": nc, "last_checkpoint": chk, **analyzer.unit_results_dict} else: - return {"debug": {"sampler": sampler}} + return {"debug": + { + "sampler": sampler, + "hybrid_factory": hybrid_factory + } + } @staticmethod def structural_analysis(scratch, shared) -> dict: diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index f5ea92cff..711f72d2a 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -236,13 +236,14 @@ def test_dry_run_default_vacuum( dag_unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] + debug = dag_unit.run(dry=True)["debug"] + sampler = debug["sampler"] assert isinstance(sampler, MultiStateSampler) assert not sampler.is_periodic assert sampler._thermodynamic_states[0].barostat is None # Check hybrid OMM and MDTtraj Topologies - htf = sampler._hybrid_factory + htf = debug["hybrid_factory"] # 16 atoms: # 11 common atoms, 1 extra hydrogen in benzene, 4 extra in toluene # 12 bonds in benzene + 4 extra toluene bonds @@ -414,7 +415,7 @@ def test_dry_core_element_change(vac_settings, tmpdir): with tmpdir.as_cwd(): sampler = dag_unit.run(dry=True)["debug"]["sampler"] - system = sampler._hybrid_factory.hybrid_system + system = sampler._hybrid_system assert system.getNumParticles() == 12 # Average mass between nitrogen and carbon assert system.getParticleMass(1) == 12.0127235 * omm_unit.amu @@ -518,7 +519,7 @@ def tip4p_hybrid_factory( shared_basepath=shared_temp, ) - return dag_unit_result["debug"]["sampler"]._factory + return dag_unit_result["debug"]["hybrid_factory"] def test_tip4p_particle_count(tip4p_hybrid_factory): @@ -624,7 +625,7 @@ def test_dry_run_ligand_system_cutoff( with tmpdir.as_cwd(): sampler = dag_unit.run(dry=True)["debug"]["sampler"] - hs = sampler._factory.hybrid_system + hs = sampler._hybrid_system nbfs = [ f @@ -691,9 +692,10 @@ def test_dry_run_charge_backends( dag_unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] - htf = sampler._factory - hybrid_system = htf.hybrid_system + debug = dag_unit.run(dry=True)["debug"] + sampler = debug["sampler"] + htf = debug["hybrid_factory"] + hybrid_system = sampler._hybrid_system # get the standard nonbonded force nonbond = [f for f in hybrid_system.getForces() if isinstance(f, NonbondedForce)] @@ -785,9 +787,10 @@ def check_propchgs(smc, charge_array): dag_unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] - htf = sampler._factory - hybrid_system = htf.hybrid_system + debug = dag_unit.run(dry=True)["debug"] + sampler = debug["sampler"] + htf = debug["hybrid_factory"] + hybrid_system = sampler._hybrid_system # get the standard nonbonded force nonbond = [f for f in hybrid_system.getForces() if isinstance(f, NonbondedForce)] @@ -902,7 +905,7 @@ def test_dodecahdron_ligand_box( with tmpdir.as_cwd(): sampler = dag_unit.run(dry=True)["debug"]["sampler"] - hs = sampler._factory.hybrid_system + hs = sampler._hybrid_system vectors = hs.getDefaultPeriodicBoxVectors() From 4a0bd26308868b48b1519a7c7fe0fef8a90f78fb Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 24 Dec 2025 01:54:00 -0500 Subject: [PATCH 10/91] fix up test --- openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 711f72d2a..814cc899f 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -2156,8 +2156,8 @@ def test_dry_run_alchemwater_solvent(benzene_to_benzoic_mapping, solv_settings, unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - sampler = unit.run(dry=True)["debug"]["sampler"] - htf = sampler._factory + debug = unit.run(dry=True)["debug"] + htf = debug["hybrid_factory"] _assert_total_charge(htf.hybrid_system, htf._atom_classes, 0, 0) assert len(htf._atom_classes["core_atoms"]) == 14 From 5848adcb54f4e17e2d7c5f1ee5ce33aa79eb70ce Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 24 Dec 2025 01:56:56 -0500 Subject: [PATCH 11/91] fix up some slow tests --- .../tests/protocols/openmm_rfe/test_hybrid_top_protocol.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 814cc899f..fc11cf164 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -1601,7 +1601,7 @@ def tyk2_xml(tmp_path_factory): dryrun = pu.run(dry=True, shared_basepath=tmp) - system = dryrun["debug"]["sampler"]._hybrid_factory.hybrid_system + system = dryrun["debug"]["sampler"]._hybrid_system return ET.fromstring(XmlSerializer.serialize(system)) @@ -2225,8 +2225,8 @@ def test_dry_run_complex_alchemwater_totcharge( unit = list(dag.protocol_units)[0] with tmpdir.as_cwd(): - sampler = unit.run(dry=True)["debug"]["sampler"] - htf = sampler._factory + debug = unit.run(dry=True)["debug"] + htf = debug["hybrid_factory"] _assert_total_charge(htf.hybrid_system, htf._atom_classes, chgA, chgB) assert len(htf._atom_classes["core_atoms"]) == core_atoms From b6d5ecd315b6ca186c62494e8aed37e4e0f69cde Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 25 Dec 2025 19:27:36 -0500 Subject: [PATCH 12/91] Fix up the one test --- .../protocols/openmm_rfe/equil_rfe_methods.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index e20c7360b..356423922 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -641,10 +641,28 @@ def _validate_charge_difference( @staticmethod def _validate_simulation_settings( - simulation_settings, - integrator_settings, - output_settings, + simulation_settings: MultiStateSimulationSettings, + integrator_settings: IntegratorSettings, + output_settings: MultiStateOutputSettings, ): + """ + Validate various simulation settings, including but not limited to + timestep conversions, and output file write frequencies. + + Parameters + ---------- + simulation_settings : MultiStateSimulationSettings + The sampler simulation settings. + integrator_settings : IntegratorSettings + Settings defining the behaviour of the integrator. + output_settings : MultiStateOutputSettings + Settings defining the simulation file writing behaviour. + + Raises + ------ + ValueError + * If the + """ steps_per_iteration = settings_validation.convert_steps_per_iteration( simulation_settings=simulation_settings, @@ -671,7 +689,7 @@ def _validate_simulation_settings( if output_settings.positions_write_frequency is not None: _ = settings_validation.divmod_time_and_check( numerator=output_settings.positions_write_frequency, - denominator=sampler_settings.time_per_iteration, + denominator=simulation_settings.time_per_iteration, numerator_name="output settings' position_write_frequency", denominator_name="sampler settings' time_per_iteration", ) @@ -679,13 +697,13 @@ def _validate_simulation_settings( if output_settings.velocities_write_frequency is not None: _ = settings_validation.divmod_time_and_check( numerator=output_settings.velocities_write_frequency, - denominator=sampler_settings.time_per_iteration, + denominator=simulation_settings.time_per_iteration, numerator_name="output settings' velocity_write_frequency", denominator_name="sampler settings' time_per_iteration", ) _, _ = settings_validation.convert_real_time_analysis_iterations( - simulation_settings=sampler_settings, + simulation_settings=simulation_settings, ) def _validate( From 0605d11049934524bafa5d14d4b12362dce7d29b Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 26 Dec 2025 01:03:39 +0000 Subject: [PATCH 13/91] fix a few things --- .../tests/protocols/openmm_rfe/test_hybrid_top_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index d0452eb9a..c4a686a01 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -178,7 +178,7 @@ def test_charge_difference_no_corr(benzene_to_benzoic_mapping): def test_charge_difference_no_solvent(benzene_to_benzoic_mapping): errmsg = "Cannot use eplicit charge correction without solvent" - with pytest.raises(ValueError, errmsg): + with pytest.raises(ValueError, match=errmsg): openmm_rfe.RelativeHybridTopologyProtocol._validate_charge_difference( benzene_to_benzoic_mapping, "pme", @@ -223,7 +223,7 @@ def test_get_charge_difference(mapping_name, result, request, caplog): mapping = request.getfixturevalue(mapping_name) caplog.set_level(logging.INFO) - ion = r"Na\+" if result == -1 else r"Cl\-" + ion = r"Na+" if result == -1 else r"Cl-" msg = ( f"A charge difference of {result} is observed " "between the end states. This will be addressed by " From 48106a297237fffb9e76ca16d2ed99a3d6834bac Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 25 Dec 2025 23:45:07 -0500 Subject: [PATCH 14/91] fix the remaining tests --- .../protocols/openmm_rfe/equil_rfe_methods.py | 10 +- .../openmm_utils/system_validation.py | 33 +- .../openmm_rfe/test_hybrid_top_protocol.py | 94 ----- .../openmm_rfe/test_hybrid_top_validation.py | 363 +++++++++++++++--- 4 files changed, 337 insertions(+), 163 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 356423922..710f4db8e 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -690,7 +690,7 @@ def _validate_simulation_settings( _ = settings_validation.divmod_time_and_check( numerator=output_settings.positions_write_frequency, denominator=simulation_settings.time_per_iteration, - numerator_name="output settings' position_write_frequency", + numerator_name="output settings' positions_write_frequency", denominator_name="sampler settings' time_per_iteration", ) @@ -698,7 +698,7 @@ def _validate_simulation_settings( _ = settings_validation.divmod_time_and_check( numerator=output_settings.velocities_write_frequency, denominator=simulation_settings.time_per_iteration, - numerator_name="output settings' velocity_write_frequency", + numerator_name="output settings' velocities_write_frequency", denominator_name="sampler settings' time_per_iteration", ) @@ -768,8 +768,10 @@ def _validate( # PR #125 temporarily pin lambda schedule spacing to n_replicas if self.settings.simulation_settings.n_replicas != self.settings.lambda_settings.lambda_windows: errmsg = ( - "Number of replicas in simulation_settings must equal " - "number of lambda windows in lambda_settings." + "Number of replicas in ``simulation_settings``: " + f"{self.settings.simulation_settings.n_replicas} must equal " + "the number of lambda windows in lambda_settings: " + f"{self.settings.lambda_settings.lambda_windows}." ) raise ValueError(errmsg) diff --git a/openfe/protocols/openmm_utils/system_validation.py b/openfe/protocols/openmm_utils/system_validation.py index 0fd3c3518..9d67e108f 100644 --- a/openfe/protocols/openmm_utils/system_validation.py +++ b/openfe/protocols/openmm_utils/system_validation.py @@ -95,23 +95,24 @@ def validate_solvent(state: ChemicalSystem, nonbonded_method: str): `nocutoff`. * If the SolventComponent solvent is not water. """ - solv = [comp for comp in state.values() if isinstance(comp, SolventComponent)] + solv_comps = state.get_components_of_type(SolventComponent) - if len(solv) > 0 and nonbonded_method.lower() == "nocutoff": - errmsg = "nocutoff cannot be used for solvent transformations" - raise ValueError(errmsg) + if len(solv_comps) > 0: + if nonbonded_method.lower() == "nocutoff": + errmsg = "nocutoff cannot be used for solvent transformations" + raise ValueError(errmsg) - if len(solv) == 0 and nonbonded_method.lower() == "pme": - errmsg = "PME cannot be used for vacuum transform" - raise ValueError(errmsg) + if len(solv_comps) > 1: + errmsg = "Multiple SolventComponent found, only one is supported" + raise ValueError(errmsg) - if len(solv) > 1: - errmsg = "Multiple SolventComponent found, only one is supported" - raise ValueError(errmsg) - - if len(solv) > 0 and solv[0].smiles != "O": - errmsg = "Non water solvent is not currently supported" - raise ValueError(errmsg) + if solv_comps[0].smiles != "O": + errmsg = "Non water solvent is not currently supported" + raise ValueError(errmsg) + else: + if nonbonded_method.lower() == "pme": + errmsg = "PME cannot be used for vacuum transform" + raise ValueError(errmsg) def validate_protein(state: ChemicalSystem): @@ -129,9 +130,9 @@ def validate_protein(state: ChemicalSystem): ValueError If there are multiple ProteinComponent in the ChemicalSystem. """ - nprot = sum(1 for comp in state.values() if isinstance(comp, ProteinComponent)) + prot_comps = state.get_components_of_type(ProteinComponent) - if nprot > 1: + if len(prot_comps) > 1: errmsg = "Multiple ProteinComponent found, only one is supported" raise ValueError(errmsg) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 148f32fe1..d4818f355 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -948,63 +948,6 @@ def test_lambda_schedule(windows): assert len(lambdas.lambda_schedule) == windows -def test_hightimestep( - benzene_vacuum_system, - toluene_vacuum_system, - benzene_to_toluene_mapping, - vac_settings, - tmpdir, -): - vac_settings.forcefield_settings.hydrogen_mass = 1.0 - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=vac_settings, - ) - - dag = p.create( - stateA=benzene_vacuum_system, - stateB=toluene_vacuum_system, - mapping=benzene_to_toluene_mapping, - ) - dag_unit = list(dag.protocol_units)[0] - - errmsg = "too large for hydrogen mass" - with tmpdir.as_cwd(): - with pytest.raises(ValueError, match=errmsg): - dag_unit.run(dry=True) - - -def test_element_change_warning(atom_mapping_basic_test_files): - # check a mapping with element change gets rejected early - l1 = atom_mapping_basic_test_files["2-methylnaphthalene"] - l2 = atom_mapping_basic_test_files["2-naftanol"] - - # We use the 'old' lomap defaults because the - # basic test files inputs we use aren't fully aligned - mapper = setup.LomapAtomMapper( - time=20, threed=True, max3d=1000.0, element_change=True, seed="", shift=True - ) - - mapping = next(mapper.suggest_mappings(l1, l2)) - - sys1 = openfe.ChemicalSystem( - {"ligand": l1, "solvent": openfe.SolventComponent()}, - ) - sys2 = openfe.ChemicalSystem( - {"ligand": l2, "solvent": openfe.SolventComponent()}, - ) - - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), - ) - with pytest.warns(UserWarning, match="Element change"): - _ = p.create( - stateA=sys1, - stateB=sys2, - mapping=mapping, - ) - - def test_ligand_overlap_warning( benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, vac_settings, tmpdir ): @@ -2023,40 +1966,3 @@ def test_dry_run_vacuum_write_frequency( assert reporter.velocity_interval == velocities_write_frequency.m else: assert reporter.velocity_interval == 0 - - -@pytest.mark.parametrize( - "positions_write_frequency,velocities_write_frequency", - [ - [100.1 * unit.picosecond, 100 * unit.picosecond], - [100 * unit.picosecond, 100.1 * unit.picosecond], - ], -) -def test_pos_write_frequency_not_divisible( - benzene_vacuum_system, - toluene_vacuum_system, - benzene_to_toluene_mapping, - positions_write_frequency, - velocities_write_frequency, - tmpdir, - vac_settings, -): - vac_settings.output_settings.positions_write_frequency = positions_write_frequency - vac_settings.output_settings.velocities_write_frequency = velocities_write_frequency - - protocol = openmm_rfe.RelativeHybridTopologyProtocol( - settings=vac_settings, - ) - - # create DAG from protocol and take first (and only) work unit from within - dag = protocol.create( - stateA=benzene_vacuum_system, - stateB=toluene_vacuum_system, - mapping=benzene_to_toluene_mapping, - ) - dag_unit = list(dag.protocol_units)[0] - - with tmpdir.as_cwd(): - errmsg = "The output settings' " - with pytest.raises(ValueError, match=errmsg): - dag_unit.run(dry=True)["debug"]["sampler"] diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index c4a686a01..984c4d6bb 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -18,7 +18,7 @@ from kartograf.atom_aligner import align_mol_shape from numpy.testing import assert_allclose from openff.toolkit import Molecule -from openff.units import unit +from openff.units import unit as offunit from openff.units.openmm import ensure_quantity, from_openmm, to_openmm from openmm import CustomNonbondedForce, MonteCarloBarostat, NonbondedForce, XmlSerializer, app from openmm import unit as omm_unit @@ -138,6 +138,169 @@ def test_validate_mapping_alchem_not_in(state, benzene_to_toluene_mapping): ) +def test_vaccuum_PME_error( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + solv_settings +): + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=solv_settings) + + errmsg = "PME cannot be used for vacuum transform" + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + ) + + +def test_solvent_nocutoff_error( + benzene_system, + toluene_system, + benzene_to_toluene_mapping, + vac_settings, +): + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) + + errmsg = "nocutoff cannot be used for solvent transformation" + + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_system, + stateB=toluene_system, + mapping=benzene_to_toluene_mapping, + ) + + +def test_nonwater_solvent_error( + benzene_modifications, + benzene_to_toluene_mapping, + solv_settings, +): + solvent = openfe.SolventComponent(smiles='C') + stateA = openfe.ChemicalSystem( + { + 'ligand': benzene_modifications['benzene'], + 'solvent': solvent, + } + ) + + stateB = openfe.ChemicalSystem( + { + 'ligand': benzene_modifications['toluene'], + 'solvent': solvent + } + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=solv_settings) + + errmsg = "Non water solvent is not currently supported" + + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=stateA, + stateB=stateB, + mapping=benzene_to_toluene_mapping, + ) + + +def test_too_many_solv_comps_error( + benzene_modifications, + benzene_to_toluene_mapping, + solv_settings, +): + stateA = openfe.ChemicalSystem( + { + 'ligand': benzene_modifications['benzene'], + 'solvent!': openfe.SolventComponent(neutralize=True), + 'solvent2': openfe.SolventComponent(neutralize=False), + } + ) + + stateB = openfe.ChemicalSystem( + { + 'ligand': benzene_modifications['toluene'], + 'solvent!': openfe.SolventComponent(neutralize=True), + 'solvent2': openfe.SolventComponent(neutralize=False), + } + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=solv_settings) + + errmsg = "Multiple SolventComponent found, only one is supported" + + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=stateA, + stateB=stateB, + mapping=benzene_to_toluene_mapping, + ) + + +def test_bad_solv_settings( + benzene_system, + toluene_system, + benzene_to_toluene_mapping, + solv_settings, +): + """ + Test a case where the solvent settings would be wrong. + Not doing every cases since those are covered under + ``test_openmmutils.py``. + """ + solv_settings.solvation_settings.solvent_padding = 1.2 * offunit.nanometer + solv_settings.solvation_settings.number_of_solvent_molecules = 20 + + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=solv_settings) + + errmsg = "Only one of solvent_padding, number_of_solvent_molecules," + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_system, + stateB=toluene_system, + mapping=benzene_to_toluene_mapping + ) + + +def test_too_many_prot_comps_error( + benzene_modifications, + benzene_to_toluene_mapping, + T4_protein_component, + eg5_protein, + solv_settings, +): + + stateA = openfe.ChemicalSystem( + { + 'ligand': benzene_modifications['benzene'], + 'solvent': openfe.SolventComponent(), + 'protein1': T4_protein_component, + 'protein2': eg5_protein, + } + ) + + stateB = openfe.ChemicalSystem( + { + 'ligand': benzene_modifications['toluene'], + 'solvent': openfe.SolventComponent(), + 'protein1': T4_protein_component, + 'protein2': eg5_protein, + } + ) + + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=solv_settings) + + errmsg = "Multiple ProteinComponent found, only one is supported" + + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=stateA, + stateB=stateB, + mapping=benzene_to_toluene_mapping, + ) + + def test_element_change_warning(atom_mapping_basic_test_files): # check a mapping with element change gets rejected early l1 = atom_mapping_basic_test_files["2-methylnaphthalene"] @@ -248,81 +411,183 @@ def test_hightimestep( toluene_vacuum_system, benzene_to_toluene_mapping, vac_settings, - tmpdir, ): vac_settings.forcefield_settings.hydrogen_mass = 1.0 - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=vac_settings, - ) - - dag = p.create( - stateA=benzene_vacuum_system, - stateB=toluene_vacuum_system, - mapping=benzene_to_toluene_mapping, - ) - dag_unit = list(dag.protocol_units)[0] + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) errmsg = "too large for hydrogen mass" - with tmpdir.as_cwd(): - with pytest.raises(ValueError, match=errmsg): - dag_unit.run(dry=True) + + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + extends=None + ) -def test_n_replicas_not_n_windows( +def test_time_per_iteration_divmod( benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, vac_settings, - tmpdir, ): - # For PR #125 we pin such that the number of lambda windows - # equals the numbers of replicas used - TODO: remove limitation - # default lambda windows is 11 - vac_settings.simulation_settings.n_replicas = 13 + vac_settings.simulation_settings.time_per_iteration = 10 * offunit.ps + vac_settings.integrator_settings.timestep = 4 * offunit.ps - errmsg = "Number of replicas 13 does not equal the number of lambda windows 11" + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) - with tmpdir.as_cwd(): - with pytest.raises(ValueError, match=errmsg): - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=vac_settings, - ) - dag = p.create( - stateA=benzene_vacuum_system, - stateB=toluene_vacuum_system, - mapping=benzene_to_toluene_mapping, - ) - dag_unit = list(dag.protocol_units)[0] - dag_unit.run(dry=True) + errmsg = "does not evenly divide by the timestep" + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + extends=None + ) -def test_vaccuum_PME_error( - benzene_vacuum_system, benzene_modifications, benzene_to_toluene_mapping + +@pytest.mark.parametrize( + "attribute", ["equilibration_length", "production_length"] +) +def test_simsteps_not_timestep_divisible( + attribute, + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + vac_settings, ): - # state B doesn't have a solvent component (i.e. its vacuum) - stateB = openfe.ChemicalSystem({"ligand": benzene_modifications["toluene"]}) + setattr(vac_settings.simulation_settings, attribute, 102 * offunit.fs) + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) - p = openmm_rfe.RelativeHybridTopologyProtocol( - settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings(), + errmsg = "Simulation time not divisible by timestep" + + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + extends=None + ) + + +@pytest.mark.parametrize( + "attribute", ["equilibration_length", "production_length"] +) +def test_simsteps_not_mcstep_divisible( + attribute, + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + vac_settings, +): + setattr(vac_settings.simulation_settings, attribute, 102 * offunit.ps) + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) + + errmsg = ( + "should contain a number of steps divisible by the number of " + "integrator timesteps" ) - errmsg = "PME cannot be used for vacuum transform" + with pytest.raises(ValueError, match=errmsg): - _ = p.create( + p.validate( stateA=benzene_vacuum_system, - stateB=stateB, + stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, + extends=None ) -def test_get_alchemical_waters_no_waters( - benzene_solvent_openmm_system, +def test_checkpoint_interval_not_divisible_time_per_iter( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + vac_settings, ): - system, topology, positions = benzene_solvent_openmm_system + vac_settings.output_settings.checkpoint_interval = 4 * offunit.ps + vac_settings.simulation_settings.time_per_iteration = 2.5 * offunit.ps + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) - errmsg = "There are no waters" + errmsg = "does not evenly divide by the amount of time per state MCMC" with pytest.raises(ValueError, match=errmsg): - topologyhelpers.get_alchemical_waters( - topology, positions, charge_difference=1, distance_cutoff=3.0 * unit.nanometer + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + extends=None + ) + + +@pytest.mark.parametrize( + "attribute", + ["positions_write_frequency", "velocities_write_frequency"] +) +def test_pos_vel_write_frequency_not_divisible( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + attribute, + vac_settings, +): + setattr(vac_settings.output_settings, attribute, 100.1 * offunit.picosecond) + + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) + + errmsg = f"The output settings' {attribute}" + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + extends=None + ) + + +@pytest.mark.parametrize( + "attribute", + ["real_time_analysis_interval", "real_time_analysis_interval"] +) +def test_pos_vel_write_frequency_not_divisible( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + attribute, + vac_settings, +): + setattr(vac_settings.simulation_settings, attribute, 100.1 * offunit.picosecond) + + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) + + errmsg = f"The {attribute}" + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + extends=None + ) + +def test_n_replicas_not_n_windows( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + vac_settings, + tmpdir, +): + # For PR #125 we pin such that the number of lambda windows + # equals the numbers of replicas used - TODO: remove limitation + vac_settings.simulation_settings.n_replicas = 13 + p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) + + errmsg = "Number of replicas in ``simulation_settings``:" + + with pytest.raises(ValueError, match=errmsg): + p.validate( + stateA=benzene_vacuum_system, + stateB=toluene_vacuum_system, + mapping=benzene_to_toluene_mapping, + extends=None ) From 5af66e81688603d997282176fd4d11c73c50e454 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 25 Dec 2025 23:50:36 -0500 Subject: [PATCH 15/91] cleanup imports --- .../protocols/openmm_rfe/equil_rfe_methods.py | 1 - .../openmm_rfe/test_hybrid_top_validation.py | 31 +------------------ 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 710f4db8e..189fffe79 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -53,7 +53,6 @@ from openff.units import Quantity, unit from openff.units.openmm import ensure_quantity, from_openmm, to_openmm from openmmtools import multistate -from rdkit import Chem from openfe.due import Doi, due from openfe.protocols.openmm_utils.omm_settings import ( diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 984c4d6bb..0f7db50d6 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -1,42 +1,13 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe import logging -import copy -import json -import sys -import xml.etree.ElementTree as ET -from importlib import resources -from math import sqrt -from pathlib import Path -from unittest import mock - -import gufe -import mdtraj as mdt -import numpy as np + import pytest -from kartograf import KartografAtomMapper -from kartograf.atom_aligner import align_mol_shape -from numpy.testing import assert_allclose -from openff.toolkit import Molecule from openff.units import unit as offunit -from openff.units.openmm import ensure_quantity, from_openmm, to_openmm -from openmm import CustomNonbondedForce, MonteCarloBarostat, NonbondedForce, XmlSerializer, app -from openmm import unit as omm_unit -from openmmforcefields.generators import SMIRNOFFTemplateGenerator -from openmmtools.multistate.multistatesampler import MultiStateSampler -from rdkit import Chem -from rdkit.Geometry import Point3D import openfe from openfe import setup from openfe.protocols import openmm_rfe -from openfe.protocols.openmm_rfe._rfe_utils import topologyhelpers -from openfe.protocols.openmm_utils import omm_compute, system_creation -from openfe.protocols.openmm_utils.charge_generation import ( - HAS_ESPALOMA_CHARGE, - HAS_NAGL, - HAS_OPENEYE, -) @pytest.fixture() From 58dd71cceb1154af70b9e6652d125ace5e23fe8f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 26 Dec 2025 00:26:36 -0500 Subject: [PATCH 16/91] Migrate protocol, units, and results for the hybridtop protocol --- openfe/protocols/openmm_rfe/__init__.py | 12 +- .../protocols/openmm_rfe/equil_rfe_methods.py | 1460 +---------------- .../openmm_rfe/hybridtop_protocols.py | 571 +++++++ .../openmm_rfe/hybridtop_unit_results.py | 240 +++ .../protocols/openmm_rfe/hybridtop_units.py | 697 ++++++++ 5 files changed, 1520 insertions(+), 1460 deletions(-) create mode 100644 openfe/protocols/openmm_rfe/hybridtop_protocols.py create mode 100644 openfe/protocols/openmm_rfe/hybridtop_unit_results.py create mode 100644 openfe/protocols/openmm_rfe/hybridtop_units.py diff --git a/openfe/protocols/openmm_rfe/__init__.py b/openfe/protocols/openmm_rfe/__init__.py index e400cc3d3..137b641c0 100644 --- a/openfe/protocols/openmm_rfe/__init__.py +++ b/openfe/protocols/openmm_rfe/__init__.py @@ -2,11 +2,7 @@ # For details, see https://github.com/OpenFreeEnergy/openfe from . import _rfe_utils -from .equil_rfe_methods import ( - RelativeHybridTopologyProtocol, - RelativeHybridTopologyProtocolResult, - RelativeHybridTopologyProtocolUnit, -) -from .equil_rfe_settings import ( - RelativeHybridTopologyProtocolSettings, -) +from .hybridtop_protocols import RelativeHybridTopologyProtocol +from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult +from .hybridtop_units import RelativeHybridTopologyProtocolUnit +from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 208ec912c..22106b484 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -1,1466 +1,22 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe -"""Equilibrium Relative Free Energy methods using OpenMM and OpenMMTools in a +"""Equilibrium Relative Free Energy Protocol using OpenMM and OpenMMTools in a Perses-like manner. -This module implements the necessary methodology toolking to run calculate a -ligand relative free energy transformation using OpenMM tools and one of the -following methods: +This module implements the necessary methodology toolking to run calculate the +relative free energy of a ligand transformation using OpenMM tools and one of +the following methods: - Hamiltonian Replica Exchange - Self-adjusted mixture sampling - Independent window sampling -TODO ----- -* Improve this docstring by adding an example use case. - Acknowledgements ---------------- This Protocol is based on, and leverages components originating from the Perses toolkit (https://github.com/choderalab/perses). """ -from __future__ import annotations - -import json -import logging -import os -import pathlib -import subprocess -import uuid -import warnings -from collections import defaultdict -from itertools import chain -from typing import Any, Iterable, Optional, Union - -import gufe -import matplotlib.pyplot as plt -import mdtraj -import numpy as np -import numpy.typing as npt -import openmmtools -from gufe import ( - ChemicalSystem, - Component, - ComponentMapping, - LigandAtomMapping, - ProteinComponent, - SmallMoleculeComponent, - SolventComponent, - settings, -) -from openff.toolkit.topology import Molecule as OFFMolecule -from openff.units import Quantity, unit -from openff.units.openmm import ensure_quantity, from_openmm, to_openmm -from openmmtools import multistate - -from openfe.due import Doi, due -from openfe.protocols.openmm_utils.omm_settings import ( - BasePartialChargeSettings, -) - -from ...analysis import plotting -from ...utils import log_system_probe, without_oechem_backend -from ..openmm_utils import ( - charge_generation, - multistate_analysis, - omm_compute, - settings_validation, - system_creation, - system_validation, -) -from . import _rfe_utils -from .equil_rfe_settings import ( - AlchemicalSettings, - IntegratorSettings, - LambdaSettings, - MultiStateOutputSettings, - MultiStateSimulationSettings, - OpenFFPartialChargeSettings, - OpenMMEngineSettings, - OpenMMSolvationSettings, - RelativeHybridTopologyProtocolSettings, -) - -logger = logging.getLogger(__name__) - - -due.cite( - Doi("10.5281/zenodo.1297683"), - description="Perses", - path="openfe.protocols.openmm_rfe.equil_rfe_methods", - cite_module=True, -) - -due.cite( - Doi("10.5281/zenodo.596622"), - description="OpenMMTools", - path="openfe.protocols.openmm_rfe.equil_rfe_methods", - cite_module=True, -) - -due.cite( - Doi("10.1371/journal.pcbi.1005659"), - description="OpenMM", - path="openfe.protocols.openmm_rfe.equil_rfe_methods", - cite_module=True, -) - - -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 RelativeHybridTopologyProtocolResult(gufe.ProtocolResult): - """Dict-like container for the output of a RelativeHybridTopologyProtocol""" - - def __init__(self, **data): - super().__init__(**data) - # data is mapping of str(repeat_id): list[protocolunitresults] - # TODO: Detect when we have extensions and stitch these together? - if any(len(pur_list) > 2 for pur_list in self.data.values()): - raise NotImplementedError("Can't stitch together results yet") - - @staticmethod - def compute_mean_estimate(dGs: list[Quantity]) -> Quantity: - u = dGs[0].u - # convert all values to units of the first value, then take average of magnitude - # this would avoid a screwy case where each value was in different units - vals = np.asarray([dG.to(u).m for dG in dGs]) - - return np.average(vals) * u - - def get_estimate(self) -> Quantity: - """Average free energy difference of this transformation - - Returns - ------- - dG : openff.units.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 = [pus[0].outputs["unit_estimate"] for pus in self.data.values()] - return self.compute_mean_estimate(dGs) - - @staticmethod - def compute_uncertainty(dGs: list[Quantity]) -> Quantity: - u = dGs[0].u - # convert all values to units of the first value, then take average of magnitude - # this would avoid a screwy case where each value was in different units - vals = np.asarray([dG.to(u).m for dG in dGs]) - - return np.std(vals) * u - - def get_uncertainty(self) -> Quantity: - """The uncertainty/error in the dG value: The std of the estimates of - each independent repeat - """ - - dGs = [pus[0].outputs["unit_estimate"] for pus in self.data.values()] - return self.compute_uncertainty(dGs) - - def get_individual_estimates(self) -> list[tuple[Quantity, Quantity]]: - """Return a list of tuples containing the individual free energy - estimates and associated MBAR errors for each repeat. - - Returns - ------- - dGs : list[tuple[openff.units.Quantity]] - n_replicate simulation list of tuples containing the free energy - estimates (first entry) and associated MBAR estimate errors - (second entry). - """ - dGs = [ - (pus[0].outputs["unit_estimate"], pus[0].outputs["unit_estimate_error"]) - for pus in self.data.values() - ] - return dGs - - def get_forward_and_reverse_energy_analysis( - self, - ) -> list[Optional[dict[str, Union[npt.NDArray, Quantity]]]]: - """ - Get a list of forward and reverse analysis of the free energies - for each repeat using uncorrelated production samples. - - The returned dicts 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. - - If the list entry is ``None`` instead of a dictionary, this indicates - that the analysis could not be carried out for that repeat. This - is most likely caused by MBAR convergence issues when attempting to - calculate free energies from too few samples. - - - Returns - ------- - forward_reverse : list[Optional[dict[str, Union[npt.NDArray, openff.units.Quantity]]]] - - - Raises - ------ - UserWarning - If any of the forward and reverse entries are ``None``. - """ - forward_reverse = [ - pus[0].outputs["forward_and_reverse_energies"] for pus in self.data.values() - ] - - if None in forward_reverse: - wmsg = ( - "One or more ``None`` entries were found in the list of " - "forward and reverse analyses. This is likely caused by " - "an MBAR convergence failure caused by too few independent " - "samples when calculating the free energies of the 10% " - "timeseries slice." - ) - warnings.warn(wmsg) - - return forward_reverse - - def get_overlap_matrices(self) -> list[dict[str, npt.NDArray]]: - """ - Return a list of dictionary containing the MBAR overlap estimates - calculated for each repeat. - - Returns - ------- - overlap_stats : list[dict[str, npt.NDArray]] - A list of dictionaries containing 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 = [pus[0].outputs["unit_mbar_overlap"] for pus in self.data.values()] - - return overlap_stats - - def get_replica_transition_statistics(self) -> list[dict[str, npt.NDArray]]: - """The replica lambda state transition statistics for each repeat. - - Note - ---- - This is currently only available in cases where a replica exchange - simulation was run. - - Returns - ------- - repex_stats : list[dict[str, npt.NDArray]] - A list of dictionaries containing 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. - """ - try: - repex_stats = [ - pus[0].outputs["replica_exchange_statistics"] for pus in self.data.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) -> list[npt.NDArray]: - """ - Returns the timeseries of replica states for each repeat. - - Returns - ------- - replica_states : List[npt.NDArray] - List of replica states for each repeat - """ - - def is_file(filename: str): - p = pathlib.Path(filename) - if not p.exists(): - errmsg = f"File could not be found {p}" - raise ValueError(errmsg) - return p - - replica_states = [] - - for pus in self.data.values(): - nc = is_file(pus[0].outputs["nc"]) - dir_path = nc.parents[0] - chk = is_file(dir_path / pus[0].outputs["last_checkpoint"]).name - reporter = multistate.MultiStateReporter( - storage=nc, checkpoint_storage=chk, open_mode="r" - ) - replica_states.append(np.asarray(reporter.read_replica_thermodynamic_states())) - reporter.close() - - return replica_states - - def equilibration_iterations(self) -> list[float]: - """ - Returns the number of equilibration iterations for each repeat - of the calculation. - - Returns - ------- - equilibration_lengths : list[float] - """ - equilibration_lengths = [ - pus[0].outputs["equilibration_iterations"] for pus in self.data.values() - ] - - return equilibration_lengths - - def production_iterations(self) -> list[float]: - """ - Returns the number of uncorrelated production samples for each - repeat of the calculation. - - Returns - ------- - production_lengths : list[float] - """ - production_lengths = [pus[0].outputs["production_iterations"] for pus in self.data.values()] - - return production_lengths - - -class RelativeHybridTopologyProtocol(gufe.Protocol): - """ - Relative Free Energy calculations using OpenMM and OpenMMTools. - - Based on `Perses `_ - - See Also - -------- - :mod:`openfe.protocols` - :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologySettings` - :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologyResult` - :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologyProtocolUnit` - """ - - result_cls = RelativeHybridTopologyProtocolResult - _settings_cls = RelativeHybridTopologyProtocolSettings - _settings: RelativeHybridTopologyProtocolSettings - - @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 RelativeHybridTopologyProtocolSettings( - protocol_repeats=3, - forcefield_settings=settings.OpenMMSystemGeneratorFFSettings(), - thermo_settings=settings.ThermoSettings( - temperature=298.15 * unit.kelvin, - pressure=1 * unit.bar, - ), - partial_charge_settings=OpenFFPartialChargeSettings(), - solvation_settings=OpenMMSolvationSettings(), - alchemical_settings=AlchemicalSettings(softcore_LJ="gapsys"), - lambda_settings=LambdaSettings(), - simulation_settings=MultiStateSimulationSettings( - equilibration_length=1.0 * unit.nanosecond, - production_length=5.0 * unit.nanosecond, - ), - engine_settings=OpenMMEngineSettings(), - integrator_settings=IntegratorSettings(), - output_settings=MultiStateOutputSettings(), - ) - - @classmethod - def _adaptive_settings( - cls, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - mapping: gufe.LigandAtomMapping | list[gufe.LigandAtomMapping], - initial_settings: None | RelativeHybridTopologyProtocolSettings = None, - ) -> RelativeHybridTopologyProtocolSettings: - """ - Get the recommended OpenFE settings for this protocol based on the input states involved in the - transformation. - - These are intended as a suitable starting point for creating an instance of this protocol, which can be further - customized before performing a Protocol. - - Parameters - ---------- - stateA : ChemicalSystem - The initial state of the transformation. - stateB : ChemicalSystem - The final state of the transformation. - mapping : LigandAtomMapping | list[LigandAtomMapping] - The mapping(s) between transforming components in stateA and stateB. - initial_settings : None | RelativeHybridTopologyProtocolSettings, optional - Initial settings to base the adaptive settings on. If None, default settings are used. - - Returns - ------- - RelativeHybridTopologyProtocolSettings - The recommended settings for this protocol based on the input states. - - Notes - ----- - - If the transformation involves a change in net charge, the settings are adapted to use a more expensive - protocol with 22 lambda windows and 20 ns production length per window. - - If both states contain a ProteinComponent, the solvation padding is set to 1 nm. - - If initial_settings is provided, the adaptive settings are based on a copy of these settings. - """ - # use initial settings or default settings - # this is needed for the CLI so we don't override user settings - if initial_settings is not None: - protocol_settings = initial_settings.copy(deep=True) - else: - protocol_settings = cls.default_settings() - - if isinstance(mapping, list): - mapping = mapping[0] - - if mapping.get_alchemical_charge_difference() != 0: - # apply the recommended charge change settings taken from the industry benchmarking as fast settings not validated - # - info = ( - "Charge changing transformation between ligands " - f"{mapping.componentA.name} and {mapping.componentB.name}. " - "A more expensive protocol with 22 lambda windows, sampled " - "for 20 ns each, will be used here." - ) - logger.info(info) - protocol_settings.alchemical_settings.explicit_charge_correction = True - protocol_settings.simulation_settings.production_length = 20 * unit.nanosecond - protocol_settings.simulation_settings.n_replicas = 22 - protocol_settings.lambda_settings.lambda_windows = 22 - - # adapt the solvation padding based on the system components - if stateA.contains(ProteinComponent) and stateB.contains(ProteinComponent): - protocol_settings.solvation_settings.solvent_padding = 1 * unit.nanometer - - return protocol_settings - - @staticmethod - def _validate_endstates( - stateA: ChemicalSystem, - stateB: ChemicalSystem, - ) -> None: - """ - Validates the end states for the RFE protocol. - - Parameters - ---------- - stateA : ChemicalSystem - The chemical system of end state A. - stateB : ChemicalSystem - The chemical system of end state B. - - Raises - ------ - ValueError - * If either state contains more than one unique Component. - * If unique components are not SmallMoleculeComponents. - """ - # Get the difference in Components between each state - diff = stateA.component_diff(stateB) - - for i, entry in enumerate(diff): - state_label = "A" if i == 0 else "B" - - # Check that there is only one unique Component in each state - if len(entry) != 1: - errmsg = ( - "Only one alchemical component is allowed per end state. " - f"Found {len(entry)} in state {state_label}." - ) - raise ValueError(errmsg) - - # Check that the unique Component is a SmallMoleculeComponent - if not isinstance(entry[0], SmallMoleculeComponent): - errmsg = ( - f"Alchemical component in state {state_label} is of type " - f"{type(entry[0])}, but only SmallMoleculeComponents " - "transformations are currently supported." - ) - raise ValueError(errmsg) - - @staticmethod - def _validate_mapping( - mapping: Optional[Union[ComponentMapping, list[ComponentMapping]]], - alchemical_components: dict[str, list[Component]], - ) -> None: - """ - Validates that the provided mapping(s) are suitable for the RFE protocol. - - Parameters - ---------- - mapping : Optional[Union[ComponentMapping, list[ComponentMapping]]] - all mappings between transforming components. - alchemical_components : dict[str, list[Component]] - Dictionary contatining the alchemical components for - states A and B. - - Raises - ------ - ValueError - * If there are more than one mapping or mapping is None - * If the mapping components are not in the alchemical components. - UserWarning - * Mappings which involve element changes in core atoms - """ - # if a single mapping is provided, convert to list - if isinstance(mapping, ComponentMapping): - mapping = [mapping] - - # For now we only support a single mapping - if mapping is None or len(mapping) > 1: - errmsg = "A single LigandAtomMapping is expected for this Protocol" - raise ValueError(errmsg) - - # check that the mapping components are in the alchemical components - for m in mapping: - if m.componentA not in alchemical_components["stateA"]: - raise ValueError(f"Mapping componentA {m.componentA} not in alchemical components of stateA") - if m.componentB not in alchemical_components["stateB"]: - raise ValueError(f"Mapping componentB {m.componentB} not in alchemical components of stateB") - - # TODO: remove - this is now the default behaviour? - # Check for element changes in mappings - for m in mapping: - molA = m.componentA.to_rdkit() - molB = m.componentB.to_rdkit() - for i, j in m.componentA_to_componentB.items(): - atomA = molA.GetAtomWithIdx(i) - atomB = molB.GetAtomWithIdx(j) - if atomA.GetAtomicNum() != atomB.GetAtomicNum(): - wmsg = ( - f"Element change in mapping between atoms " - f"Ligand A: {i} (element {atomA.GetAtomicNum()}) and " - f"Ligand B: {j} (element {atomB.GetAtomicNum()})\n" - "No mass scaling is attempted in the hybrid topology, " - "the average mass of the two atoms will be used in the " - "simulation" - ) - logger.warning(wmsg) - warnings.warn(wmsg) - - @staticmethod - def _validate_charge_difference( - mapping: LigandAtomMapping, - nonbonded_method: str, - explicit_charge_correction: bool, - solvent_component: SolventComponent | None, - ): - """ - Validates the net charge difference between the two states. - - Parameters - ---------- - mapping : dict[str, ComponentMapping] - Dictionary of mappings between transforming components. - nonbonded_method : str - The OpenMM nonbonded method used for the simulation. - explicit_charge_correction : bool - Whether or not to use an explicit charge correction. - solvent_component : openfe.SolventComponent | None - The SolventComponent of the simulation. - - Raises - ------ - ValueError - * If an explicit charge correction is attempted and the - nonbonded method is not PME. - * If the absolute charge difference is greater than one - and an explicit charge correction is attempted. - UserWarning - * If there is any charge difference. - """ - difference = mapping.get_alchemical_charge_difference() - - if abs(difference) == 0: - return - - if not explicit_charge_correction: - wmsg = ( - f"A charge difference of {difference} is observed " - "between the end states. No charge correction has " - "been requested, please account for this in your " - "final results." - ) - logger.warning(wmsg) - warnings.warn(wmsg) - return - - if solvent_component is None: - errmsg = "Cannot use eplicit charge correction without solvent" - raise ValueError(errmsg) - - # We implicitly check earlier that we have to have pme for a solvated - # system, so we only need to check the nonbonded method here - if nonbonded_method.lower() != "pme": - errmsg = "Explicit charge correction when not using PME is not currently supported." - raise ValueError(errmsg) - - if abs(difference) > 1: - errmsg = ( - f"A charge difference of {difference} is observed " - "between the end states and an explicit charge " - "correction has been requested. Unfortunately " - "only absolute differences of 1 are supported." - ) - raise ValueError(errmsg) - - ion = { - -1: solvent_component.positive_ion, - 1: solvent_component.negative_ion - }[difference] - - wmsg = ( - f"A charge difference of {difference} is observed " - "between the end states. This will be addressed by " - f"transforming a water into a {ion} ion" - ) - logger.info(wmsg) - - @staticmethod - def _validate_simulation_settings( - simulation_settings: MultiStateSimulationSettings, - integrator_settings: IntegratorSettings, - output_settings: MultiStateOutputSettings, - ): - """ - Validate various simulation settings, including but not limited to - timestep conversions, and output file write frequencies. - - Parameters - ---------- - simulation_settings : MultiStateSimulationSettings - The sampler simulation settings. - integrator_settings : IntegratorSettings - Settings defining the behaviour of the integrator. - output_settings : MultiStateOutputSettings - Settings defining the simulation file writing behaviour. - - Raises - ------ - ValueError - * If the - """ - - steps_per_iteration = settings_validation.convert_steps_per_iteration( - simulation_settings=simulation_settings, - integrator_settings=integrator_settings, - ) - - _ = settings_validation.get_simsteps( - sim_length=simulation_settings.equilibration_length, - timestep=integrator_settings.timestep, - mc_steps=steps_per_iteration, - ) - - _ = settings_validation.get_simsteps( - sim_length=simulation_settings.production_length, - timestep=integrator_settings.timestep, - mc_steps=steps_per_iteration, - ) - - _ = settings_validation.convert_checkpoint_interval_to_iterations( - checkpoint_interval=output_settings.checkpoint_interval, - time_per_iteration=simulation_settings.time_per_iteration, - ) - - if output_settings.positions_write_frequency is not None: - _ = settings_validation.divmod_time_and_check( - numerator=output_settings.positions_write_frequency, - denominator=simulation_settings.time_per_iteration, - numerator_name="output settings' positions_write_frequency", - denominator_name="sampler settings' time_per_iteration", - ) - - if output_settings.velocities_write_frequency is not None: - _ = settings_validation.divmod_time_and_check( - numerator=output_settings.velocities_write_frequency, - denominator=simulation_settings.time_per_iteration, - numerator_name="output settings' velocities_write_frequency", - denominator_name="sampler settings' time_per_iteration", - ) - - _, _ = settings_validation.convert_real_time_analysis_iterations( - simulation_settings=simulation_settings, - ) - - def _validate( - self, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - mapping: gufe.ComponentMapping | list[gufe.ComponentMapping] | None, - extends: gufe.ProtocolDAGResult | None = None, - ) -> None: - # Check we're not trying to extend - if extends: - # This technically should be NotImplementedError - # but gufe.Protocol.validate calls `_validate` wrapped around an - # except for NotImplementedError, so we can't raise it here - raise ValueError("Can't extend simulations yet") - - # Validate the end states - self._validate_endstates(stateA, stateB) - - # Valildate the mapping - alchem_comps = system_validation.get_alchemical_components(stateA, stateB) - self._validate_mapping(mapping, alchem_comps) - - # Validate solvent component - nonbond = self.settings.forcefield_settings.nonbonded_method - system_validation.validate_solvent(stateA, nonbond) - - # Validate solvation settings - settings_validation.validate_openmm_solvation_settings(self.settings.solvation_settings) - - # Validate protein component - system_validation.validate_protein(stateA) - - # Validate charge difference - # Note: validation depends on the mapping & solvent component checks - if stateA.contains(SolventComponent): - solv_comp = stateA.get_components_of_type(SolventComponent)[0] - else: - solv_comp = None - - self._validate_charge_difference( - mapping=mapping[0] if isinstance(mapping, list) else mapping, - nonbonded_method=self.settings.forcefield_settings.nonbonded_method, - explicit_charge_correction=self.settings.alchemical_settings.explicit_charge_correction, - solvent_component=solv_comp, - ) - - # Validate integrator things - settings_validation.validate_timestep( - self.settings.forcefield_settings.hydrogen_mass, - self.settings.integrator_settings.timestep, - ) - - # Validate simulation & output settings - self._validate_simulation_settings( - self.settings.simulation_settings, - self.settings.integrator_settings, - self.settings.output_settings, - ) - - # Validate alchemical settings - # PR #125 temporarily pin lambda schedule spacing to n_replicas - if self.settings.simulation_settings.n_replicas != self.settings.lambda_settings.lambda_windows: - errmsg = ( - "Number of replicas in ``simulation_settings``: " - f"{self.settings.simulation_settings.n_replicas} must equal " - "the number of lambda windows in lambda_settings: " - f"{self.settings.lambda_settings.lambda_windows}." - ) - raise ValueError(errmsg) - - def _create( - self, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - mapping: Optional[Union[gufe.ComponentMapping, list[gufe.ComponentMapping]]], - extends: Optional[gufe.ProtocolDAGResult] = None, - ) -> list[gufe.ProtocolUnit]: - # validate inputs - self.validate(stateA=stateA, stateB=stateB, mapping=mapping, extends=extends) - - # get alchemical components and mapping - alchem_comps = system_validation.get_alchemical_components(stateA, stateB) - ligandmapping = mapping[0] if isinstance(mapping, list) else mapping - - # actually create and return Units - Anames = ",".join(c.name for c in alchem_comps["stateA"]) - Bnames = ",".join(c.name for c in alchem_comps["stateB"]) - - # our DAG has no dependencies, so just list units - n_repeats = self.settings.protocol_repeats - - units = [ - RelativeHybridTopologyProtocolUnit( - protocol=self, - stateA=stateA, - stateB=stateB, - ligandmapping=ligandmapping, - generation=0, - repeat_id=int(uuid.uuid4()), - name=f"{Anames} to {Bnames} repeat {i} generation 0", - ) - for i in range(n_repeats) - ] - - return units - - def _gather(self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult]) -> dict[str, Any]: - # result units will have a repeat_id and generations within this repeat_id - # first group according to repeat_id - 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 - - unsorted_repeats[pu.outputs["repeat_id"]].append(pu) - - # then sort by generation within each repeat_id list - repeats: dict[str, list[gufe.ProtocolUnitResult]] = {} - for k, v in unsorted_repeats.items(): - repeats[str(k)] = sorted(v, key=lambda x: x.outputs["generation"]) - - # returns a dict of repeat_id: sorted list of ProtocolUnitResult - return repeats - - -class RelativeHybridTopologyProtocolUnit(gufe.ProtocolUnit): - """ - Calculates the relative free energy of an alchemical ligand transformation. - """ - - def __init__( - self, - *, - protocol: RelativeHybridTopologyProtocol, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - ligandmapping: LigandAtomMapping, - generation: int, - repeat_id: int, - name: Optional[str] = None, - ): - """ - Parameters - ---------- - protocol : RelativeHybridTopologyProtocol - protocol used to create this Unit. Contains key information such - as the settings. - stateA, stateB : ChemicalSystem - the two ligand SmallMoleculeComponents to transform between. The - transformation will go from ligandA to ligandB. - ligandmapping : LigandAtomMapping - the mapping of atoms between the two ligand components - repeat_id : int - identifier for which repeat (aka replica/clone) this Unit is - generation : int - counter for how many times this repeat has been extended - name : str, optional - human-readable identifier for this Unit - - Notes - ----- - The mapping used must not involve any elemental changes. A check for - this is done on class creation. - """ - super().__init__( - name=name, - protocol=protocol, - stateA=stateA, - stateB=stateB, - ligandmapping=ligandmapping, - repeat_id=repeat_id, - generation=generation, - ) - - @staticmethod - def _assign_partial_charges( - charge_settings: OpenFFPartialChargeSettings, - off_small_mols: dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]], - ) -> None: - """ - Assign partial charges to SMCs. - - Parameters - ---------- - charge_settings : OpenFFPartialChargeSettings - Settings for controlling how the partial charges are assigned. - off_small_mols : dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]] - Dictionary of dictionary of OpenFF Molecules to add, keyed by - state and SmallMoleculeComponent. - """ - for smc, mol in chain( - off_small_mols["stateA"], off_small_mols["stateB"], off_small_mols["both"] - ): - charge_generation.assign_offmol_partial_charges( - offmol=mol, - overwrite=False, - method=charge_settings.partial_charge_method, - toolkit_backend=charge_settings.off_toolkit_backend, - generate_n_conformers=charge_settings.number_of_conformers, - nagl_model=charge_settings.nagl_model, - ) - - def run( - self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None - ) -> dict[str, Any]: - """Run the relative 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. - scratch_basepath: Pathlike, optional - Where to store temporary files, defaults to current working directory - shared_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: - self.logger.info("Preparing the hybrid topology simulation") - if scratch_basepath is None: - scratch_basepath = pathlib.Path(".") - if shared_basepath is None: - # use cwd - shared_basepath = pathlib.Path(".") - - # 0. General setup and settings dependency resolution step - - # Extract relevant settings - protocol_settings: RelativeHybridTopologyProtocolSettings = self._inputs[ - "protocol" - ].settings - stateA = self._inputs["stateA"] - stateB = self._inputs["stateB"] - mapping = self._inputs["ligandmapping"] - - forcefield_settings: settings.OpenMMSystemGeneratorFFSettings = ( - protocol_settings.forcefield_settings - ) - thermo_settings: settings.ThermoSettings = protocol_settings.thermo_settings - alchem_settings: AlchemicalSettings = protocol_settings.alchemical_settings - lambda_settings: LambdaSettings = protocol_settings.lambda_settings - charge_settings: BasePartialChargeSettings = protocol_settings.partial_charge_settings - solvation_settings: OpenMMSolvationSettings = protocol_settings.solvation_settings - sampler_settings: MultiStateSimulationSettings = protocol_settings.simulation_settings - output_settings: MultiStateOutputSettings = protocol_settings.output_settings - integrator_settings: IntegratorSettings = protocol_settings.integrator_settings - - # TODO: Also validate various conversions? - # Convert various time based inputs to steps/iterations - steps_per_iteration = settings_validation.convert_steps_per_iteration( - simulation_settings=sampler_settings, - integrator_settings=integrator_settings, - ) - - equil_steps = settings_validation.get_simsteps( - sim_length=sampler_settings.equilibration_length, - timestep=integrator_settings.timestep, - mc_steps=steps_per_iteration, - ) - prod_steps = settings_validation.get_simsteps( - sim_length=sampler_settings.production_length, - timestep=integrator_settings.timestep, - mc_steps=steps_per_iteration, - ) - - solvent_comp, protein_comp, small_mols = system_validation.get_components(stateA) - - # Get the change difference between the end states - # and check if the charge correction used is appropriate - charge_difference = mapping.get_alchemical_charge_difference() - - # 1. Create stateA system - self.logger.info("Parameterizing molecules") - - # a. create offmol dictionaries and assign partial charges - # workaround for conformer generation failures - # see openfe issue #576 - # calculate partial charges manually if not already given - # convert to OpenFF here, - # and keep the molecule around to maintain the partial charges - off_small_mols: dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]] - off_small_mols = { - "stateA": [(mapping.componentA, mapping.componentA.to_openff())], - "stateB": [(mapping.componentB, mapping.componentB.to_openff())], - "both": [ - (m, m.to_openff()) - for m in small_mols - if (m != mapping.componentA and m != mapping.componentB) - ], - } - - self._assign_partial_charges(charge_settings, off_small_mols) - - # b. get a system generator - if output_settings.forcefield_cache is not None: - ffcache = shared_basepath / output_settings.forcefield_cache - else: - ffcache = None - - # Block out oechem backend in system_generator calls to avoid - # any issues with smiles roundtripping between rdkit and oechem - with without_oechem_backend(): - system_generator = system_creation.get_system_generator( - forcefield_settings=forcefield_settings, - integrator_settings=integrator_settings, - thermo_settings=thermo_settings, - cache=ffcache, - has_solvent=solvent_comp is not None, - ) - - # c. force the creation of parameters - # This is necessary because we need to have the FF templates - # registered ahead of solvating the system. - for smc, mol in chain( - off_small_mols["stateA"], off_small_mols["stateB"], off_small_mols["both"] - ): - system_generator.create_system(mol.to_topology().to_openmm(), molecules=[mol]) - - # c. get OpenMM Modeller + a dictionary of resids for each component - stateA_modeller, comp_resids = system_creation.get_omm_modeller( - protein_comp=protein_comp, - solvent_comp=solvent_comp, - small_mols=dict(chain(off_small_mols["stateA"], off_small_mols["both"])), - omm_forcefield=system_generator.forcefield, - solvent_settings=solvation_settings, - ) - - # d. get topology & positions - # Note: roundtrip positions to remove vec3 issues - stateA_topology = stateA_modeller.getTopology() - stateA_positions = to_openmm(from_openmm(stateA_modeller.getPositions())) - - # e. create the stateA System - # Block out oechem backend in system_generator calls to avoid - # any issues with smiles roundtripping between rdkit and oechem - with without_oechem_backend(): - stateA_system = system_generator.create_system( - stateA_modeller.topology, - molecules=[m for _, m in chain(off_small_mols["stateA"], off_small_mols["both"])], - ) - - # 2. Get stateB system - # a. get the topology - stateB_topology, stateB_alchem_resids = _rfe_utils.topologyhelpers.combined_topology( - stateA_topology, - # zeroth item (there's only one) then get the OFF representation - off_small_mols["stateB"][0][1].to_topology().to_openmm(), - exclude_resids=comp_resids[mapping.componentA], - ) - - # b. get a list of small molecules for stateB - # Block out oechem backend in system_generator calls to avoid - # any issues with smiles roundtripping between rdkit and oechem - with without_oechem_backend(): - stateB_system = system_generator.create_system( - stateB_topology, - molecules=[m for _, m in chain(off_small_mols["stateB"], off_small_mols["both"])], - ) - - # c. Define correspondence mappings between the two systems - ligand_mappings = _rfe_utils.topologyhelpers.get_system_mappings( - mapping.componentA_to_componentB, - stateA_system, - stateA_topology, - comp_resids[mapping.componentA], - stateB_system, - stateB_topology, - stateB_alchem_resids, - # These are non-optional settings for this method - fix_constraints=True, - ) - - # d. if a charge correction is necessary, select alchemical waters - # and transform them - if alchem_settings.explicit_charge_correction: - alchem_water_resids = _rfe_utils.topologyhelpers.get_alchemical_waters( - stateA_topology, - stateA_positions, - charge_difference, - alchem_settings.explicit_charge_correction_cutoff, - ) - _rfe_utils.topologyhelpers.handle_alchemical_waters( - alchem_water_resids, - stateB_topology, - stateB_system, - ligand_mappings, - charge_difference, - solvent_comp, - ) - - # e. Finally get the positions - stateB_positions = _rfe_utils.topologyhelpers.set_and_check_new_positions( - ligand_mappings, - stateA_topology, - stateB_topology, - old_positions=ensure_quantity(stateA_positions, "openmm"), - insert_positions=ensure_quantity( - off_small_mols["stateB"][0][1].conformers[0], "openmm" - ), - ) - - # 3. Create the hybrid topology - # a. Get softcore potential settings - if alchem_settings.softcore_LJ.lower() == "gapsys": - softcore_LJ_v2 = True - elif alchem_settings.softcore_LJ.lower() == "beutler": - softcore_LJ_v2 = False - # b. Get hybrid topology factory - hybrid_factory = _rfe_utils.relative.HybridTopologyFactory( - stateA_system, - stateA_positions, - stateA_topology, - stateB_system, - stateB_positions, - stateB_topology, - old_to_new_atom_map=ligand_mappings["old_to_new_atom_map"], - old_to_new_core_atom_map=ligand_mappings["old_to_new_core_atom_map"], - use_dispersion_correction=alchem_settings.use_dispersion_correction, - softcore_alpha=alchem_settings.softcore_alpha, - softcore_LJ_v2=softcore_LJ_v2, - softcore_LJ_v2_alpha=alchem_settings.softcore_alpha, - interpolate_old_and_new_14s=alchem_settings.turn_off_core_unique_exceptions, - ) - - # 4. Create lambda schedule - # TODO - this should be exposed to users, maybe we should offer the - # ability to print the schedule directly in settings? - # fmt: off - lambdas = _rfe_utils.lambdaprotocol.LambdaProtocol( - functions=lambda_settings.lambda_functions, - windows=lambda_settings.lambda_windows - ) - # fmt: on - # PR #125 temporarily pin lambda schedule spacing to n_replicas - n_replicas = sampler_settings.n_replicas - if n_replicas != len(lambdas.lambda_schedule): - errmsg = ( - f"Number of replicas {n_replicas} " - f"does not equal the number of lambda windows " - f"{len(lambdas.lambda_schedule)}" - ) - raise ValueError(errmsg) - - # 9. Create the multistate reporter - # Get the sub selection of the system to print coords for - selection_indices = hybrid_factory.hybrid_topology.select(output_settings.output_indices) - - # a. Create the multistate reporter - # convert checkpoint_interval from time to iterations - chk_intervals = settings_validation.convert_checkpoint_interval_to_iterations( - checkpoint_interval=output_settings.checkpoint_interval, - time_per_iteration=sampler_settings.time_per_iteration, - ) - - nc = shared_basepath / output_settings.output_filename - chk = output_settings.checkpoint_storage_filename - - if output_settings.positions_write_frequency is not None: - pos_interval = settings_validation.divmod_time_and_check( - numerator=output_settings.positions_write_frequency, - denominator=sampler_settings.time_per_iteration, - numerator_name="output settings' position_write_frequency", - denominator_name="sampler settings' time_per_iteration", - ) - else: - pos_interval = 0 - - if output_settings.velocities_write_frequency is not None: - vel_interval = settings_validation.divmod_time_and_check( - numerator=output_settings.velocities_write_frequency, - denominator=sampler_settings.time_per_iteration, - numerator_name="output settings' velocity_write_frequency", - denominator_name="sampler settings' time_per_iteration", - ) - else: - vel_interval = 0 - - reporter = multistate.MultiStateReporter( - storage=nc, - analysis_particle_indices=selection_indices, - checkpoint_interval=chk_intervals, - checkpoint_storage=chk, - position_interval=pos_interval, - velocity_interval=vel_interval, - ) - - # b. Write out a PDB containing the subsampled hybrid state - # fmt: off - bfactors = np.zeros_like(selection_indices, dtype=float) # solvent - bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['unique_old_atoms']))] = 0.25 # lig A - bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['core_atoms']))] = 0.50 # core - bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['unique_new_atoms']))] = 0.75 # lig B - # bfactors[np.in1d(selection_indices, protein)] = 1.0 # prot+cofactor - if len(selection_indices) > 0: - traj = mdtraj.Trajectory( - hybrid_factory.hybrid_positions[selection_indices, :], - hybrid_factory.hybrid_topology.subset(selection_indices), - ).save_pdb( - shared_basepath / output_settings.output_structure, - bfactors=bfactors, - ) - # fmt: on - - # 10. Get compute platform - # restrict to a single CPU if running vacuum - restrict_cpu = forcefield_settings.nonbonded_method.lower() == "nocutoff" - platform = omm_compute.get_openmm_platform( - platform_name=protocol_settings.engine_settings.compute_platform, - gpu_device_index=protocol_settings.engine_settings.gpu_device_index, - restrict_cpu_count=restrict_cpu, - ) - - # 11. Set the integrator - # a. Validate integrator settings for current system - # Virtual sites sanity check - ensure we restart velocities when - # there are virtual sites in the system - if hybrid_factory.has_virtual_sites: - if not integrator_settings.reassign_velocities: - errmsg = ( - "Simulations with virtual sites without velocity " - "reassignments are unstable in openmmtools" - ) - raise ValueError(errmsg) - - # b. create langevin integrator - integrator = openmmtools.mcmc.LangevinDynamicsMove( - timestep=to_openmm(integrator_settings.timestep), - collision_rate=to_openmm(integrator_settings.langevin_collision_rate), - n_steps=steps_per_iteration, - reassign_velocities=integrator_settings.reassign_velocities, - n_restart_attempts=integrator_settings.n_restart_attempts, - constraint_tolerance=integrator_settings.constraint_tolerance, - ) - - # 12. Create sampler - self.logger.info("Creating and setting up the sampler") - rta_its, rta_min_its = settings_validation.convert_real_time_analysis_iterations( - simulation_settings=sampler_settings, - ) - # convert early_termination_target_error from kcal/mol to kT - early_termination_target_error = ( - settings_validation.convert_target_error_from_kcal_per_mole_to_kT( - thermo_settings.temperature, - sampler_settings.early_termination_target_error, - ) - ) - - if sampler_settings.sampler_method.lower() == "repex": - sampler = _rfe_utils.multistate.HybridRepexSampler( - mcmc_moves=integrator, - hybrid_system=hybrid_factory.hybrid_system, - hybrid_positions=hybrid_factory.hybrid_positions, - online_analysis_interval=rta_its, - online_analysis_target_error=early_termination_target_error, - online_analysis_minimum_iterations=rta_min_its, - ) - elif sampler_settings.sampler_method.lower() == "sams": - sampler = _rfe_utils.multistate.HybridSAMSSampler( - mcmc_moves=integrator, - hybrid_system=hybrid_factory.hybrid_system, - hybrid_positions=hybrid_factory.hybrid_positions, - online_analysis_interval=rta_its, - online_analysis_minimum_iterations=rta_min_its, - flatness_criteria=sampler_settings.sams_flatness_criteria, - gamma0=sampler_settings.sams_gamma0, - ) - elif sampler_settings.sampler_method.lower() == "independent": - sampler = _rfe_utils.multistate.HybridMultiStateSampler( - mcmc_moves=integrator, - hybrid_system=hybrid_factory.hybrid_system, - hybrid_positions=hybrid_factory.hybrid_positions, - online_analysis_interval=rta_its, - online_analysis_target_error=early_termination_target_error, - online_analysis_minimum_iterations=rta_min_its, - ) - else: - raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") - - sampler.setup( - n_replicas=sampler_settings.n_replicas, - reporter=reporter, - lambda_protocol=lambdas, - temperature=to_openmm(thermo_settings.temperature), - endstates=alchem_settings.endstate_dispersion_correction, - minimization_platform=platform.getName(), - # Set minimization steps to None when running in dry mode - # otherwise do a very small one to avoid NaNs - minimization_steps=100 if not dry else None, - ) - - try: - # Create context caches (energy + sampler) - 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, - ) - - sampler.energy_context_cache = energy_context_cache - sampler.sampler_context_cache = sampler_context_cache - - if not dry: # pragma: no-cover - # minimize - if verbose: - self.logger.info("Running minimization") - - sampler.minimize(max_iterations=sampler_settings.minimization_steps) - - # equilibrate - if verbose: - self.logger.info("Running equilibration phase") - - sampler.equilibrate(int(equil_steps / steps_per_iteration)) - - # production - if verbose: - self.logger.info("Running production phase") - - sampler.extend(int(prod_steps / steps_per_iteration)) - - self.logger.info("Production phase complete") - - self.logger.info("Post-simulation analysis of results") - # calculate relevant analyses of the free energies & sampling - # First close & reload the reporter to avoid netcdf clashes - analyzer = multistate_analysis.MultistateEquilFEAnalysis( - reporter, - sampling_method=sampler_settings.sampler_method.lower(), - result_units=unit.kilocalorie_per_mole, - ) - analyzer.plot(filepath=shared_basepath, filename_prefix="") - analyzer.close() - - else: - # clean up the reporter file - fns = [ - shared_basepath / output_settings.output_filename, - shared_basepath / output_settings.checkpoint_storage_filename, - ] - for fn in fns: - os.remove(fn) - finally: - # close reporter when you're done, prevent - # file handle clashes - reporter.close() - - # clear GPU contexts - # TODO: use cache.empty() calls when openmmtools #690 is resolved - # replace with above - for context in list(energy_context_cache._lru._data.keys()): - del energy_context_cache._lru._data[context] - for context in list(sampler_context_cache._lru._data.keys()): - del sampler_context_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_context_cache, energy_context_cache - - if not dry: - del integrator, sampler - - if not dry: # pragma: no-cover - return {"nc": nc, "last_checkpoint": chk, **analyzer.unit_results_dict} - else: - return {"debug": - { - "sampler": sampler, - "hybrid_factory": hybrid_factory - } - } - - @staticmethod - def structural_analysis(scratch, shared) -> dict: - # don't put energy analysis in here, it uses the open file reporter - # whereas structural stuff requires that the file handle is closed - # TODO: we should just make openfe_analysis write an npz instead! - analysis_out = scratch / "structural_analysis.json" - - ret = subprocess.run( - [ - "openfe_analysis", # CLI entry point - "RFE_analysis", # CLI option - str(shared), # Where the simulation.nc fille - str(analysis_out), # Where the analysis json file is written - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if ret.returncode: - return {"structural_analysis_error": ret.stderr} - - with open(analysis_out, "rb") as f: - data = json.load(f) - - savedir = pathlib.Path(shared) - if d := data["protein_2D_RMSD"]: - fig = plotting.plot_2D_rmsd(d) - fig.savefig(savedir / "protein_2D_RMSD.png") - plt.close(fig) - f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) - f2.savefig(savedir / "ligand_COM_drift.png") - plt.close(f2) - - f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) - f3.savefig(savedir / "ligand_RMSD.png") - plt.close(f3) - - # Save to numpy compressed format (~ 6x more space efficient than JSON) - np.savez_compressed( - shared / "structural_analysis.npz", - protein_RMSD=np.asarray(data["protein_RMSD"], dtype=np.float32), - ligand_RMSD=np.asarray(data["ligand_RMSD"], dtype=np.float32), - ligand_COM_drift=np.asarray(data["ligand_wander"], dtype=np.float32), - protein_2D_RMSD=np.asarray(data["protein_2D_RMSD"], dtype=np.float32), - time_ps=np.asarray(data["time(ps)"], dtype=np.float32), - ) - - return {"structural_analysis": shared / "structural_analysis.npz"} - - def _execute( - self, - ctx: gufe.Context, - **kwargs, - ) -> dict[str, Any]: - log_system_probe(logging.INFO, paths=[ctx.scratch]) - - outputs = self.run(scratch_basepath=ctx.scratch, shared_basepath=ctx.shared) - - structural_analysis_outputs = self.structural_analysis(ctx.scratch, ctx.shared) - - return { - "repeat_id": self._inputs["repeat_id"], - "generation": self._inputs["generation"], - **outputs, - **structural_analysis_outputs, - } +from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings +from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult +from .hybridtop_units import RelativeHybridTopologyProtocolUnit +from .hybridtop_protocols import RelativeHybridTopologyProtocol diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py new file mode 100644 index 000000000..42bf2ab9a --- /dev/null +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -0,0 +1,571 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +""" +Hybrid Topology Protocols using OpenMM and OpenMMTools in a Perses-like manner. + +Acknowledgements +---------------- +These Protocols are based on, and leverages components originating from +the Perses toolkit (https://github.com/choderalab/perses). +""" + +from __future__ import annotations + +import logging +import uuid +import warnings +from collections import defaultdict +from typing import Any, Iterable, Optional, Union + +import gufe +from gufe import ( + ChemicalSystem, + Component, + ComponentMapping, + LigandAtomMapping, + ProteinComponent, + SmallMoleculeComponent, + SolventComponent, + settings, +) +from openff.units import unit as offunit + +from openfe.due import Doi, due + +from ..openmm_utils import ( + settings_validation, + system_validation, +) +from .equil_rfe_settings import ( + AlchemicalSettings, + IntegratorSettings, + LambdaSettings, + MultiStateOutputSettings, + MultiStateSimulationSettings, + OpenFFPartialChargeSettings, + OpenMMEngineSettings, + OpenMMSolvationSettings, + RelativeHybridTopologyProtocolSettings, +) +from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult +from .hybridtop_units import RelativeHybridTopologyProtocolUnit + + +logger = logging.getLogger(__name__) + + +due.cite( + Doi("10.5281/zenodo.1297683"), + description="Perses", + path="openfe.protocols.openmm_rfe.hybridtop_protocols", + cite_module=True, +) + +due.cite( + Doi("10.5281/zenodo.596622"), + description="OpenMMTools", + path="openfe.protocols.openmm_rfe.hybridtop_protocols", + cite_module=True, +) + +due.cite( + Doi("10.1371/journal.pcbi.1005659"), + description="OpenMM", + path="openfe.protocols.openmm_rfe.hybridtop_protocols", + cite_module=True, +) + + +class RelativeHybridTopologyProtocol(gufe.Protocol): + """ + Relative Free Energy calculations using OpenMM and OpenMMTools. + + Based on `Perses `_ + + See Also + -------- + :mod:`openfe.protocols` + :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologySettings` + :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologyResult` + :class:`openfe.protocols.openmm_rfe.RelativeHybridTopologyProtocolUnit` + """ + + result_cls = RelativeHybridTopologyProtocolResult + _settings_cls = RelativeHybridTopologyProtocolSettings + _settings: RelativeHybridTopologyProtocolSettings + + @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 RelativeHybridTopologyProtocolSettings( + protocol_repeats=3, + forcefield_settings=settings.OpenMMSystemGeneratorFFSettings(), + thermo_settings=settings.ThermoSettings( + temperature=298.15 * offunit.kelvin, + pressure=1 * offunit.bar, + ), + partial_charge_settings=OpenFFPartialChargeSettings(), + solvation_settings=OpenMMSolvationSettings(), + alchemical_settings=AlchemicalSettings(softcore_LJ="gapsys"), + lambda_settings=LambdaSettings(), + simulation_settings=MultiStateSimulationSettings( + equilibration_length=1.0 * offunit.nanosecond, + production_length=5.0 * offunit.nanosecond, + ), + engine_settings=OpenMMEngineSettings(), + integrator_settings=IntegratorSettings(), + output_settings=MultiStateOutputSettings(), + ) + + @classmethod + def _adaptive_settings( + cls, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: gufe.LigandAtomMapping | list[gufe.LigandAtomMapping], + initial_settings: None | RelativeHybridTopologyProtocolSettings = None, + ) -> RelativeHybridTopologyProtocolSettings: + """ + Get the recommended OpenFE settings for this protocol based on the input states involved in the + transformation. + + These are intended as a suitable starting point for creating an instance of this protocol, which can be further + customized before performing a Protocol. + + Parameters + ---------- + stateA : ChemicalSystem + The initial state of the transformation. + stateB : ChemicalSystem + The final state of the transformation. + mapping : LigandAtomMapping | list[LigandAtomMapping] + The mapping(s) between transforming components in stateA and stateB. + initial_settings : None | RelativeHybridTopologyProtocolSettings, optional + Initial settings to base the adaptive settings on. If None, default settings are used. + + Returns + ------- + RelativeHybridTopologyProtocolSettings + The recommended settings for this protocol based on the input states. + + Notes + ----- + - If the transformation involves a change in net charge, the settings are adapted to use a more expensive + protocol with 22 lambda windows and 20 ns production length per window. + - If both states contain a ProteinComponent, the solvation padding is set to 1 nm. + - If initial_settings is provided, the adaptive settings are based on a copy of these settings. + """ + # use initial settings or default settings + # this is needed for the CLI so we don't override user settings + if initial_settings is not None: + protocol_settings = initial_settings.copy(deep=True) + else: + protocol_settings = cls.default_settings() + + if isinstance(mapping, list): + mapping = mapping[0] + + if mapping.get_alchemical_charge_difference() != 0: + # apply the recommended charge change settings taken from the industry benchmarking as fast settings not validated + # + info = ( + "Charge changing transformation between ligands " + f"{mapping.componentA.name} and {mapping.componentB.name}. " + "A more expensive protocol with 22 lambda windows, sampled " + "for 20 ns each, will be used here." + ) + logger.info(info) + protocol_settings.alchemical_settings.explicit_charge_correction = True + protocol_settings.simulation_settings.production_length = 20 * offunit.nanosecond + protocol_settings.simulation_settings.n_replicas = 22 + protocol_settings.lambda_settings.lambda_windows = 22 + + # adapt the solvation padding based on the system components + if stateA.contains(ProteinComponent) and stateB.contains(ProteinComponent): + protocol_settings.solvation_settings.solvent_padding = 1 * offunit.nanometer + + return protocol_settings + + @staticmethod + def _validate_endstates( + stateA: ChemicalSystem, + stateB: ChemicalSystem, + ) -> None: + """ + Validates the end states for the RFE protocol. + + Parameters + ---------- + stateA : ChemicalSystem + The chemical system of end state A. + stateB : ChemicalSystem + The chemical system of end state B. + + Raises + ------ + ValueError + * If either state contains more than one unique Component. + * If unique components are not SmallMoleculeComponents. + """ + # Get the difference in Components between each state + diff = stateA.component_diff(stateB) + + for i, entry in enumerate(diff): + state_label = "A" if i == 0 else "B" + + # Check that there is only one unique Component in each state + if len(entry) != 1: + errmsg = ( + "Only one alchemical component is allowed per end state. " + f"Found {len(entry)} in state {state_label}." + ) + raise ValueError(errmsg) + + # Check that the unique Component is a SmallMoleculeComponent + if not isinstance(entry[0], SmallMoleculeComponent): + errmsg = ( + f"Alchemical component in state {state_label} is of type " + f"{type(entry[0])}, but only SmallMoleculeComponents " + "transformations are currently supported." + ) + raise ValueError(errmsg) + + @staticmethod + def _validate_mapping( + mapping: Optional[Union[ComponentMapping, list[ComponentMapping]]], + alchemical_components: dict[str, list[Component]], + ) -> None: + """ + Validates that the provided mapping(s) are suitable for the RFE protocol. + + Parameters + ---------- + mapping : Optional[Union[ComponentMapping, list[ComponentMapping]]] + all mappings between transforming components. + alchemical_components : dict[str, list[Component]] + Dictionary contatining the alchemical components for + states A and B. + + Raises + ------ + ValueError + * If there are more than one mapping or mapping is None + * If the mapping components are not in the alchemical components. + UserWarning + * Mappings which involve element changes in core atoms + """ + # if a single mapping is provided, convert to list + if isinstance(mapping, ComponentMapping): + mapping = [mapping] + + # For now we only support a single mapping + if mapping is None or len(mapping) > 1: + errmsg = "A single LigandAtomMapping is expected for this Protocol" + raise ValueError(errmsg) + + # check that the mapping components are in the alchemical components + for m in mapping: + if m.componentA not in alchemical_components["stateA"]: + raise ValueError(f"Mapping componentA {m.componentA} not in alchemical components of stateA") + if m.componentB not in alchemical_components["stateB"]: + raise ValueError(f"Mapping componentB {m.componentB} not in alchemical components of stateB") + + # TODO: remove - this is now the default behaviour? + # Check for element changes in mappings + for m in mapping: + molA = m.componentA.to_rdkit() + molB = m.componentB.to_rdkit() + for i, j in m.componentA_to_componentB.items(): + atomA = molA.GetAtomWithIdx(i) + atomB = molB.GetAtomWithIdx(j) + if atomA.GetAtomicNum() != atomB.GetAtomicNum(): + wmsg = ( + f"Element change in mapping between atoms " + f"Ligand A: {i} (element {atomA.GetAtomicNum()}) and " + f"Ligand B: {j} (element {atomB.GetAtomicNum()})\n" + "No mass scaling is attempted in the hybrid topology, " + "the average mass of the two atoms will be used in the " + "simulation" + ) + logger.warning(wmsg) + warnings.warn(wmsg) + + @staticmethod + def _validate_charge_difference( + mapping: LigandAtomMapping, + nonbonded_method: str, + explicit_charge_correction: bool, + solvent_component: SolventComponent | None, + ): + """ + Validates the net charge difference between the two states. + + Parameters + ---------- + mapping : dict[str, ComponentMapping] + Dictionary of mappings between transforming components. + nonbonded_method : str + The OpenMM nonbonded method used for the simulation. + explicit_charge_correction : bool + Whether or not to use an explicit charge correction. + solvent_component : openfe.SolventComponent | None + The SolventComponent of the simulation. + + Raises + ------ + ValueError + * If an explicit charge correction is attempted and the + nonbonded method is not PME. + * If the absolute charge difference is greater than one + and an explicit charge correction is attempted. + UserWarning + * If there is any charge difference. + """ + difference = mapping.get_alchemical_charge_difference() + + if abs(difference) == 0: + return + + if not explicit_charge_correction: + wmsg = ( + f"A charge difference of {difference} is observed " + "between the end states. No charge correction has " + "been requested, please account for this in your " + "final results." + ) + logger.warning(wmsg) + warnings.warn(wmsg) + return + + if solvent_component is None: + errmsg = "Cannot use eplicit charge correction without solvent" + raise ValueError(errmsg) + + # We implicitly check earlier that we have to have pme for a solvated + # system, so we only need to check the nonbonded method here + if nonbonded_method.lower() != "pme": + errmsg = "Explicit charge correction when not using PME is not currently supported." + raise ValueError(errmsg) + + if abs(difference) > 1: + errmsg = ( + f"A charge difference of {difference} is observed " + "between the end states and an explicit charge " + "correction has been requested. Unfortunately " + "only absolute differences of 1 are supported." + ) + raise ValueError(errmsg) + + ion = { + -1: solvent_component.positive_ion, + 1: solvent_component.negative_ion + }[difference] + + wmsg = ( + f"A charge difference of {difference} is observed " + "between the end states. This will be addressed by " + f"transforming a water into a {ion} ion" + ) + logger.info(wmsg) + + @staticmethod + def _validate_simulation_settings( + simulation_settings: MultiStateSimulationSettings, + integrator_settings: IntegratorSettings, + output_settings: MultiStateOutputSettings, + ): + """ + Validate various simulation settings, including but not limited to + timestep conversions, and output file write frequencies. + + Parameters + ---------- + simulation_settings : MultiStateSimulationSettings + The sampler simulation settings. + integrator_settings : IntegratorSettings + Settings defining the behaviour of the integrator. + output_settings : MultiStateOutputSettings + Settings defining the simulation file writing behaviour. + + Raises + ------ + ValueError + * If the + """ + + steps_per_iteration = settings_validation.convert_steps_per_iteration( + simulation_settings=simulation_settings, + integrator_settings=integrator_settings, + ) + + _ = settings_validation.get_simsteps( + sim_length=simulation_settings.equilibration_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + _ = settings_validation.get_simsteps( + sim_length=simulation_settings.production_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + _ = settings_validation.convert_checkpoint_interval_to_iterations( + checkpoint_interval=output_settings.checkpoint_interval, + time_per_iteration=simulation_settings.time_per_iteration, + ) + + if output_settings.positions_write_frequency is not None: + _ = settings_validation.divmod_time_and_check( + numerator=output_settings.positions_write_frequency, + denominator=simulation_settings.time_per_iteration, + numerator_name="output settings' positions_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + + if output_settings.velocities_write_frequency is not None: + _ = settings_validation.divmod_time_and_check( + numerator=output_settings.velocities_write_frequency, + denominator=simulation_settings.time_per_iteration, + numerator_name="output settings' velocities_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + + _, _ = settings_validation.convert_real_time_analysis_iterations( + simulation_settings=simulation_settings, + ) + + def _validate( + self, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: gufe.ComponentMapping | list[gufe.ComponentMapping] | None, + extends: gufe.ProtocolDAGResult | None = None, + ) -> None: + # Check we're not trying to extend + if extends: + # This technically should be NotImplementedError + # but gufe.Protocol.validate calls `_validate` wrapped around an + # except for NotImplementedError, so we can't raise it here + raise ValueError("Can't extend simulations yet") + + # Validate the end states + self._validate_endstates(stateA, stateB) + + # Valildate the mapping + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + self._validate_mapping(mapping, alchem_comps) + + # Validate solvent component + nonbond = self.settings.forcefield_settings.nonbonded_method + system_validation.validate_solvent(stateA, nonbond) + + # Validate solvation settings + settings_validation.validate_openmm_solvation_settings(self.settings.solvation_settings) + + # Validate protein component + system_validation.validate_protein(stateA) + + # Validate charge difference + # Note: validation depends on the mapping & solvent component checks + if stateA.contains(SolventComponent): + solv_comp = stateA.get_components_of_type(SolventComponent)[0] + else: + solv_comp = None + + self._validate_charge_difference( + mapping=mapping[0] if isinstance(mapping, list) else mapping, + nonbonded_method=self.settings.forcefield_settings.nonbonded_method, + explicit_charge_correction=self.settings.alchemical_settings.explicit_charge_correction, + solvent_component=solv_comp, + ) + + # Validate integrator things + settings_validation.validate_timestep( + self.settings.forcefield_settings.hydrogen_mass, + self.settings.integrator_settings.timestep, + ) + + # Validate simulation & output settings + self._validate_simulation_settings( + self.settings.simulation_settings, + self.settings.integrator_settings, + self.settings.output_settings, + ) + + # Validate alchemical settings + # PR #125 temporarily pin lambda schedule spacing to n_replicas + if self.settings.simulation_settings.n_replicas != self.settings.lambda_settings.lambda_windows: + errmsg = ( + "Number of replicas in ``simulation_settings``: " + f"{self.settings.simulation_settings.n_replicas} must equal " + "the number of lambda windows in lambda_settings: " + f"{self.settings.lambda_settings.lambda_windows}." + ) + raise ValueError(errmsg) + + def _create( + self, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: Optional[Union[gufe.ComponentMapping, list[gufe.ComponentMapping]]], + extends: Optional[gufe.ProtocolDAGResult] = None, + ) -> list[gufe.ProtocolUnit]: + # validate inputs + self.validate(stateA=stateA, stateB=stateB, mapping=mapping, extends=extends) + + # get alchemical components and mapping + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + ligandmapping = mapping[0] if isinstance(mapping, list) else mapping + + # actually create and return Units + Anames = ",".join(c.name for c in alchem_comps["stateA"]) + Bnames = ",".join(c.name for c in alchem_comps["stateB"]) + + # our DAG has no dependencies, so just list units + n_repeats = self.settings.protocol_repeats + + units = [ + RelativeHybridTopologyProtocolUnit( + protocol=self, + stateA=stateA, + stateB=stateB, + ligandmapping=ligandmapping, + generation=0, + repeat_id=int(uuid.uuid4()), + name=f"{Anames} to {Bnames} repeat {i} generation 0", + ) + for i in range(n_repeats) + ] + + return units + + def _gather(self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult]) -> dict[str, Any]: + # result units will have a repeat_id and generations within this repeat_id + # first group according to repeat_id + 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 + + unsorted_repeats[pu.outputs["repeat_id"]].append(pu) + + # then sort by generation within each repeat_id list + repeats: dict[str, list[gufe.ProtocolUnitResult]] = {} + for k, v in unsorted_repeats.items(): + repeats[str(k)] = sorted(v, key=lambda x: x.outputs["generation"]) + + # returns a dict of repeat_id: sorted list of ProtocolUnitResult + return repeats diff --git a/openfe/protocols/openmm_rfe/hybridtop_unit_results.py b/openfe/protocols/openmm_rfe/hybridtop_unit_results.py new file mode 100644 index 000000000..d3a6dc78d --- /dev/null +++ b/openfe/protocols/openmm_rfe/hybridtop_unit_results.py @@ -0,0 +1,240 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +""" +ProtocolUnitResults for Hybrid Topology methods using +OpenMM and OpenMMTools in a Perses-like manner. +""" + +import logging +import pathlib +import warnings +from typing import Optional, Union + +import gufe +import numpy as np +import numpy.typing as npt +from openff.units import Quantity +from openmmtools import multistate + + +logger = logging.getLogger(__name__) + + +class RelativeHybridTopologyProtocolResult(gufe.ProtocolResult): + """Dict-like container for the output of a RelativeHybridTopologyProtocol""" + + def __init__(self, **data): + super().__init__(**data) + # data is mapping of str(repeat_id): list[protocolunitresults] + # TODO: Detect when we have extensions and stitch these together? + if any(len(pur_list) > 2 for pur_list in self.data.values()): + raise NotImplementedError("Can't stitch together results yet") + + @staticmethod + def compute_mean_estimate(dGs: list[Quantity]) -> Quantity: + u = dGs[0].u + # convert all values to units of the first value, then take average of magnitude + # this would avoid a screwy case where each value was in different units + vals = np.asarray([dG.to(u).m for dG in dGs]) + + return np.average(vals) * u + + def get_estimate(self) -> Quantity: + """Average free energy difference of this transformation + + Returns + ------- + dG : openff.units.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 = [pus[0].outputs["unit_estimate"] for pus in self.data.values()] + return self.compute_mean_estimate(dGs) + + @staticmethod + def compute_uncertainty(dGs: list[Quantity]) -> Quantity: + u = dGs[0].u + # convert all values to units of the first value, then take average of magnitude + # this would avoid a screwy case where each value was in different units + vals = np.asarray([dG.to(u).m for dG in dGs]) + + return np.std(vals) * u + + def get_uncertainty(self) -> Quantity: + """The uncertainty/error in the dG value: The std of the estimates of + each independent repeat + """ + + dGs = [pus[0].outputs["unit_estimate"] for pus in self.data.values()] + return self.compute_uncertainty(dGs) + + def get_individual_estimates(self) -> list[tuple[Quantity, Quantity]]: + """Return a list of tuples containing the individual free energy + estimates and associated MBAR errors for each repeat. + + Returns + ------- + dGs : list[tuple[openff.units.Quantity]] + n_replicate simulation list of tuples containing the free energy + estimates (first entry) and associated MBAR estimate errors + (second entry). + """ + dGs = [ + (pus[0].outputs["unit_estimate"], pus[0].outputs["unit_estimate_error"]) + for pus in self.data.values() + ] + return dGs + + def get_forward_and_reverse_energy_analysis( + self, + ) -> list[Optional[dict[str, Union[npt.NDArray, Quantity]]]]: + """ + Get a list of forward and reverse analysis of the free energies + for each repeat using uncorrelated production samples. + + The returned dicts 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. + + If the list entry is ``None`` instead of a dictionary, this indicates + that the analysis could not be carried out for that repeat. This + is most likely caused by MBAR convergence issues when attempting to + calculate free energies from too few samples. + + + Returns + ------- + forward_reverse : list[Optional[dict[str, Union[npt.NDArray, openff.units.Quantity]]]] + + + Raises + ------ + UserWarning + If any of the forward and reverse entries are ``None``. + """ + forward_reverse = [ + pus[0].outputs["forward_and_reverse_energies"] for pus in self.data.values() + ] + + if None in forward_reverse: + wmsg = ( + "One or more ``None`` entries were found in the list of " + "forward and reverse analyses. This is likely caused by " + "an MBAR convergence failure caused by too few independent " + "samples when calculating the free energies of the 10% " + "timeseries slice." + ) + warnings.warn(wmsg) + + return forward_reverse + + def get_overlap_matrices(self) -> list[dict[str, npt.NDArray]]: + """ + Return a list of dictionary containing the MBAR overlap estimates + calculated for each repeat. + + Returns + ------- + overlap_stats : list[dict[str, npt.NDArray]] + A list of dictionaries containing 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 = [pus[0].outputs["unit_mbar_overlap"] for pus in self.data.values()] + + return overlap_stats + + def get_replica_transition_statistics(self) -> list[dict[str, npt.NDArray]]: + """The replica lambda state transition statistics for each repeat. + + Note + ---- + This is currently only available in cases where a replica exchange + simulation was run. + + Returns + ------- + repex_stats : list[dict[str, npt.NDArray]] + A list of dictionaries containing 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. + """ + try: + repex_stats = [ + pus[0].outputs["replica_exchange_statistics"] for pus in self.data.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) -> list[npt.NDArray]: + """ + Returns the timeseries of replica states for each repeat. + + Returns + ------- + replica_states : List[npt.NDArray] + List of replica states for each repeat + """ + + def is_file(filename: str): + p = pathlib.Path(filename) + if not p.exists(): + errmsg = f"File could not be found {p}" + raise ValueError(errmsg) + return p + + replica_states = [] + + for pus in self.data.values(): + nc = is_file(pus[0].outputs["nc"]) + dir_path = nc.parents[0] + chk = is_file(dir_path / pus[0].outputs["last_checkpoint"]).name + reporter = multistate.MultiStateReporter( + storage=nc, checkpoint_storage=chk, open_mode="r" + ) + replica_states.append(np.asarray(reporter.read_replica_thermodynamic_states())) + reporter.close() + + return replica_states + + def equilibration_iterations(self) -> list[float]: + """ + Returns the number of equilibration iterations for each repeat + of the calculation. + + Returns + ------- + equilibration_lengths : list[float] + """ + equilibration_lengths = [ + pus[0].outputs["equilibration_iterations"] for pus in self.data.values() + ] + + return equilibration_lengths + + def production_iterations(self) -> list[float]: + """ + Returns the number of uncorrelated production samples for each + repeat of the calculation. + + Returns + ------- + production_lengths : list[float] + """ + production_lengths = [pus[0].outputs["production_iterations"] for pus in self.data.values()] + + return production_lengths diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py new file mode 100644 index 000000000..5b47bb09c --- /dev/null +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -0,0 +1,697 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +""" +ProtocolUnits for Hybrid Topology methods using OpenMM and OpenMMTools in a +Perses-like manner. + +Acknowledgements +---------------- +These ProtocolUnits are based on, and leverage components originating from +the Perses toolkit (https://github.com/choderalab/perses). +""" + +import json +import logging +import os +import pathlib +import subprocess +from itertools import chain +from typing import Any, Optional + +import gufe +import matplotlib.pyplot as plt +import mdtraj +import numpy as np +import openmmtools +from gufe import ( + ChemicalSystem, + LigandAtomMapping, + SmallMoleculeComponent, + settings, +) +from openff.toolkit.topology import Molecule as OFFMolecule +from openff.units import unit as offunit +from openff.units.openmm import ensure_quantity, from_openmm, to_openmm +from openmmtools import multistate + +from openfe.protocols.openmm_utils.omm_settings import ( + BasePartialChargeSettings, +) + +from ...analysis import plotting +from ...utils import log_system_probe, without_oechem_backend +from ..openmm_utils import ( + charge_generation, + multistate_analysis, + omm_compute, + settings_validation, + system_creation, + system_validation, +) +from . import _rfe_utils +from .equil_rfe_settings import ( + AlchemicalSettings, + IntegratorSettings, + LambdaSettings, + MultiStateOutputSettings, + MultiStateSimulationSettings, + OpenFFPartialChargeSettings, + OpenMMSolvationSettings, + RelativeHybridTopologyProtocolSettings, +) + +logger = logging.getLogger(__name__) + + +class RelativeHybridTopologyProtocolUnit(gufe.ProtocolUnit): + """ + Calculates the relative free energy of an alchemical ligand transformation. + """ + def __init__( + self, + *, + protocol: gufe.Protocol, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + ligandmapping: LigandAtomMapping, + generation: int, + repeat_id: int, + name: Optional[str] = None, + ): + """ + Parameters + ---------- + protocol : RelativeHybridTopologyProtocol + protocol used to create this Unit. Contains key information such + as the settings. + stateA, stateB : ChemicalSystem + the two ligand SmallMoleculeComponents to transform between. The + transformation will go from ligandA to ligandB. + ligandmapping : LigandAtomMapping + the mapping of atoms between the two ligand components + repeat_id : int + identifier for which repeat (aka replica/clone) this Unit is + generation : int + counter for how many times this repeat has been extended + name : str, optional + human-readable identifier for this Unit + + Notes + ----- + The mapping used must not involve any elemental changes. A check for + this is done on class creation. + """ + super().__init__( + name=name, + protocol=protocol, + stateA=stateA, + stateB=stateB, + ligandmapping=ligandmapping, + repeat_id=repeat_id, + generation=generation, + ) + + @staticmethod + def _assign_partial_charges( + charge_settings: OpenFFPartialChargeSettings, + off_small_mols: dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]], + ) -> None: + """ + Assign partial charges to SMCs. + + Parameters + ---------- + charge_settings : OpenFFPartialChargeSettings + Settings for controlling how the partial charges are assigned. + off_small_mols : dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]] + Dictionary of dictionary of OpenFF Molecules to add, keyed by + state and SmallMoleculeComponent. + """ + for smc, mol in chain( + off_small_mols["stateA"], off_small_mols["stateB"], off_small_mols["both"] + ): + charge_generation.assign_offmol_partial_charges( + offmol=mol, + overwrite=False, + method=charge_settings.partial_charge_method, + toolkit_backend=charge_settings.off_toolkit_backend, + generate_n_conformers=charge_settings.number_of_conformers, + nagl_model=charge_settings.nagl_model, + ) + + def run( + self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None + ) -> dict[str, Any]: + """Run the relative 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. + scratch_basepath: Pathlike, optional + Where to store temporary files, defaults to current working directory + shared_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: + self.logger.info("Preparing the hybrid topology simulation") + if scratch_basepath is None: + scratch_basepath = pathlib.Path(".") + if shared_basepath is None: + # use cwd + shared_basepath = pathlib.Path(".") + + # 0. General setup and settings dependency resolution step + + # Extract relevant settings + protocol_settings: RelativeHybridTopologyProtocolSettings = self._inputs[ + "protocol" + ].settings + stateA = self._inputs["stateA"] + stateB = self._inputs["stateB"] + mapping = self._inputs["ligandmapping"] + + forcefield_settings: settings.OpenMMSystemGeneratorFFSettings = ( + protocol_settings.forcefield_settings + ) + thermo_settings: settings.ThermoSettings = protocol_settings.thermo_settings + alchem_settings: AlchemicalSettings = protocol_settings.alchemical_settings + lambda_settings: LambdaSettings = protocol_settings.lambda_settings + charge_settings: BasePartialChargeSettings = protocol_settings.partial_charge_settings + solvation_settings: OpenMMSolvationSettings = protocol_settings.solvation_settings + sampler_settings: MultiStateSimulationSettings = protocol_settings.simulation_settings + output_settings: MultiStateOutputSettings = protocol_settings.output_settings + integrator_settings: IntegratorSettings = protocol_settings.integrator_settings + + # TODO: Also validate various conversions? + # Convert various time based inputs to steps/iterations + steps_per_iteration = settings_validation.convert_steps_per_iteration( + simulation_settings=sampler_settings, + integrator_settings=integrator_settings, + ) + + equil_steps = settings_validation.get_simsteps( + sim_length=sampler_settings.equilibration_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + prod_steps = settings_validation.get_simsteps( + sim_length=sampler_settings.production_length, + timestep=integrator_settings.timestep, + mc_steps=steps_per_iteration, + ) + + solvent_comp, protein_comp, small_mols = system_validation.get_components(stateA) + + # Get the change difference between the end states + # and check if the charge correction used is appropriate + charge_difference = mapping.get_alchemical_charge_difference() + + # 1. Create stateA system + self.logger.info("Parameterizing molecules") + + # a. create offmol dictionaries and assign partial charges + # workaround for conformer generation failures + # see openfe issue #576 + # calculate partial charges manually if not already given + # convert to OpenFF here, + # and keep the molecule around to maintain the partial charges + off_small_mols: dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]] + off_small_mols = { + "stateA": [(mapping.componentA, mapping.componentA.to_openff())], + "stateB": [(mapping.componentB, mapping.componentB.to_openff())], + "both": [ + (m, m.to_openff()) + for m in small_mols + if (m != mapping.componentA and m != mapping.componentB) + ], + } + + self._assign_partial_charges(charge_settings, off_small_mols) + + # b. get a system generator + if output_settings.forcefield_cache is not None: + ffcache = shared_basepath / output_settings.forcefield_cache + else: + ffcache = None + + # Block out oechem backend in system_generator calls to avoid + # any issues with smiles roundtripping between rdkit and oechem + with without_oechem_backend(): + system_generator = system_creation.get_system_generator( + forcefield_settings=forcefield_settings, + integrator_settings=integrator_settings, + thermo_settings=thermo_settings, + cache=ffcache, + has_solvent=solvent_comp is not None, + ) + + # c. force the creation of parameters + # This is necessary because we need to have the FF templates + # registered ahead of solvating the system. + for smc, mol in chain( + off_small_mols["stateA"], off_small_mols["stateB"], off_small_mols["both"] + ): + system_generator.create_system(mol.to_topology().to_openmm(), molecules=[mol]) + + # c. get OpenMM Modeller + a dictionary of resids for each component + stateA_modeller, comp_resids = system_creation.get_omm_modeller( + protein_comp=protein_comp, + solvent_comp=solvent_comp, + small_mols=dict(chain(off_small_mols["stateA"], off_small_mols["both"])), + omm_forcefield=system_generator.forcefield, + solvent_settings=solvation_settings, + ) + + # d. get topology & positions + # Note: roundtrip positions to remove vec3 issues + stateA_topology = stateA_modeller.getTopology() + stateA_positions = to_openmm(from_openmm(stateA_modeller.getPositions())) + + # e. create the stateA System + # Block out oechem backend in system_generator calls to avoid + # any issues with smiles roundtripping between rdkit and oechem + with without_oechem_backend(): + stateA_system = system_generator.create_system( + stateA_modeller.topology, + molecules=[m for _, m in chain(off_small_mols["stateA"], off_small_mols["both"])], + ) + + # 2. Get stateB system + # a. get the topology + stateB_topology, stateB_alchem_resids = _rfe_utils.topologyhelpers.combined_topology( + stateA_topology, + # zeroth item (there's only one) then get the OFF representation + off_small_mols["stateB"][0][1].to_topology().to_openmm(), + exclude_resids=comp_resids[mapping.componentA], + ) + + # b. get a list of small molecules for stateB + # Block out oechem backend in system_generator calls to avoid + # any issues with smiles roundtripping between rdkit and oechem + with without_oechem_backend(): + stateB_system = system_generator.create_system( + stateB_topology, + molecules=[m for _, m in chain(off_small_mols["stateB"], off_small_mols["both"])], + ) + + # c. Define correspondence mappings between the two systems + ligand_mappings = _rfe_utils.topologyhelpers.get_system_mappings( + mapping.componentA_to_componentB, + stateA_system, + stateA_topology, + comp_resids[mapping.componentA], + stateB_system, + stateB_topology, + stateB_alchem_resids, + # These are non-optional settings for this method + fix_constraints=True, + ) + + # d. if a charge correction is necessary, select alchemical waters + # and transform them + if alchem_settings.explicit_charge_correction: + alchem_water_resids = _rfe_utils.topologyhelpers.get_alchemical_waters( + stateA_topology, + stateA_positions, + charge_difference, + alchem_settings.explicit_charge_correction_cutoff, + ) + _rfe_utils.topologyhelpers.handle_alchemical_waters( + alchem_water_resids, + stateB_topology, + stateB_system, + ligand_mappings, + charge_difference, + solvent_comp, + ) + + # e. Finally get the positions + stateB_positions = _rfe_utils.topologyhelpers.set_and_check_new_positions( + ligand_mappings, + stateA_topology, + stateB_topology, + old_positions=ensure_quantity(stateA_positions, "openmm"), + insert_positions=ensure_quantity( + off_small_mols["stateB"][0][1].conformers[0], "openmm" + ), + ) + + # 3. Create the hybrid topology + # a. Get softcore potential settings + if alchem_settings.softcore_LJ.lower() == "gapsys": + softcore_LJ_v2 = True + elif alchem_settings.softcore_LJ.lower() == "beutler": + softcore_LJ_v2 = False + # b. Get hybrid topology factory + hybrid_factory = _rfe_utils.relative.HybridTopologyFactory( + stateA_system, + stateA_positions, + stateA_topology, + stateB_system, + stateB_positions, + stateB_topology, + old_to_new_atom_map=ligand_mappings["old_to_new_atom_map"], + old_to_new_core_atom_map=ligand_mappings["old_to_new_core_atom_map"], + use_dispersion_correction=alchem_settings.use_dispersion_correction, + softcore_alpha=alchem_settings.softcore_alpha, + softcore_LJ_v2=softcore_LJ_v2, + softcore_LJ_v2_alpha=alchem_settings.softcore_alpha, + interpolate_old_and_new_14s=alchem_settings.turn_off_core_unique_exceptions, + ) + + # 4. Create lambda schedule + # TODO - this should be exposed to users, maybe we should offer the + # ability to print the schedule directly in settings? + # fmt: off + lambdas = _rfe_utils.lambdaprotocol.LambdaProtocol( + functions=lambda_settings.lambda_functions, + windows=lambda_settings.lambda_windows + ) + # fmt: on + # PR #125 temporarily pin lambda schedule spacing to n_replicas + n_replicas = sampler_settings.n_replicas + if n_replicas != len(lambdas.lambda_schedule): + errmsg = ( + f"Number of replicas {n_replicas} " + f"does not equal the number of lambda windows " + f"{len(lambdas.lambda_schedule)}" + ) + raise ValueError(errmsg) + + # 9. Create the multistate reporter + # Get the sub selection of the system to print coords for + selection_indices = hybrid_factory.hybrid_topology.select(output_settings.output_indices) + + # a. Create the multistate reporter + # convert checkpoint_interval from time to iterations + chk_intervals = settings_validation.convert_checkpoint_interval_to_iterations( + checkpoint_interval=output_settings.checkpoint_interval, + time_per_iteration=sampler_settings.time_per_iteration, + ) + + nc = shared_basepath / output_settings.output_filename + chk = output_settings.checkpoint_storage_filename + + if output_settings.positions_write_frequency is not None: + pos_interval = settings_validation.divmod_time_and_check( + numerator=output_settings.positions_write_frequency, + denominator=sampler_settings.time_per_iteration, + numerator_name="output settings' position_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + else: + pos_interval = 0 + + if output_settings.velocities_write_frequency is not None: + vel_interval = settings_validation.divmod_time_and_check( + numerator=output_settings.velocities_write_frequency, + denominator=sampler_settings.time_per_iteration, + numerator_name="output settings' velocity_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + else: + vel_interval = 0 + + reporter = multistate.MultiStateReporter( + storage=nc, + analysis_particle_indices=selection_indices, + checkpoint_interval=chk_intervals, + checkpoint_storage=chk, + position_interval=pos_interval, + velocity_interval=vel_interval, + ) + + # b. Write out a PDB containing the subsampled hybrid state + # fmt: off + bfactors = np.zeros_like(selection_indices, dtype=float) # solvent + bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['unique_old_atoms']))] = 0.25 # lig A + bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['core_atoms']))] = 0.50 # core + bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['unique_new_atoms']))] = 0.75 # lig B + # bfactors[np.in1d(selection_indices, protein)] = 1.0 # prot+cofactor + if len(selection_indices) > 0: + traj = mdtraj.Trajectory( + hybrid_factory.hybrid_positions[selection_indices, :], + hybrid_factory.hybrid_topology.subset(selection_indices), + ).save_pdb( + shared_basepath / output_settings.output_structure, + bfactors=bfactors, + ) + # fmt: on + + # 10. Get compute platform + # restrict to a single CPU if running vacuum + restrict_cpu = forcefield_settings.nonbonded_method.lower() == "nocutoff" + platform = omm_compute.get_openmm_platform( + platform_name=protocol_settings.engine_settings.compute_platform, + gpu_device_index=protocol_settings.engine_settings.gpu_device_index, + restrict_cpu_count=restrict_cpu, + ) + + # 11. Set the integrator + # a. Validate integrator settings for current system + # Virtual sites sanity check - ensure we restart velocities when + # there are virtual sites in the system + if hybrid_factory.has_virtual_sites: + if not integrator_settings.reassign_velocities: + errmsg = ( + "Simulations with virtual sites without velocity " + "reassignments are unstable in openmmtools" + ) + raise ValueError(errmsg) + + # b. create langevin integrator + integrator = openmmtools.mcmc.LangevinDynamicsMove( + timestep=to_openmm(integrator_settings.timestep), + collision_rate=to_openmm(integrator_settings.langevin_collision_rate), + n_steps=steps_per_iteration, + reassign_velocities=integrator_settings.reassign_velocities, + n_restart_attempts=integrator_settings.n_restart_attempts, + constraint_tolerance=integrator_settings.constraint_tolerance, + ) + + # 12. Create sampler + self.logger.info("Creating and setting up the sampler") + rta_its, rta_min_its = settings_validation.convert_real_time_analysis_iterations( + simulation_settings=sampler_settings, + ) + # convert early_termination_target_error from kcal/mol to kT + early_termination_target_error = ( + settings_validation.convert_target_error_from_kcal_per_mole_to_kT( + thermo_settings.temperature, + sampler_settings.early_termination_target_error, + ) + ) + + if sampler_settings.sampler_method.lower() == "repex": + sampler = _rfe_utils.multistate.HybridRepexSampler( + mcmc_moves=integrator, + hybrid_system=hybrid_factory.hybrid_system, + hybrid_positions=hybrid_factory.hybrid_positions, + online_analysis_interval=rta_its, + online_analysis_target_error=early_termination_target_error, + online_analysis_minimum_iterations=rta_min_its, + ) + elif sampler_settings.sampler_method.lower() == "sams": + sampler = _rfe_utils.multistate.HybridSAMSSampler( + mcmc_moves=integrator, + hybrid_system=hybrid_factory.hybrid_system, + hybrid_positions=hybrid_factory.hybrid_positions, + online_analysis_interval=rta_its, + online_analysis_minimum_iterations=rta_min_its, + flatness_criteria=sampler_settings.sams_flatness_criteria, + gamma0=sampler_settings.sams_gamma0, + ) + elif sampler_settings.sampler_method.lower() == "independent": + sampler = _rfe_utils.multistate.HybridMultiStateSampler( + mcmc_moves=integrator, + hybrid_system=hybrid_factory.hybrid_system, + hybrid_positions=hybrid_factory.hybrid_positions, + online_analysis_interval=rta_its, + online_analysis_target_error=early_termination_target_error, + online_analysis_minimum_iterations=rta_min_its, + ) + else: + raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") + + sampler.setup( + n_replicas=sampler_settings.n_replicas, + reporter=reporter, + lambda_protocol=lambdas, + temperature=to_openmm(thermo_settings.temperature), + endstates=alchem_settings.endstate_dispersion_correction, + minimization_platform=platform.getName(), + # Set minimization steps to None when running in dry mode + # otherwise do a very small one to avoid NaNs + minimization_steps=100 if not dry else None, + ) + + try: + # Create context caches (energy + sampler) + 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, + ) + + sampler.energy_context_cache = energy_context_cache + sampler.sampler_context_cache = sampler_context_cache + + if not dry: # pragma: no-cover + # minimize + if verbose: + self.logger.info("Running minimization") + + sampler.minimize(max_iterations=sampler_settings.minimization_steps) + + # equilibrate + if verbose: + self.logger.info("Running equilibration phase") + + sampler.equilibrate(int(equil_steps / steps_per_iteration)) + + # production + if verbose: + self.logger.info("Running production phase") + + sampler.extend(int(prod_steps / steps_per_iteration)) + + self.logger.info("Production phase complete") + + self.logger.info("Post-simulation analysis of results") + # calculate relevant analyses of the free energies & sampling + # First close & reload the reporter to avoid netcdf clashes + analyzer = multistate_analysis.MultistateEquilFEAnalysis( + reporter, + sampling_method=sampler_settings.sampler_method.lower(), + result_units=offunit.kilocalorie_per_mole, + ) + analyzer.plot(filepath=shared_basepath, filename_prefix="") + analyzer.close() + + else: + # clean up the reporter file + fns = [ + shared_basepath / output_settings.output_filename, + shared_basepath / output_settings.checkpoint_storage_filename, + ] + for fn in fns: + os.remove(fn) + finally: + # close reporter when you're done, prevent + # file handle clashes + reporter.close() + + # clear GPU contexts + # TODO: use cache.empty() calls when openmmtools #690 is resolved + # replace with above + for context in list(energy_context_cache._lru._data.keys()): + del energy_context_cache._lru._data[context] + for context in list(sampler_context_cache._lru._data.keys()): + del sampler_context_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_context_cache, energy_context_cache + + if not dry: + del integrator, sampler + + if not dry: # pragma: no-cover + return {"nc": nc, "last_checkpoint": chk, **analyzer.unit_results_dict} + else: + return {"debug": + { + "sampler": sampler, + "hybrid_factory": hybrid_factory + } + } + + @staticmethod + def structural_analysis(scratch, shared) -> dict: + # don't put energy analysis in here, it uses the open file reporter + # whereas structural stuff requires that the file handle is closed + # TODO: we should just make openfe_analysis write an npz instead! + analysis_out = scratch / "structural_analysis.json" + + ret = subprocess.run( + [ + "openfe_analysis", # CLI entry point + "RFE_analysis", # CLI option + str(shared), # Where the simulation.nc fille + str(analysis_out), # Where the analysis json file is written + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if ret.returncode: + return {"structural_analysis_error": ret.stderr} + + with open(analysis_out, "rb") as f: + data = json.load(f) + + savedir = pathlib.Path(shared) + if d := data["protein_2D_RMSD"]: + fig = plotting.plot_2D_rmsd(d) + fig.savefig(savedir / "protein_2D_RMSD.png") + plt.close(fig) + f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) + f2.savefig(savedir / "ligand_COM_drift.png") + plt.close(f2) + + f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) + f3.savefig(savedir / "ligand_RMSD.png") + plt.close(f3) + + # Save to numpy compressed format (~ 6x more space efficient than JSON) + np.savez_compressed( + shared / "structural_analysis.npz", + protein_RMSD=np.asarray(data["protein_RMSD"], dtype=np.float32), + ligand_RMSD=np.asarray(data["ligand_RMSD"], dtype=np.float32), + ligand_COM_drift=np.asarray(data["ligand_wander"], dtype=np.float32), + protein_2D_RMSD=np.asarray(data["protein_2D_RMSD"], dtype=np.float32), + time_ps=np.asarray(data["time(ps)"], dtype=np.float32), + ) + + return {"structural_analysis": shared / "structural_analysis.npz"} + + def _execute( + self, + ctx: gufe.Context, + **kwargs, + ) -> dict[str, Any]: + log_system_probe(logging.INFO, paths=[ctx.scratch]) + + outputs = self.run(scratch_basepath=ctx.scratch, shared_basepath=ctx.shared) + + structural_analysis_outputs = self.structural_analysis(ctx.scratch, ctx.shared) + + return { + "repeat_id": self._inputs["repeat_id"], + "generation": self._inputs["generation"], + **outputs, + **structural_analysis_outputs, + } From 792996e9fd5492537f06427a910d818047c3ede4 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 26 Dec 2025 00:30:39 -0500 Subject: [PATCH 17/91] Add news item --- news/validate-rfe.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 news/validate-rfe.rst diff --git a/news/validate-rfe.rst b/news/validate-rfe.rst new file mode 100644 index 000000000..d7036e8d4 --- /dev/null +++ b/news/validate-rfe.rst @@ -0,0 +1,26 @@ +**Added:** + +* The `validate` method for the RelativeHybridTopologyProtocol has been + implemented. This means that settings and system validation can mostly + be done prior to Protocol execution by calling + `RelativeHybridTopologyProtocol.validate(stateA, stateB, mapping)`. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 7d179981e86b8d579951f10314ca14107df94f96 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 26 Dec 2025 20:25:30 -0500 Subject: [PATCH 18/91] fix redefine --- openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 0f7db50d6..349c81d06 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -521,7 +521,7 @@ def test_pos_vel_write_frequency_not_divisible( "attribute", ["real_time_analysis_interval", "real_time_analysis_interval"] ) -def test_pos_vel_write_frequency_not_divisible( +def test_real_time_analysis_not_divisible( benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, From 43eb947872896f350c694d714ced77789b495b0b Mon Sep 17 00:00:00 2001 From: IAlibay Date: Fri, 26 Dec 2025 23:27:58 -0500 Subject: [PATCH 19/91] start modularising everything --- .../protocols/openmm_rfe/hybridtop_units.py | 307 +++++++++++++++--- 1 file changed, 253 insertions(+), 54 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 5b47bb09c..96a8a2389 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -111,25 +111,132 @@ def __init__( generation=generation, ) + def _prepare( + self, + verbose: bool, + scratch_basepath: pathlib.Path | None, + shared_basepath: pathlib.Path | None, + ): + """ + Set basepaths and do some initial logging. + + Parameters + ---------- + verbose : bool + Verbose output of the simulation progress. Output is provided at the + INFO level logging. + scratch_basepath : pathlib.Path | None + Optional scratch base path to write scratch files to. + shared_basepath : pathlib.Path | None + Optional shared base path to write shared files to. + """ + self.verbose = verbose + + if self.verbose: + self.logger.info("Setting up the hybrid topology simulation") + + # 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) + + @staticmethod + def _get_settings( + settings: RelativeHybridTopologyProtocolSettings + ) -> dict[str, SettingsBaseModel]: + """ + Get a dictionary of Protocol settings. + + Returns + ------- + protocol_settings : dict[str, SettingsBaseModel] + + Notes + ----- + We return a dict so that we can duck type behaviour between phases. + For example subclasses may contain both `solvent` and `complex` + settings, using this approach we can extract the relevant entry + to the same key and pass it to other methods in a seamless manner. + """ + protocol_settings: dict[str, SettingsBaseModel] = {} + protocol_settings["forcefield_settings"] = settings.forcefield_settings + protocol_settings["thermo_settings"] = settings.thermo_settings + protocol_settings["alchemical_settings"] = settings.alchemical_settings + protocol_settings["lambda_settings"] = settings.lambda_settings + protocol_settings["charge_settings"] = settings.partial_charge_settings + protocol_settings["solvation_settings"] = settings.solvation_settings + protocol_settings["simulation_settings"] = settings.simulation_settings + protocol_settings["output_settings"] = settings.output_settings + protocol_settings["integrator_settings"] = settings.integrator_settings + protocol_settings["engine_settings"] = settings.engine_settings + return protocol_settings + + @staticmethod + def _get_components( + stateA: ChemicalSystem, + stateB: ChemicalSystem + ) -> tuple[ + dict[str, Component], + SolventComponent, + ProteinComponent, + dict[SmallMoleculeComponent, OFFMolecule] + ]: + """ + Get the components from the ChemicalSystem inputs. + + Parameters + ---------- + stateA : ChemicalSystem + ChemicalSystem defining the state A components. + stateB : CHemicalSystem + ChemicalSystem defining the state B components. + + Returns + ------- + alchem_comps : dict[str, Component] + Dictionary of alchemical components. + solv_comp : SolventComponent + The solvent component. + protein_comp : ProteinComponent + The protein component. + small_mols : dict[SmallMoleculeComponent, openff.toolkit.Molecule] + Dictionary of small molecule components paired + with their OpenFF Molecule. + """ + alchem_comps = system_validation.get_alchemical_components(stateA, stateB) + + solvent_comp, protein_comp, smcs_A = system_validation.get_components(stateA) + _, _, smcs_B = system_validation.get_components(stateB) + + small_mols = { + m: m.to_openff() + for m in set(smcs_A).union(set(smcs_B)) + } + + return alchem_comps, solvent_comp, protein_comp, small_mols + @staticmethod def _assign_partial_charges( charge_settings: OpenFFPartialChargeSettings, - off_small_mols: dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]], + small_mols: dict[SmallMoleculeComponent, OFFMolecule], ) -> None: """ - Assign partial charges to SMCs. + Assign partial charges to the OpenFF Molecules associated with all + the SmallMoleculeComponents in the transformation. Parameters ---------- charge_settings : OpenFFPartialChargeSettings Settings for controlling how the partial charges are assigned. - off_small_mols : dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]] - Dictionary of dictionary of OpenFF Molecules to add, keyed by - state and SmallMoleculeComponent. + small_mols : dict[SmallMoleculeComponent, openff.toolkit.Molecule] + Dictionary of OpenFF Molecules to add, keyed by + their associated SmallMoleculeComponent. """ - for smc, mol in chain( - off_small_mols["stateA"], off_small_mols["stateB"], off_small_mols["both"] - ): + for smc, mol in small_mols.items(): charge_generation.assign_offmol_partial_charges( offmol=mol, overwrite=False, @@ -139,6 +246,129 @@ def _assign_partial_charges( nagl_model=charge_settings.nagl_model, ) + @staticmethod + def _get_system_generator( + shared_basepath: pathlib.Path, + settings: dict[str, SettingsBaseModel], + solvent_comp: SolventComponent | None, + openff_molecules: list[OFFMolecule] | None, + ) -> SystemGenerator: + """ + Get an OpenMM SystemGenerator. + + Parameters + ---------- + settings : dict[str, SettingsBaseModel] + A dictionary of protocol settings. + solvent_comp : SolventComponent | None + The solvent component of the system, if any. + openff_molecules : list[openff.Toolkit] | None + A list of openff molecules to generate templates for, if any. + + Returns + ------- + system_generator : openmmtools.SystemGenerator + The SystemGenerator for the protocol. + """ + ffcache = settings["output_settings"].forcefield_cachea + + if ffcache is not None: + ffcache = shared_basepath / ffcache + + # Block out oechem backend in system_generator calls to avoid + # any issues with smiles roundtripping between rdkit and oechem + with without_oechem_backend(): + system_generator = system_creation.get_system_generator( + forcefield_settings=settings["forcefield_settings"], + integrator_settings=settings["integrator_settings"], + thermo_settings=settings["thermo_settings"], + cache=ffcache, + has_solvent=solvent_comp is not None, + ) + + # Handle openff Molecule templates + # TODO: revisit this once the SystemGenerator update happens + # and we start loading the whole protein into OpenFF Topologies + + # First deduplicate isomoprhic molecules + unique_offmols = [] + for mol in openff_molecules: + unique = all( + [ + not mol.is_isomorphic_with(umol) + for umol in unique_offmols + ] + ) + if unique: + unique_offmols.append(mol) + + # register all the templates + system_generator.add_molecules(unique_offmols) + + return system_generator + + def _get_omm_objects( + self, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + mapping: LigandAtomMapping, + settings: dict[str, SettingsBaseModel], + protein_component: ProteinComponent | None, + solvent_component: SolventComponent | None, + small_mols: dict[SmallMoleculeComponent, OFFMolecule] + ): + """ + Get OpenMM objects for both end states A and B. + + Parameters + ---------- + stateA : ChemicalSystem + ChemicalSystem defining end state A. + stateB : ChmiecalSysstem + ChemicalSystem defining end state B. + mapping : LigandAtomMapping + The mapping for alchemical components between state A and B. + settings : dict[str, SettingsBaseModel] + Settings for the transformation. + protein_component : ProteinComponent | None + The common ProteinComponent between the end states, if there is is one. + solvent_component : SolventComponent | None + The common SolventComponent between the end states, if there is one. + small_mols : dict[SmallMoleculeCOmponent, openff.toolkit.Molecule] + The small molecules for both end states. + + Returns + ------- + stateA_system : openmm.System + OpenMM System for state A. + stateA_topology : openmm.app.Topology + OpenMM Topology for the state A System. + stateA_positions : openmm.unit.Quantity + Positions of partials for state A System. + stateB_system : openmm.System + OpenMM System for state B. + stateB_topology : openmm.app.Topology + OpenMM Topology for the state B System. + stateB_positions : openmm.unit.Quantity + Positions of partials for state B System. + system_mapping : dict[str, dict[int, int]] + Dictionary of mappings defining the correspondance between + the two state Systems. + """ + if self.verbose: + self.logger.info("Parameterizing systems") + + # Get the system generator with all the templates registered + system_generator = self._get_system_generator( + shared_basepath=self.shared_basepath, + settings=settings, + solv_comp=solvent_component, + openff_molecules=list(small_mols.values()) + ) + + .... + + def run( self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None ) -> dict[str, Any]: @@ -169,36 +399,26 @@ def run( error Exception if anything failed """ - if verbose: - self.logger.info("Preparing the hybrid topology simulation") - if scratch_basepath is None: - scratch_basepath = pathlib.Path(".") - if shared_basepath is None: - # use cwd - shared_basepath = pathlib.Path(".") - - # 0. General setup and settings dependency resolution step - - # Extract relevant settings - protocol_settings: RelativeHybridTopologyProtocolSettings = self._inputs[ - "protocol" - ].settings + # Prepare paths & verbosity + self._prepare(verbose, scratch_basepath, shared_basepath) + + # Get settings + settings = self._get_settings(self._inputs["protocol"].settings) + + # Get components stateA = self._inputs["stateA"] stateB = self._inputs["stateB"] mapping = self._inputs["ligandmapping"] - - forcefield_settings: settings.OpenMMSystemGeneratorFFSettings = ( - protocol_settings.forcefield_settings + alchem_comps, solvent_comp, protein_comp, small_mols = self._get_components( + stateA, stateB ) - thermo_settings: settings.ThermoSettings = protocol_settings.thermo_settings - alchem_settings: AlchemicalSettings = protocol_settings.alchemical_settings - lambda_settings: LambdaSettings = protocol_settings.lambda_settings - charge_settings: BasePartialChargeSettings = protocol_settings.partial_charge_settings - solvation_settings: OpenMMSolvationSettings = protocol_settings.solvation_settings - sampler_settings: MultiStateSimulationSettings = protocol_settings.simulation_settings - output_settings: MultiStateOutputSettings = protocol_settings.output_settings - integrator_settings: IntegratorSettings = protocol_settings.integrator_settings + # Assign partial charges now to avoid any discrepancies later + self._assign_partial_charges(charge_settings, small_mols) + + + + # TODO: move these down, not needed until we get to the sampler # TODO: Also validate various conversions? # Convert various time based inputs to steps/iterations steps_per_iteration = settings_validation.convert_steps_per_iteration( @@ -217,8 +437,6 @@ def run( mc_steps=steps_per_iteration, ) - solvent_comp, protein_comp, small_mols = system_validation.get_components(stateA) - # Get the change difference between the end states # and check if the charge correction used is appropriate charge_difference = mapping.get_alchemical_charge_difference() @@ -226,25 +444,6 @@ def run( # 1. Create stateA system self.logger.info("Parameterizing molecules") - # a. create offmol dictionaries and assign partial charges - # workaround for conformer generation failures - # see openfe issue #576 - # calculate partial charges manually if not already given - # convert to OpenFF here, - # and keep the molecule around to maintain the partial charges - off_small_mols: dict[str, list[tuple[SmallMoleculeComponent, OFFMolecule]]] - off_small_mols = { - "stateA": [(mapping.componentA, mapping.componentA.to_openff())], - "stateB": [(mapping.componentB, mapping.componentB.to_openff())], - "both": [ - (m, m.to_openff()) - for m in small_mols - if (m != mapping.componentA and m != mapping.componentB) - ], - } - - self._assign_partial_charges(charge_settings, off_small_mols) - # b. get a system generator if output_settings.forcefield_cache is not None: ffcache = shared_basepath / output_settings.forcefield_cache From d1bd736414491f41f45ffb1341fca2d0dd36c86f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 27 Dec 2025 17:23:43 -0500 Subject: [PATCH 20/91] Add charge validation for smcs when dealing with ismorphic molecules --- .../protocols/openmm_rfe/equil_rfe_methods.py | 55 ++++++++++++++++ .../openmm_utils/system_validation.py | 18 +++--- .../openmm_rfe/test_hybrid_top_validation.py | 63 +++++++++++++++++++ 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 189fffe79..6995f82dd 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -560,6 +560,58 @@ def _validate_mapping( logger.warning(wmsg) warnings.warn(wmsg) + @staticmethod + def _validate_smcs( + stateA: ChemicalSystem, + stateB: ChemicalSystem, + ) -> None: + """ + Validates the SmallMoleculeComponents. + + Parameters + ---------- + stateA : ChemicalSystem + The chemical system of end state A. + stateB : ChemicalSystem + The chemical system of end state B. + + Raises + ------ + ValueError + * If there are isomorphic SmallMoleculeComponents with + different charges. + """ + smcs_A = stateA.get_components_of_type(SmallMoleculeComponent) + smcs_B = stateB.get_components_of_type(SmallMoleculeComponent) + smcs_all = list(set(smcs_A).union(set(smcs_B))) + offmols = [m.to_openff() for m in smcs_all] + + def _equal_charges(moli, molj): + # Base case, both molecules don't have charges + if (moli.partial_charges is None) & (molj.partial_charges is None): + return True + # If either is None but not the other + if (moli.partial_charges is None) ^ (molj.partial_charges is None): + return False + # Check if the charges are close to each other + return np.allclose(moli.partial_charges, molj.partial_charges) + + clashes = [] + + for i, moli in enumerate(offmols): + for molj in offmols: + if moli.is_isomorphic_with(molj): + if not _equal_charges(moli, molj): + clashes.append(smcs_all[i]) + + if len(clashes) > 0: + errmsg = ( + "Found SmallMoleculeComponents are are isomorphic " + "but with different charges, this is not currently allowed. " + f"Affected components: {clashes}" + ) + raise ValueError(errmsg) + @staticmethod def _validate_charge_difference( mapping: LigandAtomMapping, @@ -726,6 +778,9 @@ def _validate( alchem_comps = system_validation.get_alchemical_components(stateA, stateB) self._validate_mapping(mapping, alchem_comps) + # Validate the small molecule components + self._validate_smcs(stateA, stateB) + # Validate solvent component nonbond = self.settings.forcefield_settings.nonbonded_method system_validation.validate_solvent(stateA, nonbond) diff --git a/openfe/protocols/openmm_utils/system_validation.py b/openfe/protocols/openmm_utils/system_validation.py index 9d67e108f..750f5f565 100644 --- a/openfe/protocols/openmm_utils/system_validation.py +++ b/openfe/protocols/openmm_utils/system_validation.py @@ -162,24 +162,24 @@ def get_components(state: ChemicalSystem) -> ParseCompRet: small_mols : list[SmallMoleculeComponent] """ - def _get_single_comps(comp_list, comptype): - ret_comps = [comp for comp in comp_list if isinstance(comp, comptype)] - if ret_comps: + def _get_single_comps(state, comptype): + comps = state.get_components_of_type(comptype) + + if len(ret_comps) > 0: return ret_comps[0] else: return None solvent_comp: Optional[SolventComponent] = _get_single_comps( - list(state.values()), SolventComponent + state, + SolventComponent ) protein_comp: Optional[ProteinComponent] = _get_single_comps( - list(state.values()), ProteinComponent + state, + ProteinComponent ) - small_mols = [] - for comp in state.components.values(): - if isinstance(comp, SmallMoleculeComponent): - small_mols.append(comp) + small_mols = state.get_components_of_type(SmallMoleculeComponent) return solvent_comp, protein_comp, small_mols diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 349c81d06..57092ce3c 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -126,6 +126,69 @@ def test_vaccuum_PME_error( ) +@pytest.mark.parametrize('charge', [None, 'gasteiger']) +def test_smcs_same_charge_passes( + charge, + benzene_modifications +): + benzene = benzene_modifications['benzene'] + if charge is None: + smc = benzene + else: + offmol = benzene.to_openff() + offmol.assign_partial_charges(partial_charge_method='gasteiger') + smc = openfe.SmallMoleculeComponent.from_openff(offmol) + + # Just pass the same thing twice + state = openfe.ChemicalSystem({'l': smc}) + openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs(state, state) + + +def test_smcs_different_charges_none_not_none( + benzene_modifications +): + # smcA has no charges + smcA = benzene_modifications['benzene'] + + # smcB has charges + offmol = smcA.to_openff() + offmol.assign_partial_charges(partial_charge_method='gasteiger') + smcB = openfe.SmallMoleculeComponent.from_openff(offmol) + + stateA = openfe.ChemicalSystem({'l': smcA}) + stateB = openfe.ChemicalSystem({'l': smcB}) + + errmsg = "isomorphic but with different charges" + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs( + stateA, stateB + ) + + +def test_smcs_different_charges_all( + benzene_modifications +): + # For this test, we will assign both A and B to both states + # It wouldn't happen in real life, but it tests that within a state + # you can pick up isomorphic molecules with different charges + # create an offmol with gasteiger charges + offmol = benzene_modifications['benzene'].to_openff() + offmol.assign_partial_charges(partial_charge_method='gasteiger') + smcA = openfe.SmallMoleculeComponent.from_openff(offmol) + + # now alter the offmol charges, scaling by 0.1 + offmol.partial_charges *= 0.1 + smcB = openfe.SmallMoleculeComponent.from_openff(offmol) + + state = openfe.ChemicalSystem({'l1': smcA, 'l2': smcB}) + + errmsg = "isomorphic but with different charges" + with pytest.raises(ValueError, match=errmsg): + openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs( + state, state + ) + + def test_solvent_nocutoff_error( benzene_system, toluene_system, From 51a6de1da7d9778c92b3aab4d57906c8e314fd3b Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 28 Dec 2025 22:30:32 -0500 Subject: [PATCH 21/91] break down the rfe units into bits --- .../protocols/openmm_rfe/hybridtop_units.py | 1051 ++++++++++++----- 1 file changed, 733 insertions(+), 318 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 96a8a2389..da090e88e 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -18,19 +18,30 @@ from itertools import chain from typing import Any, Optional -import gufe import matplotlib.pyplot as plt import mdtraj import numpy as np +import numpy.typing as npt +import openmm import openmmtools +from openmmforcefields.generators import SystemGenerator + +import gufe +from gufe.settings import ( + SettingsBaseModel, + ThermoSettings, +) from gufe import ( ChemicalSystem, LigandAtomMapping, + Component, + SolventComponent, + ProteinComponent, SmallMoleculeComponent, - settings, ) from openff.toolkit.topology import Molecule as OFFMolecule from openff.units import unit as offunit +from openff.units import Quantity from openff.units.openmm import ensure_quantity, from_openmm, to_openmm from openmmtools import multistate @@ -49,6 +60,7 @@ system_validation, ) from . import _rfe_utils +from ._rfe_utils.relative import HybridTopologyFactory from .equil_rfe_settings import ( AlchemicalSettings, IntegratorSettings, @@ -57,6 +69,7 @@ MultiStateSimulationSettings, OpenFFPartialChargeSettings, OpenMMSolvationSettings, + OpenMMEngineSettings, RelativeHybridTopologyProtocolSettings, ) @@ -270,7 +283,7 @@ def _get_system_generator( system_generator : openmmtools.SystemGenerator The SystemGenerator for the protocol. """ - ffcache = settings["output_settings"].forcefield_cachea + ffcache = settings["output_settings"].forcefield_cache if ffcache is not None: ffcache = shared_basepath / ffcache @@ -307,6 +320,163 @@ def _get_system_generator( return system_generator + @staticmethod + def _create_stateA_system( + small_mols: dict[SmallMoleculeComponent, OFFMolecule], + protein_component: ProteinComponent | None, + solvent_component: SolventComponent | None, + system_generator: SystemGenerator, + solvation_settings: OpenMMSolvationSettings, + ) -> tuple[ + openmm.System, + openmm.app.Topology, + openmm.unit.Quantity, + dict[Component, npt.NDArray] + ]: + """ + Create an OpenMM System for state A. + + Parameters + ---------- + small_mols : dict[SmallMoleculeComponent, openff.toolkit.Molecule] + A list of small molecules to include in the System. + protein_component : ProteinComponent | None + Optionally, the protein component to include in the System. + solvent_component : SolventComponent | None + Optionally, the solvent component to include in the System. + system_generator : SystemGenerator + The SystemGenerator object ot use to construct the System. + solvation_settings : OpenMMSolvationSettings + Settings defining how to build the System. + + Returns + ------- + system : openmm.System + The System that defines state A. + topology : openmm.app.Topology + The Topology defining the returned System. + positions : openmm.unit.Quantity + The positions of the particles in the System. + comp_residues : dict[Component, npt.NDArray] + A dictionary defining which residues in the System + belong to which ChemicalSystem Component. + """ + modeller, comp_resids = system_creation.get_omm_modeller( + protein_comp=protein_component, + solvent_comp=solvent_component, + small_mols=small_mols, + omm_forcefield=system_generator.forcefield, + solvent_settings=solvation_settings, + ) + + topology = modeller.getTopology() + # Note: roundtrip positions to remove vec3 issues + positions = to_openmm(from_openmm(modeller.getPositions())) + + with without_oechem_backend(): + system = system_generator.create_system( + modeller.topology, + molecules=list(small_mols.values()), + ) + + return system, topology, positions, comp_resids + + @staticmethod + def _create_stateB_system( + small_mols: dict[SmallMoleculeComponent, OFFMolecule], + mapping: LigandAtomMapping, + stateA_topology: openmm.app.Topology, + exclude_resids: npt.NDArray, + system_generator: SystemGenerator, + ) -> tuple[openmm.System, openmm.app.Topology, npt.NDArray]: + """ + Create the state B System from the state A Topology. + + Parameters + ---------- + small_mols : dict[SmallMoleculeComponent, openff.toolkit.Molecule] + Dictionary of OpenFF Molecules keyed by SmallMoleculeComponent + to be present in system B. + mapping : LigandAtomMapping + LigandAtomMapping defining the correspondance betwee state A + and B's alchemical ligand. + stateA_topology : openmm.app.Topology + The OpenMM topology for state A. + exclude_resids : npt.NDArray + A list of residues to exclude from state A when building state B. + system_generator : SystemGenerator + The SystemGenerator to use to build System B. + + Returns + ------- + system : openmm.System + The state B System. + topology : openmm.app.Topology + The OpenMM Topology associated with the state B System. + alchem_resids : npt.NDArray + The residue indices of the state B alchemical species. + """ + topology, alchem_resids = _rfe_utils.topologyhelpers.combined_topology( + topology1=stateA_topology, + topology2=small_mols[mapping.componentB].to_topology().to_openmm(), + exclude_resids=exclude_resids, + ) + + with without_oechem_backend(): + system = system_generator.create_system( + topology, + molecules=list(small_mols.values()), + ) + + return system, topology, alchem_resids + + @staticmethod + def _handle_net_charge( + stateA_topology: openmm.app.Topology, + stateA_positions: openmm.unit.Quantity, + stateB_topology: openmm.app.Topology, + stateB_system: openmm.System, + charge_difference: int, + system_mappings: dict[str, dict[int, int]], + distance_cutoff: Quantity, + solvent_component: SolventComponent | None, + ) -> None: + """ + Handle system net charge by adding an alchemical water. + + Parameters + ---------- + stateA_topology : openmm.app.Topology + stateA_positions : openmm.unit.Quantity + stateB_topology : openmm.app.Topology + stateB_system : openmm.System + charge_difference : int + system_mappings : dict[str, dict[int, int]] + distance_cutoff : Quantity + solvent_component : SolventComponent | None + """ + # Base case, return if no net charge + if charge_difference == 0: + return + + # Get the residue ids for waters to turn alchemical + alchem_water_resids = _rfe_utils.topologyhelpers.get_alchemical_waters( + topology=stateA_topology, + positions=stateA_positions, + charge_difference=charge_difference, + distance_cutoff=distance_cutoff, + ) + + # In-place modify state B alchemical waters to ions + _rfe_utils.topologyhelpers.handle_alchemical_waters( + water_resids=alchem_water_resids, + topology=stateB_topology, + system=stateB_system, + system_mapping=system_mappings, + charge_difference=charge_difference, + solvent_component=solvent_component, + ) + def _get_omm_objects( self, stateA: ChemicalSystem, @@ -316,7 +486,15 @@ def _get_omm_objects( protein_component: ProteinComponent | None, solvent_component: SolventComponent | None, small_mols: dict[SmallMoleculeComponent, OFFMolecule] - ): + ) -> tuple[ + openmm.System, + openmm.app.Topology, + openmm.unit.Quantity, + openmm.System, + openmm.app.Topology, + openmm.unit.Quantity, + dict[str, dict[int, int]], + ]: """ Get OpenMM objects for both end states A and B. @@ -358,207 +536,140 @@ def _get_omm_objects( if self.verbose: self.logger.info("Parameterizing systems") + # TODO: get two generators, one for state A and one for stateB + # See issue #1120 # Get the system generator with all the templates registered system_generator = self._get_system_generator( shared_basepath=self.shared_basepath, settings=settings, - solv_comp=solvent_component, + solvent_comp=solvent_component, openff_molecules=list(small_mols.values()) ) - .... - - - def run( - self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None - ) -> dict[str, Any]: - """Run the relative 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. - scratch_basepath: Pathlike, optional - Where to store temporary files, defaults to current working directory - shared_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 - """ - # Prepare paths & verbosity - self._prepare(verbose, scratch_basepath, shared_basepath) - - # Get settings - settings = self._get_settings(self._inputs["protocol"].settings) - - # Get components - stateA = self._inputs["stateA"] - stateB = self._inputs["stateB"] - mapping = self._inputs["ligandmapping"] - alchem_comps, solvent_comp, protein_comp, small_mols = self._get_components( - stateA, stateB - ) - - # Assign partial charges now to avoid any discrepancies later - self._assign_partial_charges(charge_settings, small_mols) - - - - # TODO: move these down, not needed until we get to the sampler - # TODO: Also validate various conversions? - # Convert various time based inputs to steps/iterations - steps_per_iteration = settings_validation.convert_steps_per_iteration( - simulation_settings=sampler_settings, - integrator_settings=integrator_settings, - ) + # Create the state A system + small_mols_stateA = { + smc: offmol + for smc, offmol in small_mols.items() + if stateA.contains(smc) + } - equil_steps = settings_validation.get_simsteps( - sim_length=sampler_settings.equilibration_length, - timestep=integrator_settings.timestep, - mc_steps=steps_per_iteration, - ) - prod_steps = settings_validation.get_simsteps( - sim_length=sampler_settings.production_length, - timestep=integrator_settings.timestep, - mc_steps=steps_per_iteration, + stateA_system, stateA_topology, stateA_positions, comp_resids = self._create_stateA_system( + small_mols=small_mols_stateA, + protein_component=protein_component, + solvent_component=solvent_component, + system_generator=system_generator, + solvation_settings=settings["solvation_settings"] ) - # Get the change difference between the end states - # and check if the charge correction used is appropriate - charge_difference = mapping.get_alchemical_charge_difference() - - # 1. Create stateA system - self.logger.info("Parameterizing molecules") - - # b. get a system generator - if output_settings.forcefield_cache is not None: - ffcache = shared_basepath / output_settings.forcefield_cache - else: - ffcache = None - - # Block out oechem backend in system_generator calls to avoid - # any issues with smiles roundtripping between rdkit and oechem - with without_oechem_backend(): - system_generator = system_creation.get_system_generator( - forcefield_settings=forcefield_settings, - integrator_settings=integrator_settings, - thermo_settings=thermo_settings, - cache=ffcache, - has_solvent=solvent_comp is not None, - ) - - # c. force the creation of parameters - # This is necessary because we need to have the FF templates - # registered ahead of solvating the system. - for smc, mol in chain( - off_small_mols["stateA"], off_small_mols["stateB"], off_small_mols["both"] - ): - system_generator.create_system(mol.to_topology().to_openmm(), molecules=[mol]) - - # c. get OpenMM Modeller + a dictionary of resids for each component - stateA_modeller, comp_resids = system_creation.get_omm_modeller( - protein_comp=protein_comp, - solvent_comp=solvent_comp, - small_mols=dict(chain(off_small_mols["stateA"], off_small_mols["both"])), - omm_forcefield=system_generator.forcefield, - solvent_settings=solvation_settings, - ) - - # d. get topology & positions - # Note: roundtrip positions to remove vec3 issues - stateA_topology = stateA_modeller.getTopology() - stateA_positions = to_openmm(from_openmm(stateA_modeller.getPositions())) - - # e. create the stateA System - # Block out oechem backend in system_generator calls to avoid - # any issues with smiles roundtripping between rdkit and oechem - with without_oechem_backend(): - stateA_system = system_generator.create_system( - stateA_modeller.topology, - molecules=[m for _, m in chain(off_small_mols["stateA"], off_small_mols["both"])], - ) + # State B system creation + small_mols_stateB = { + smc: offmol + for smc, offmol in small_mols.items() + if stateB.contains(smc) + } - # 2. Get stateB system - # a. get the topology - stateB_topology, stateB_alchem_resids = _rfe_utils.topologyhelpers.combined_topology( - stateA_topology, - # zeroth item (there's only one) then get the OFF representation - off_small_mols["stateB"][0][1].to_topology().to_openmm(), + ( + stateB_system, + stateB_topology, + stateB_alchem_resids + ) = self._create_stateB_system( + small_mols=small_mols_stateB, + mapping=mapping, + stateA_topology=stateA_topology, exclude_resids=comp_resids[mapping.componentA], + system_generator=system_generator, ) - # b. get a list of small molecules for stateB - # Block out oechem backend in system_generator calls to avoid - # any issues with smiles roundtripping between rdkit and oechem - with without_oechem_backend(): - stateB_system = system_generator.create_system( - stateB_topology, - molecules=[m for _, m in chain(off_small_mols["stateB"], off_small_mols["both"])], - ) - - # c. Define correspondence mappings between the two systems - ligand_mappings = _rfe_utils.topologyhelpers.get_system_mappings( - mapping.componentA_to_componentB, - stateA_system, - stateA_topology, - comp_resids[mapping.componentA], - stateB_system, - stateB_topology, - stateB_alchem_resids, + # Get the mapping between the two systems + system_mappings = _rfe_utils.topologyhelpers.get_system_mappings( + old_to_new_atom_map=mapping.componentA_to_componentB, + old_system=stateA_system, + old_topology=stateA_topology, + old_resids=comp_resids[mapping.componentA], + new_system=stateB_system, + new_topology=stateB_topology, + new_resids=stateB_alchem_resids, # These are non-optional settings for this method fix_constraints=True, ) - # d. if a charge correction is necessary, select alchemical waters - # and transform them - if alchem_settings.explicit_charge_correction: - alchem_water_resids = _rfe_utils.topologyhelpers.get_alchemical_waters( - stateA_topology, - stateA_positions, - charge_difference, - alchem_settings.explicit_charge_correction_cutoff, - ) - _rfe_utils.topologyhelpers.handle_alchemical_waters( - alchem_water_resids, - stateB_topology, - stateB_system, - ligand_mappings, - charge_difference, - solvent_comp, + # Net charge: add alchemical water if needed + # Must be done here as we in-place modify the particles of state B. + if settings["alchemical_settings"].explicit_charge_correction: + self._handle_net_charge( + stateA_topology=stateA_topology, + stateA_positions=stateA_positions, + stateB_topology=stateB_topology, + stateB_system=stateB_system, + charge_difference=mapping.get_alchemical_charge_difference(), + system_mappings=system_mappings, + distance_cutoff=settings["alchemical_settings"].explicit_charge_correction_cutoff, + solvent_component=solvent_component, ) - # e. Finally get the positions + # Finally get the state B positions stateB_positions = _rfe_utils.topologyhelpers.set_and_check_new_positions( - ligand_mappings, + system_mappings, stateA_topology, stateB_topology, old_positions=ensure_quantity(stateA_positions, "openmm"), insert_positions=ensure_quantity( - off_small_mols["stateB"][0][1].conformers[0], "openmm" + small_mols[mapping.componentB].conformers[0], "openmm" ), ) - # 3. Create the hybrid topology - # a. Get softcore potential settings - if alchem_settings.softcore_LJ.lower() == "gapsys": + return ( + stateA_system, stateA_topology, stateA_positions, + stateB_system, stateB_topology, stateB_positions, + system_mappings + ) + + @staticmethod + def _get_alchemical_system( + stateA_system: openmm.System, + stateA_positions: openmm.unit.Quantity, + stateA_topology: openmm.app.Topology, + stateB_system: openmm.System, + stateB_positions: openmm.unit.Quantity, + stateB_topology: openmm.app.Topology, + system_mappings: dict[str, dict[int, int]], + alchemical_settings: AlchemicalSettings, + ): + """ + Get the hybrid topology alchemical system. + + Parameters + ---------- + stateA_system : openmm.System + State A OpenMM System + stateA_positions : openmm.unit.Quantity + Positions of state A System + stateA_topology : openmm.app.Topology + Topology of state A System + stateB_system : openmm.System + State B OpenMM System + stateB_positions : openmm.unit.Quantity + Positions of state B System + stateB_topology : openmm.app.Topology + Topology of state B System + system_mappings : dict[str, dict[int, int]] + Mapping of corresponding atoms between the two Systems. + alchemical_settings : AlchemicalSettings + The alchemical settings defining how the alchemical system + will be built. + + Returns + ------- + hybrid_factory : HybridTopologyFactory + The factory creating the hybrid system. + hybrid_system : openmm.System + The hybrid System. + """ + if alchemical_settings.softcore_LJ.lower() == "gapsys": softcore_LJ_v2 = True - elif alchem_settings.softcore_LJ.lower() == "beutler": + elif alchemical_settings.softcore_LJ.lower() == "beutler": softcore_LJ_v2 = False - # b. Get hybrid topology factory + hybrid_factory = _rfe_utils.relative.HybridTopologyFactory( stateA_system, stateA_positions, @@ -566,54 +677,103 @@ def run( stateB_system, stateB_positions, stateB_topology, - old_to_new_atom_map=ligand_mappings["old_to_new_atom_map"], - old_to_new_core_atom_map=ligand_mappings["old_to_new_core_atom_map"], - use_dispersion_correction=alchem_settings.use_dispersion_correction, - softcore_alpha=alchem_settings.softcore_alpha, + old_to_new_atom_map=system_mappings["old_to_new_atom_map"], + old_to_new_core_atom_map=system_mappings["old_to_new_core_atom_map"], + use_dispersion_correction=alchemical_settings.use_dispersion_correction, + softcore_alpha=alchemical_settings.softcore_alpha, softcore_LJ_v2=softcore_LJ_v2, - softcore_LJ_v2_alpha=alchem_settings.softcore_alpha, - interpolate_old_and_new_14s=alchem_settings.turn_off_core_unique_exceptions, + softcore_LJ_v2_alpha=alchemical_settings.softcore_alpha, + interpolate_old_and_new_14s=alchemical_settings.turn_off_core_unique_exceptions, ) - # 4. Create lambda schedule - # TODO - this should be exposed to users, maybe we should offer the - # ability to print the schedule directly in settings? - # fmt: off - lambdas = _rfe_utils.lambdaprotocol.LambdaProtocol( - functions=lambda_settings.lambda_functions, - windows=lambda_settings.lambda_windows - ) - # fmt: on - # PR #125 temporarily pin lambda schedule spacing to n_replicas - n_replicas = sampler_settings.n_replicas - if n_replicas != len(lambdas.lambda_schedule): - errmsg = ( - f"Number of replicas {n_replicas} " - f"does not equal the number of lambda windows " - f"{len(lambdas.lambda_schedule)}" + return hybrid_factory, hybrid_factory.hybrid_system + + def _subsample_topology( + self, + hybrid_topology: openmm.app.Topology, + hybrid_positions: openmm.unit.Quantity, + output_selection: str, + output_filename: str, + atom_classes: dict[str, set[int]], + ) -> npt.NDArray: + """ + Subsample the hybrid topology based on user-selected output selection + and write the subsampled topology to a PDB file. + + Parameters + ---------- + hybrid_topology : openmm.app.Topology + The hybrid system topology to subsample. + hybrid_positions : openmm.unit.Quantity + The hybrid system positions. + output_selection : str + An MDTraj selection string to subsample the topology with. + output_filename : str + The name of the file to write the PDB to. + atom_classes : dict[str, set[int]] + A dictionary defining what atoms belong to the different + components of the hybrid system. + + Returns + ------- + selection_indices : npt.NDArray + The indices of the subselected system. + + TODO + ---- + Modify this to also store the full system. + """ + selection_indices = hybrid_topology.select(output_selection) + + # Write out a PDB containing the subsampled hybrid state + # We use bfactors as a hack to label different states + # bfactor of 0 is environment atoms + # bfactor of 0.25 is unique old atoms + # bfactor of 0.5 is core atoms + # bfactor of 0.75 is unique new atoms + bfactors = np.zeros_like(selection_indices, dtype=float) + bfactors[np.isin(selection_indices, list(atom_classes['unique_old_atoms']))] = 0.25 + bfactors[np.isin(selection_indices, list(atom_classes['core_atoms']))] = 0.50 + bfactors[np.isin(selection_indices, list(atom_classes['unique_new_atoms']))] = 0.75 + + if len(selection_indices) > 0: + traj = mdtraj.Trajectory( + hybrid_positions[selection_indices, :], + hybrid_topology.subset(selection_indices), + ).save_pdb( + self.shared_basepath / output_filename, + bfactors=bfactors, ) - raise ValueError(errmsg) - # 9. Create the multistate reporter - # Get the sub selection of the system to print coords for - selection_indices = hybrid_factory.hybrid_topology.select(output_settings.output_indices) + return selection_indices - # a. Create the multistate reporter - # convert checkpoint_interval from time to iterations - chk_intervals = settings_validation.convert_checkpoint_interval_to_iterations( - checkpoint_interval=output_settings.checkpoint_interval, - time_per_iteration=sampler_settings.time_per_iteration, - ) + def _get_reporter( + self, + selection_indices: npt.NDArray, + output_settings: MultiStateOutputSettings, + simulation_settings: MultiStateSimulationSettings, + ) -> multistate.MultiStateReporter: + """ + Get the multistate reporter. - nc = shared_basepath / output_settings.output_filename + Parameters + ---------- + selection_indices : npt.NDArray + The set of system indices to report positions & velocities for. + output_settings : MultiStateOutputSettings + Settings defining how outputs should be written. + simulation_settings : MultiStateSimulationSettings + Settings defining out the simulation should be run. + """ + nc = self.shared_basepath / output_settings.output_filename chk = output_settings.checkpoint_storage_filename if output_settings.positions_write_frequency is not None: pos_interval = settings_validation.divmod_time_and_check( numerator=output_settings.positions_write_frequency, - denominator=sampler_settings.time_per_iteration, + denominator=simulation_settings.time_per_iteration, numerator_name="output settings' position_write_frequency", - denominator_name="sampler settings' time_per_iteration", + denominator_name="simulation settings' time_per_iteration", ) else: pos_interval = 0 @@ -621,14 +781,19 @@ def run( if output_settings.velocities_write_frequency is not None: vel_interval = settings_validation.divmod_time_and_check( numerator=output_settings.velocities_write_frequency, - denominator=sampler_settings.time_per_iteration, + denominator=simulation_settings.time_per_iteration, numerator_name="output settings' velocity_write_frequency", denominator_name="sampler settings' time_per_iteration", ) else: vel_interval = 0 - reporter = multistate.MultiStateReporter( + chk_intervals = settings_validation.convert_checkpoint_interval_to_iterations( + checkpoint_interval=output_settings.checkpoint_interval, + time_per_iteration=simulation_settings.time_per_iteration, + ) + + return multistate.MultiStateReporter( storage=nc, analysis_particle_indices=selection_indices, checkpoint_interval=chk_intervals, @@ -637,45 +802,39 @@ def run( velocity_interval=vel_interval, ) - # b. Write out a PDB containing the subsampled hybrid state - # fmt: off - bfactors = np.zeros_like(selection_indices, dtype=float) # solvent - bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['unique_old_atoms']))] = 0.25 # lig A - bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['core_atoms']))] = 0.50 # core - bfactors[np.in1d(selection_indices, list(hybrid_factory._atom_classes['unique_new_atoms']))] = 0.75 # lig B - # bfactors[np.in1d(selection_indices, protein)] = 1.0 # prot+cofactor - if len(selection_indices) > 0: - traj = mdtraj.Trajectory( - hybrid_factory.hybrid_positions[selection_indices, :], - hybrid_factory.hybrid_topology.subset(selection_indices), - ).save_pdb( - shared_basepath / output_settings.output_structure, - bfactors=bfactors, - ) - # fmt: on + @staticmethod + def _get_integrator( + integrator_settings: IntegratorSettings, + simulation_settings: MultiStateSimulationSettings, + system: openmm.System + ) -> openmmtools.mcmc.LangevinDynamicsMove: + """ + Get and validate the integrator - # 10. Get compute platform - # restrict to a single CPU if running vacuum - restrict_cpu = forcefield_settings.nonbonded_method.lower() == "nocutoff" - platform = omm_compute.get_openmm_platform( - platform_name=protocol_settings.engine_settings.compute_platform, - gpu_device_index=protocol_settings.engine_settings.gpu_device_index, - restrict_cpu_count=restrict_cpu, - ) + Parameters + ---------- + integrator_settings : IntegratorSettings + Settings controlling the Langevin integrator. + simulation_settings : MultiStateSimulationSettings + Settings controlling the simulation. + system : openmm.System + The OpenMM System. - # 11. Set the integrator - # a. Validate integrator settings for current system - # Virtual sites sanity check - ensure we restart velocities when - # there are virtual sites in the system - if hybrid_factory.has_virtual_sites: - if not integrator_settings.reassign_velocities: - errmsg = ( - "Simulations with virtual sites without velocity " - "reassignments are unstable in openmmtools" - ) - raise ValueError(errmsg) + Returns + ------- + integrator : openmmtools.mcmc.LangevinDynamicsMove + The LangevinDynamicsMove integrator. + + Raises + ------ + ValueError + If there are virtual sites in the system, but velocities + are not being reassigned after every MCMC move. + """ + steps_per_iteration = settings_validation.convert_steps_per_iteration( + simulation_settings, integrator_settings + ) - # b. create langevin integrator integrator = openmmtools.mcmc.LangevinDynamicsMove( timestep=to_openmm(integrator_settings.timestep), collision_rate=to_openmm(integrator_settings.langevin_collision_rate), @@ -685,52 +844,112 @@ def run( constraint_tolerance=integrator_settings.constraint_tolerance, ) - # 12. Create sampler - self.logger.info("Creating and setting up the sampler") + # Validate for known issue when dealing with virtual sites + # and mutltistate simulations + if not integrator_settings.reassign_velocities: + for particle_idx in range(system.getNumParticles()): + if system.isVirtualSite(particle_idx): + errmsg = ( + "Simulations with virtual sites without velocity " + "reassignments are unstable with MCMC integrators." + ) + raise ValueError(errmsg) + + return integrator + + @staticmethod + def _get_sampler( + system: openmm.System, + positions: openmm.unit.Quantity, + lambdas: _rfe_utils.lambdaprotocol.LambdaProtocol, + integrator: openmmtools.mcmc.MCMCMove, + reporter: multistate.MultiStateReporter, + simulation_settings: MultiStateSimulationSettings, + thermo_settings: ThermoSettings, + alchem_settings: AlchemicalSettings, + platform: openmm.Platform, + dry: bool, + ) -> multistate.MultiStateSampler: + """ + Get the MultiStateSampler. + + Parameters + ---------- + system : openmm.System + The OpenMM System to simulate. + positions : openmm.unit.Quantity + The positions of the OpenMM System. + lambdas : LambdaProtocol + The lambda protocol to sample along. + integrator : openmmtools.mcmc.MCMCMove + The integrator to use. + reporter : multistate.MultiStateReporter + The reporter to attach to the sampler. + simulation_settings : MultiStateSimulationSettings + The simulation control settings. + thermo_settings : ThermoSettings + The thermodynamic control settings. + alchem_settings : AlchemicalSettings + The alchemical transformation settings. + platform : openmm.Platform + The compute platform to use. + dry : bool + Whether or not this is a dry run. + + Returns + ------- + sampler : multistate.MultiStateSampler + The requested sampler. + """ rta_its, rta_min_its = settings_validation.convert_real_time_analysis_iterations( - simulation_settings=sampler_settings, + simulation_settings=simulation_settings, ) + # convert early_termination_target_error from kcal/mol to kT early_termination_target_error = ( settings_validation.convert_target_error_from_kcal_per_mole_to_kT( thermo_settings.temperature, - sampler_settings.early_termination_target_error, + simulation_settings.early_termination_target_error, ) ) - if sampler_settings.sampler_method.lower() == "repex": + if simulation_settings.sampler_method.lower() == "repex": sampler = _rfe_utils.multistate.HybridRepexSampler( mcmc_moves=integrator, - hybrid_system=hybrid_factory.hybrid_system, - hybrid_positions=hybrid_factory.hybrid_positions, + hybrid_system=system, + hybrid_positions=positions, online_analysis_interval=rta_its, online_analysis_target_error=early_termination_target_error, online_analysis_minimum_iterations=rta_min_its, ) - elif sampler_settings.sampler_method.lower() == "sams": + + elif simulation_settings.sampler_method.lower() == "sams": sampler = _rfe_utils.multistate.HybridSAMSSampler( mcmc_moves=integrator, - hybrid_system=hybrid_factory.hybrid_system, - hybrid_positions=hybrid_factory.hybrid_positions, + hybrid_system=system, + hybrid_positions=positions, online_analysis_interval=rta_its, online_analysis_minimum_iterations=rta_min_its, - flatness_criteria=sampler_settings.sams_flatness_criteria, - gamma0=sampler_settings.sams_gamma0, + flatness_criteria=simulation_settings.sams_flatness_criteria, + gamma0=simulation_settings.sams_gamma0, ) - elif sampler_settings.sampler_method.lower() == "independent": + + elif simulation_settings.sampler_method.lower() == "independent": sampler = _rfe_utils.multistate.HybridMultiStateSampler( mcmc_moves=integrator, - hybrid_system=hybrid_factory.hybrid_system, - hybrid_positions=hybrid_factory.hybrid_positions, + hybrid_system=system, + hybrid_positions=positions, online_analysis_interval=rta_its, online_analysis_target_error=early_termination_target_error, online_analysis_minimum_iterations=rta_min_its, ) + + else: - raise AttributeError(f"Unknown sampler {sampler_settings.sampler_method}") + raise AttributeError(f"Unknown sampler {simulation_settings.sampler_method}") sampler.setup( - n_replicas=sampler_settings.n_replicas, + n_replicas=simulation_settings.n_replicas, reporter=reporter, lambda_protocol=lambdas, temperature=to_openmm(thermo_settings.temperature), @@ -741,63 +960,256 @@ def run( minimization_steps=100 if not dry else None, ) - try: - # Create context caches (energy + sampler) - energy_context_cache = openmmtools.cache.ContextCache( - capacity=None, - time_to_live=None, - platform=platform, - ) + # Get and set the context caches + sampler.energy_context_cache = openmmtools.cache.ContextCache( + capacity=None, + time_to_live=None, + platform=platform, + ) + sampler.sampler_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, + return sampler + + def _run_simulation( + self, + sampler: multistate.MultiStateSampler, + reporter: multistate.MultiStateReporter, + simulation_settings : MultiStateSimulationSettings, + integrator_settings : IntegratorSettings, + output_settings : MultiStateOutputSettings, + 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. + simulation_settings : MultiStateSimulationSettings + Simulation control settings. + integrator_settings : IntegratorSettings + Integrator control settings. + output_settings : MultiStateOutputSettings + Simulation output control settings. + dry : bool + Whether or not to dry run the simulation. + + Returns + ------- + unit_results_dict : dict | None + A dictionary containing the free energy results to report. + ``None`` if it is a dry run. + """ + # Get the relevant simulation steps + mc_steps = settings_validation.convert_steps_per_iteration( + simulation_settings=simulation_settings, + integrator_settings=integrator_settings, + ) + + equil_steps = settings_validation.get_simsteps( + sim_length=simulation_settings.equilibration_length, + timestep=integrator_settings.timestep, + mc_steps=mc_steps, + ) + prod_steps = settings_validation.get_simsteps( + sim_length=simulation_settings.production_length, + timestep=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=simulation_settings.minimization_steps) + + # equilibrate + if self.verbose: + self.logger.info("equilibrating systems") + + sampler.equilibrate(int(equil_steps / mc_steps)) + + # production + if self.verbose: + self.logger.info("running production phase") + + sampler.extend(int(prod_steps / mc_steps)) + + if self.verbose: + self.logger.info("production phase complete") + + if self.verbose: + self.logger.info("post-simulation result analysis") + + # calculate relevant analysis of the free energies & sampling + analyzer = multistate_analysis.MultistateEquilFEAnalysis( + reporter, + sampling_method=simulation_settings.sampler_method.lower(), + result_units=offunit.kilocalorie_per_mole, ) + analyzer.plot(filepath=self.shared_basepath, filename_prefix="") + analyzer.close() + + return analyzer.unit_results_dict + + else: + # We ran a dry simulation + # close reporter when you're done, prevent file handle clashes + reporter.close() + + # TODO: review this is likely no longer necessary + # clean up the reporter file + fns = [ + self.shared_basepath / output_settings.output_filename, + self.shared_basepath / output_settings.checkpoint_storage_filename, + ] + for fn in fns: + os.remove(fn) + + return None + + def run( + self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None + ) -> dict[str, Any]: + """Run the relative 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. + scratch_basepath: Pathlike, optional + Where to store temporary files, defaults to current working directory + shared_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 + """ + # Prepare paths & verbosity + self._prepare(verbose, scratch_basepath, shared_basepath) + + # Get settings + settings = self._get_settings(self._inputs["protocol"].settings) + + # Get components + stateA = self._inputs["stateA"] + stateB = self._inputs["stateB"] + mapping = self._inputs["ligandmapping"] + alchem_comps, solvent_comp, protein_comp, small_mols = self._get_components( + stateA, stateB + ) - sampler.energy_context_cache = energy_context_cache - sampler.sampler_context_cache = sampler_context_cache + # Assign partial charges now to avoid any discrepancies later + self._assign_partial_charges(settings["charge_settings"], small_mols) - if not dry: # pragma: no-cover - # minimize - if verbose: - self.logger.info("Running minimization") + ( + stateA_system, stateA_topology, stateA_positions, + stateB_system, stateB_topology, stateB_positions, + system_mappings + ) = self._get_omm_objects( + stateA=stateA, + stateB=stateB, + mapping=mapping, + settings=settings, + protein_component=protein_comp, + solvent_component=solvent_comp, + small_mols=small_mols + ) - sampler.minimize(max_iterations=sampler_settings.minimization_steps) + # Get the hybrid factory & system + hybrid_factory, hybrid_system = self._get_alchemical_system( + stateA_system=stateA_system, + stateA_positions=stateA_positions, + stateA_topology=stateA_topology, + stateB_system=stateB_system, + stateB_positions=stateB_positions, + stateB_topology=stateB_topology, + system_mappings=system_mappings, + alchemical_settings=settings["alchemical_settings"], + ) - # equilibrate - if verbose: - self.logger.info("Running equilibration phase") + # Subselect system based on user inputs & write initial PDB + selection_indices = self._subsample_topology( + hybrid_topology=hybrid_factory.hybrid_topology, + hybrid_positions=hybrid_factory.hybrid_positions, + output_selection=settings["output_settings"].output_indices, + output_filename=settings["output_settings"].output_structure, + atom_classes=hybrid_factory._atom_classes, + ) - sampler.equilibrate(int(equil_steps / steps_per_iteration)) + # Get the lambda schedule + # TODO - this should be better exposed to users + lambdas = _rfe_utils.lambdaprotocol.LambdaProtocol( + functions=settings["lambda_settings"].lambda_functions, + windows=settings["lambda_settings"].lambda_windows + ) + + # Get the reporter + reporter = self._get_reporter( + selection_indices=selection_indices, + output_settings=settings["output_settings"], + simulation_settings=settings["simulation_settings"], + ) - # production - if verbose: - self.logger.info("Running production phase") + # Get the compute platform + restrict_cpu = settings["forcefield_settings"].nonbonded_method.lower() == "nocutoff" + platform = omm_compute.get_openmm_platform( + platform_name=settings["engine_settings"].compute_platform, + gpu_device_index=settings["engine_settings"].gpu_device_index, + restrict_cpu_count=restrict_cpu, + ) - sampler.extend(int(prod_steps / steps_per_iteration)) + # Get the integrator + integrator = self._get_integrator( + integrator_settings=settings["integrator_settings"], + simulation_settings=settings["simulation_settings"], + system=hybrid_system + ) - self.logger.info("Production phase complete") + try: + # Get sampler + sampler = self._get_sampler( + system=hybrid_system, + positions=hybrid_factory.hybrid_positions, + lambdas=lambdas, + integrator=integrator, + reporter=reporter, + simulation_settings=settings["simulation_settings"], + thermo_settings=settings["thermo_settings"], + alchem_settings=settings["alchemical_settings"], + platform=platform, + dry=dry + ) - self.logger.info("Post-simulation analysis of results") - # calculate relevant analyses of the free energies & sampling - # First close & reload the reporter to avoid netcdf clashes - analyzer = multistate_analysis.MultistateEquilFEAnalysis( - reporter, - sampling_method=sampler_settings.sampler_method.lower(), - result_units=offunit.kilocalorie_per_mole, - ) - analyzer.plot(filepath=shared_basepath, filename_prefix="") - analyzer.close() - - else: - # clean up the reporter file - fns = [ - shared_basepath / output_settings.output_filename, - shared_basepath / output_settings.checkpoint_storage_filename, - ] - for fn in fns: - os.remove(fn) + unit_results_dict = self._run_simulation( + sampler=sampler, + reporter=reporter, + simulation_settings=settings["simulation_settings"], + integrator_settings=settings["integrator_settings"], + output_settings=settings["output_settings"], + dry=dry, + ) finally: # close reporter when you're done, prevent # file handle clashes @@ -806,26 +1218,29 @@ def run( # clear GPU contexts # TODO: use cache.empty() calls when openmmtools #690 is resolved # replace with above - for context in list(energy_context_cache._lru._data.keys()): - del energy_context_cache._lru._data[context] - for context in list(sampler_context_cache._lru._data.keys()): - del sampler_context_cache._lru._data[context] + for context in list(sampler.energy_context_cache._lru._data.keys()): + del sampler.energy_context_cache._lru._data[context] + for context in list(sampler.sampler_context_cache._lru._data.keys()): + del sampler.sampler_context_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_context_cache, energy_context_cache + del sampler.sampler_context_cache, sampler.energy_context_cache if not dry: del integrator, sampler if not dry: # pragma: no-cover - return {"nc": nc, "last_checkpoint": chk, **analyzer.unit_results_dict} + unit_results_dict["nc"] = nc + unit_results_dict["last_checkpoint"] = chk + unit_results_dict["selection_indices"] = selection_indices + return unit_results_dict else: return {"debug": { "sampler": sampler, - "hybrid_factory": hybrid_factory + "hybrid_factory": hybrid_factory, } } From 6a5a76a95957dbbb0722657d86a2604ce62a7875 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 09:59:29 -0500 Subject: [PATCH 22/91] more broadly disallow oechem as a backend when creating systems --- .../protocols/openmm_rfe/hybridtop_units.py | 96 +++++++++---------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index da090e88e..14f4485bb 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -373,11 +373,10 @@ def _create_stateA_system( # Note: roundtrip positions to remove vec3 issues positions = to_openmm(from_openmm(modeller.getPositions())) - with without_oechem_backend(): - system = system_generator.create_system( - modeller.topology, - molecules=list(small_mols.values()), - ) + system = system_generator.create_system( + modeller.topology, + molecules=list(small_mols.values()), + ) return system, topology, positions, comp_resids @@ -422,11 +421,10 @@ def _create_stateB_system( exclude_resids=exclude_resids, ) - with without_oechem_backend(): - system = system_generator.create_system( - topology, - molecules=list(small_mols.values()), - ) + system = system_generator.create_system( + topology, + molecules=list(small_mols.values()), + ) return system, topology, alchem_resids @@ -536,49 +534,49 @@ def _get_omm_objects( if self.verbose: self.logger.info("Parameterizing systems") - # TODO: get two generators, one for state A and one for stateB - # See issue #1120 - # Get the system generator with all the templates registered - system_generator = self._get_system_generator( - shared_basepath=self.shared_basepath, - settings=settings, - solvent_comp=solvent_component, - openff_molecules=list(small_mols.values()) - ) + def _filter_small_mols(smols, state): + return { + smc: offmol + for smc, offmol in smols.items() + if state.contains(smc) + } - # Create the state A system - small_mols_stateA = { - smc: offmol - for smc, offmol in small_mols.items() - if stateA.contains(smc) - } + small_mols_stateA = _filter_small_mols(small_mols, stateA) + small_mols_stateB = _filter_small_mols(small_mols, stateB) - stateA_system, stateA_topology, stateA_positions, comp_resids = self._create_stateA_system( - small_mols=small_mols_stateA, - protein_component=protein_component, - solvent_component=solvent_component, - system_generator=system_generator, - solvation_settings=settings["solvation_settings"] - ) + # Everything involving systemgenerator handling has a risk of + # oechem <-> rdkit smiles conversion clashes, cautiously ban it. + with without_oechem_backend(): + # TODO: get two generators, one for state A and one for stateB + # See issue #1120 + # Get the system generator with all the templates registered + system_generator = self._get_system_generator( + shared_basepath=self.shared_basepath, + settings=settings, + solvent_comp=solvent_component, + openff_molecules=list(small_mols.values()) + ) - # State B system creation - small_mols_stateB = { - smc: offmol - for smc, offmol in small_mols.items() - if stateB.contains(smc) - } + ( + stateA_system, stateA_topology, stateA_positions, + comp_resids + ) = self._create_stateA_system( + small_mols=small_mols_stateA, + protein_component=protein_component, + solvent_component=solvent_component, + system_generator=system_generator, + solvation_settings=settings["solvation_settings"] + ) - ( - stateB_system, - stateB_topology, - stateB_alchem_resids - ) = self._create_stateB_system( - small_mols=small_mols_stateB, - mapping=mapping, - stateA_topology=stateA_topology, - exclude_resids=comp_resids[mapping.componentA], - system_generator=system_generator, - ) + ( + stateB_system, stateB_topology, stateB_alchem_resids + ) = self._create_stateB_system( + small_mols=small_mols_stateB, + mapping=mapping, + stateA_topology=stateA_topology, + exclude_resids=comp_resids[mapping.componentA], + system_generator=system_generator, + ) # Get the mapping between the two systems system_mappings = _rfe_utils.topologyhelpers.get_system_mappings( From cdd3da04b3a0110d240e34a4ce1e9231ceb4e687 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 11:33:03 -0500 Subject: [PATCH 23/91] fix issue with nc being undefined --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 14f4485bb..06d3270e6 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1230,6 +1230,8 @@ def run( del integrator, sampler if not dry: # pragma: no-cover + nc = self.shared_basepath / settings["output_settings"].output_filename + chk = settings["output_settings"].checkpoint_storage_filename unit_results_dict["nc"] = nc unit_results_dict["last_checkpoint"] = chk unit_results_dict["selection_indices"] = selection_indices From b580de5c09ce90e9a4bd0425fd2e9e9523fa5ba5 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 13:43:32 -0500 Subject: [PATCH 24/91] Make structural analysis not use the CLI anymore --- .../protocols/openmm_rfe/hybridtop_units.py | 76 +++++++++++++------ .../openmm_rfe/test_hybrid_top_protocol.py | 5 +- 2 files changed, 55 insertions(+), 26 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 06d3270e6..08295646a 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1245,39 +1245,60 @@ def run( } @staticmethod - def structural_analysis(scratch, shared) -> dict: - # don't put energy analysis in here, it uses the open file reporter - # whereas structural stuff requires that the file handle is closed - # TODO: we should just make openfe_analysis write an npz instead! - analysis_out = scratch / "structural_analysis.json" - - ret = subprocess.run( - [ - "openfe_analysis", # CLI entry point - "RFE_analysis", # CLI option - str(shared), # Where the simulation.nc fille - str(analysis_out), # Where the analysis json file is written - ], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if ret.returncode: - return {"structural_analysis_error": ret.stderr} + def structural_analysis( + scratch: pathlib.Path, + shared: pathlib.Path, + pdb_filename: str, + trj_filename: str, + ) -> dict[str, str]: + """ + Run structural analysis using ``openfe-analysis``. + + Parameters + ---------- + scratch : pathlib.path + Path to the scratch directory. + shared : pathlib.path + Path to the shared directory. + pdb_filename : str + The PDB file name. + trj_filename : str + The trajectory file name. - with open(analysis_out, "rb") as f: - data = json.load(f) + Returns + ------- + dict[str, str] + Dictionary containing either the path to the NPZ + file with the structural data, or the analysis error. + + Notes + ----- + Don't put energy analysis here, it uses the open file reporter + whereas structural stuff requires the file handle to be closed. + """ + import json + from openfe_analysis import rmsd + + # TODO: fix these so that it works with any user defined name + pdb_file = shared / "hybrid_system.pdb" + trj_file = shared / "simulation.nc" + + try: + data = rmsd.gather_rms_data(pdb_file, trj_file) + # TODO: change this to more specific exception types + except Exception as e: + return {"structural_analysis_error": str(e)} - savedir = pathlib.Path(shared) if d := data["protein_2D_RMSD"]: fig = plotting.plot_2D_rmsd(d) - fig.savefig(savedir / "protein_2D_RMSD.png") + fig.savefig(shared / "protein_2D_RMSD.png") plt.close(fig) f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) - f2.savefig(savedir / "ligand_COM_drift.png") + f2.savefig(shared / "ligand_COM_drift.png") plt.close(f2) f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) - f3.savefig(savedir / "ligand_RMSD.png") + f3.savefig(shared / "ligand_RMSD.png") plt.close(f3) # Save to numpy compressed format (~ 6x more space efficient than JSON) @@ -1301,7 +1322,12 @@ def _execute( outputs = self.run(scratch_basepath=ctx.scratch, shared_basepath=ctx.shared) - structural_analysis_outputs = self.structural_analysis(ctx.scratch, ctx.shared) + structural_analysis_outputs = self.structural_analysis( + scratch=ctx.scratch, + shared=ctx.shared, + pdb_filename=self._inputs["protocol"].settings.output_settings.output_structure, + trj_filename=self._inputs["protocol"].settings.output_settings.output_filename, + ) return { "repeat_id": self._inputs["repeat_id"], diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 283104e52..e063d7d50 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -1916,7 +1916,10 @@ def test_dry_run_complex_alchemwater_totcharge( def test_structural_analysis_error(tmpdir): with tmpdir.as_cwd(): ret = openmm_rfe.RelativeHybridTopologyProtocolUnit.structural_analysis( - Path("."), Path(".") + Path("."), + Path("."), + 'foo', + 'bar' ) assert "structural_analysis_error" in ret From 37cee847bb15c27947c14c594f0a61d0d91ad248 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 16:40:29 -0500 Subject: [PATCH 25/91] Create two system generators to handle issue 1120 --- .../protocols/openmm_rfe/hybridtop_units.py | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 06d3270e6..cb307dd4f 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -261,10 +261,10 @@ def _assign_partial_charges( @staticmethod def _get_system_generator( - shared_basepath: pathlib.Path, settings: dict[str, SettingsBaseModel], - solvent_comp: SolventComponent | None, + solvent_component: SolventComponent | None, openff_molecules: list[OFFMolecule] | None, + ffcache: pathlib.Path | None ) -> SystemGenerator: """ Get an OpenMM SystemGenerator. @@ -273,21 +273,18 @@ def _get_system_generator( ---------- settings : dict[str, SettingsBaseModel] A dictionary of protocol settings. - solvent_comp : SolventComponent | None + solvent_component : SolventComponent | None The solvent component of the system, if any. openff_molecules : list[openff.Toolkit] | None A list of openff molecules to generate templates for, if any. + ffcache : pathlib.Path | None + Path to the force field parameter cache. Returns ------- system_generator : openmmtools.SystemGenerator The SystemGenerator for the protocol. """ - ffcache = settings["output_settings"].forcefield_cache - - if ffcache is not None: - ffcache = shared_basepath / ffcache - # Block out oechem backend in system_generator calls to avoid # any issues with smiles roundtripping between rdkit and oechem with without_oechem_backend(): @@ -296,7 +293,7 @@ def _get_system_generator( integrator_settings=settings["integrator_settings"], thermo_settings=settings["thermo_settings"], cache=ffcache, - has_solvent=solvent_comp is not None, + has_solvent=solvent_component is not None, ) # Handle openff Molecule templates @@ -534,48 +531,53 @@ def _get_omm_objects( if self.verbose: self.logger.info("Parameterizing systems") - def _filter_small_mols(smols, state): + def _filter_mols(smols, state): return { smc: offmol for smc, offmol in smols.items() if state.contains(smc) } - small_mols_stateA = _filter_small_mols(small_mols, stateA) - small_mols_stateB = _filter_small_mols(small_mols, stateB) + states_inputs = { + 'A': {'state': stateA, 'mols': _filter_mols(small_mols, stateA)}, + 'B': {'state': stateB, 'mols': _filter_mols(small_mols, stateB)}, + } # Everything involving systemgenerator handling has a risk of # oechem <-> rdkit smiles conversion clashes, cautiously ban it. with without_oechem_backend(): - # TODO: get two generators, one for state A and one for stateB - # See issue #1120 - # Get the system generator with all the templates registered - system_generator = self._get_system_generator( - shared_basepath=self.shared_basepath, - settings=settings, - solvent_comp=solvent_component, - openff_molecules=list(small_mols.values()) - ) + # Get the system generators with all the templates registered + for state in ['A', 'B']: + ffcache = settings["output_settings"].forcefield_cache + if ffcache is not None: + ffcache = self.shared_basepath / (f"{state}_" + ffcache) + + states_inputs[state]['generator'] = self._get_system_generator( + settings=settings, + solvent_component=solvent_component, + openff_molecules=list(states_inputs[state]['mols'].values()), + ffcache=ffcache, + ) ( stateA_system, stateA_topology, stateA_positions, comp_resids ) = self._create_stateA_system( - small_mols=small_mols_stateA, + small_mols=states_inputs['A']['mols'], protein_component=protein_component, solvent_component=solvent_component, - system_generator=system_generator, + system_generator=states_inputs['A']['generator'], solvation_settings=settings["solvation_settings"] ) ( stateB_system, stateB_topology, stateB_alchem_resids ) = self._create_stateB_system( - small_mols=small_mols_stateB, + small_mols=states_inputs['B']['mols'], mapping=mapping, stateA_topology=stateA_topology, exclude_resids=comp_resids[mapping.componentA], - system_generator=system_generator, + system_generator=states_inputs['B']['generator'], ) # Get the mapping between the two systems From 35c19fdb32c912773fb643dff232c748fae4ae8d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 16:41:53 -0500 Subject: [PATCH 26/91] Add news item --- news/issue-1120.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 news/issue-1120.rst diff --git a/news/issue-1120.rst b/news/issue-1120.rst new file mode 100644 index 000000000..55c8286b7 --- /dev/null +++ b/news/issue-1120.rst @@ -0,0 +1,25 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Endstates in the RelativeHybridTopologyProtocol are now being created + in a manner that allows for isomorphic molecules that differ between + endstates to have different parameters (Issue #1120). + +**Security:** + +* From 7f9bf834406112dbe40c818036868e7b3353c23d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 17:09:15 -0500 Subject: [PATCH 27/91] Add a test to check we can have the same mol but different charges --- .../openmm_rfe/test_hybrid_top_protocol.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 283104e52..593c616e6 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -710,6 +710,69 @@ def test_dry_run_charge_backends( np.testing.assert_allclose(c, ref, rtol=1e-4) +def test_dry_run_same_mol_different_charges( + benzene_modifications, + vac_settings, + tmpdir +): + """ + Issue #1120 - make sure we can do an RFE of a system with different + parameters but the same molecule. + """ + protocol = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) + + benzene_offmol = benzene_modifications["benzene"].to_openff() + # Give state A some gasteiger charges + benzene_offmol.assign_partial_charges(partial_charge_method='gasteiger') + stateA_charges = copy.deepcopy(benzene_offmol.partial_charges) + stateA_mol = openfe.SmallMoleculeComponent.from_openff(benzene_offmol) + + # Give state B gasteiger charges scaled by 0.9 + benzene_offmol.partial_charges *= 0.9 + stateB_charges = copy.deepcopy(benzene_offmol.partial_charges) + stateB_mol = openfe.SmallMoleculeComponent.from_openff(benzene_offmol) + + # Create new mapping + mapper = openfe.setup.LomapAtomMapper(element_change=False) + mapping = next(mapper.suggest_mappings(stateA_mol, stateB_mol)) + + # create DAG from protocol and take first (and only) work unit from within + dag = protocol.create( + stateA=openfe.ChemicalSystem({"l": stateA_mol}), + stateB=openfe.ChemicalSystem({"l": stateB_mol}), + mapping=mapping, + ) + dag_unit = list(dag.protocol_units)[0] + + with tmpdir.as_cwd(): + debug = dag_unit.run(dry=True)["debug"] + sampler = debug["sampler"] + htf = debug["hybrid_factory"] + hybrid_system = sampler._hybrid_system + + # get the standard nonbonded force + nonbond = [f for f in hybrid_system.getForces() if isinstance(f, NonbondedForce)] + + # get the particle parameters & offsets + for i in range(hybrid_system.getNumParticles()): + # All particles should be core atoms + assert i in htf._atom_classes["core_atoms"] + + # offsets + offset = ensure_quantity(nonbond[0].getParticleParameterOffset(i)[2], "openff") + + # parameters + c, s, e = nonbond[0].getParticleParameters(i) + c = ensure_quantity(c, "openff") + + # check state A charge + assert pytest.approx(c) == stateA_charges[i] + + # check state B charge + c_diff = stateB_charges[i] - stateA_charges[i] + assert pytest.approx(offset) == c_diff + + @pytest.mark.flaky(reruns=3) # bad minimisation can happen def test_dry_run_user_charges(benzene_modifications, vac_settings, tmpdir): """ From b8268036322106b6018c6b436fa5383c4ccc11e1 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 17:21:18 -0500 Subject: [PATCH 28/91] Fix missing import --- openfe/protocols/openmm_rfe/hybridtop_protocols.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index 40accc7e2..b2bcc1ab1 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -16,6 +16,7 @@ import warnings from collections import defaultdict from typing import Any, Iterable, Optional, Union +import numpy as np import gufe from gufe import ( From 63a2f8ff6638a6434bcfc9462de8bef466d9e036 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 17:35:39 -0500 Subject: [PATCH 29/91] Fix smc checks --- .../openmm_rfe/hybridtop_protocols.py | 14 ++++++---- .../openmm_rfe/test_hybrid_top_validation.py | 28 ++++++++++++++----- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index b2bcc1ab1..7bfb04720 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -320,7 +320,7 @@ def _validate_smcs( ------ ValueError * If there are isomorphic SmallMoleculeComponents with - different charges. + different charges within a given ChemicalSystem. """ smcs_A = stateA.get_components_of_type(SmallMoleculeComponent) smcs_B = stateB.get_components_of_type(SmallMoleculeComponent) @@ -339,11 +339,13 @@ def _equal_charges(moli, molj): clashes = [] - for i, moli in enumerate(offmols): - for molj in offmols: - if moli.is_isomorphic_with(molj): - if not _equal_charges(moli, molj): - clashes.append(smcs_all[i]) + for smcs in [smcs_A, smcs_B]: + offmols = [m.to_openff() for m in smcs] + for i, moli in enumerate(offmols): + for molj in offmols: + if moli.is_isomorphic_with(molj): + if not _equal_charges(moli, molj): + clashes.append(smcs[i]) if len(clashes) > 0: errmsg = ( diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 57092ce3c..984dbd536 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -155,23 +155,18 @@ def test_smcs_different_charges_none_not_none( offmol.assign_partial_charges(partial_charge_method='gasteiger') smcB = openfe.SmallMoleculeComponent.from_openff(offmol) - stateA = openfe.ChemicalSystem({'l': smcA}) - stateB = openfe.ChemicalSystem({'l': smcB}) + state = openfe.ChemicalSystem({'a': smcA, 'b': smcB}) errmsg = "isomorphic but with different charges" with pytest.raises(ValueError, match=errmsg): openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs( - stateA, stateB + state, state ) def test_smcs_different_charges_all( benzene_modifications ): - # For this test, we will assign both A and B to both states - # It wouldn't happen in real life, but it tests that within a state - # you can pick up isomorphic molecules with different charges - # create an offmol with gasteiger charges offmol = benzene_modifications['benzene'].to_openff() offmol.assign_partial_charges(partial_charge_method='gasteiger') smcA = openfe.SmallMoleculeComponent.from_openff(offmol) @@ -189,6 +184,25 @@ def test_smcs_different_charges_all( ) +def test_smcs_different_charges_different_endstates( + benzene_modifications +): + # This should just pass, the charge is different but only + # in the end states - which is an acceptable transformation. + offmol = benzene_modifications['benzene'].to_openff() + offmol.assign_partial_charges(partial_charge_method='gasteiger') + smcA = openfe.SmallMoleculeComponent.from_openff(offmol) + + # now alter the offmol charges, scaling by 0.1 + offmol.partial_charges *= 0.1 + smcB = openfe.SmallMoleculeComponent.from_openff(offmol) + + stateA = openfe.ChemicalSystem({'l': smcA}) + stateB = openfe.ChemicalSystem({'l': smcB}) + + openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs(stateA, stateB) + + def test_solvent_nocutoff_error( benzene_system, toluene_system, From 063e8ced9d18e7889065b0fa5fbabb59ab02ceb1 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 17:41:59 -0500 Subject: [PATCH 30/91] Fix comp getter --- openfe/protocols/openmm_utils/system_validation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_utils/system_validation.py b/openfe/protocols/openmm_utils/system_validation.py index 750f5f565..7cacaa1f1 100644 --- a/openfe/protocols/openmm_utils/system_validation.py +++ b/openfe/protocols/openmm_utils/system_validation.py @@ -165,8 +165,8 @@ def get_components(state: ChemicalSystem) -> ParseCompRet: def _get_single_comps(state, comptype): comps = state.get_components_of_type(comptype) - if len(ret_comps) > 0: - return ret_comps[0] + if len(comps) > 0: + return comps[0] else: return None From c514eab0829e619c2f315c849ec19e8cbeb8781c Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 20:13:07 -0500 Subject: [PATCH 31/91] Some progress towards a three unit split --- .../protocols/openmm_rfe/hybridtop_units.py | 489 ++++++++++-------- 1 file changed, 259 insertions(+), 230 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 3f9df3fc8..51564aa66 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -76,54 +76,7 @@ logger = logging.getLogger(__name__) -class RelativeHybridTopologyProtocolUnit(gufe.ProtocolUnit): - """ - Calculates the relative free energy of an alchemical ligand transformation. - """ - def __init__( - self, - *, - protocol: gufe.Protocol, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - ligandmapping: LigandAtomMapping, - generation: int, - repeat_id: int, - name: Optional[str] = None, - ): - """ - Parameters - ---------- - protocol : RelativeHybridTopologyProtocol - protocol used to create this Unit. Contains key information such - as the settings. - stateA, stateB : ChemicalSystem - the two ligand SmallMoleculeComponents to transform between. The - transformation will go from ligandA to ligandB. - ligandmapping : LigandAtomMapping - the mapping of atoms between the two ligand components - repeat_id : int - identifier for which repeat (aka replica/clone) this Unit is - generation : int - counter for how many times this repeat has been extended - name : str, optional - human-readable identifier for this Unit - - Notes - ----- - The mapping used must not involve any elemental changes. A check for - this is done on class creation. - """ - super().__init__( - name=name, - protocol=protocol, - stateA=stateA, - stateB=stateB, - ligandmapping=ligandmapping, - repeat_id=repeat_id, - generation=generation, - ) - +class HybridTopologyUnitMixin: def _prepare( self, verbose: bool, @@ -188,6 +141,55 @@ def _get_settings( protocol_settings["engine_settings"] = settings.engine_settings return protocol_settings + +class HybridTopologySetupUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): + """ + Calculates the relative free energy of an alchemical ligand transformation. + """ + def __init__( + self, + *, + protocol: gufe.Protocol, + stateA: ChemicalSystem, + stateB: ChemicalSystem, + ligandmapping: LigandAtomMapping, + generation: int, + repeat_id: int, + name: Optional[str] = None, + ): + """ + Parameters + ---------- + protocol : RelativeHybridTopologyProtocol + protocol used to create this Unit. Contains key information such + as the settings. + stateA, stateB : ChemicalSystem + the two ligand SmallMoleculeComponents to transform between. The + transformation will go from ligandA to ligandB. + ligandmapping : LigandAtomMapping + the mapping of atoms between the two ligand components + repeat_id : int + identifier for which repeat (aka replica/clone) this Unit is + generation : int + counter for how many times this repeat has been extended + name : str, optional + human-readable identifier for this Unit + + Notes + ----- + The mapping used must not involve any elemental changes. A check for + this is done on class creation. + """ + super().__init__( + name=name, + protocol=protocol, + stateA=stateA, + stateB=stateB, + ligandmapping=ligandmapping, + repeat_id=repeat_id, + generation=generation, + ) + @staticmethod def _get_components( stateA: ChemicalSystem, @@ -747,6 +749,138 @@ def _subsample_topology( return selection_indices + def run( + self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None + ) -> dict[str, Any]: + """Run the relative 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. + scratch_basepath: Pathlike, optional + Where to store temporary files, defaults to current working directory + shared_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 + """ + # Prepare paths & verbosity + self._prepare(verbose, scratch_basepath, shared_basepath) + + # Get settings + settings = self._get_settings(self._inputs["protocol"].settings) + + # Get components + stateA = self._inputs["stateA"] + stateB = self._inputs["stateB"] + mapping = self._inputs["ligandmapping"] + alchem_comps, solvent_comp, protein_comp, small_mols = self._get_components( + stateA, stateB + ) + + # Assign partial charges now to avoid any discrepancies later + self._assign_partial_charges(settings["charge_settings"], small_mols) + + ( + stateA_system, stateA_topology, stateA_positions, + stateB_system, stateB_topology, stateB_positions, + system_mappings + ) = self._get_omm_objects( + stateA=stateA, + stateB=stateB, + mapping=mapping, + settings=settings, + protein_component=protein_comp, + solvent_component=solvent_comp, + small_mols=small_mols + ) + + # Get the hybrid factory & system + hybrid_factory, hybrid_system = self._get_alchemical_system( + stateA_system=stateA_system, + stateA_positions=stateA_positions, + stateA_topology=stateA_topology, + stateB_system=stateB_system, + stateB_positions=stateB_positions, + stateB_topology=stateB_topology, + system_mappings=system_mappings, + alchemical_settings=settings["alchemical_settings"], + ) + + # Subselect system based on user inputs & write initial PDB + selection_indices = self._subsample_topology( + hybrid_topology=hybrid_factory.hybrid_topology, + hybrid_positions=hybrid_factory.hybrid_positions, + output_selection=settings["output_settings"].output_indices, + output_filename=settings["output_settings"].output_structure, + atom_classes=hybrid_factory._atom_classes, + ) + + # Serialize things + # OpenMM System + system_outfile = self.shared_basepath / "hybrid_system.xml.bz2" + serialize(hybrid_system, system_outfile) + + # Positions + positions_outfile = self.shared_basepath / "hybrid_positions.npz" + npy_positions = from_openmm(hybrid_factory.hybrid_positions).to("nanometer").m + np.savez(positions_outfile, npy_positions) + + uni_results_dict = { + "system": system_outfile, + "positions": positions_outfile, + "pdb_structure": self.shared_basepath / settings["output_settings"].output_structure, + "selection_indices": selection_indices, + } + + if dry: + unit_results_dict |= { + "hybrid_factory": hybrid_factory, + "hybrid_system": hybrid_system, + } + + return unit_results_dict + + def _execute( + self, + ctx: gufe.Context, + **kwargs, + ) -> dict[str, Any]: + log_system_probe(logging.INFO, paths=[ctx.scratch]) + + outputs = self.run(scratch_basepath=ctx.scratch, shared_basepath=ctx.shared) + + structural_analysis_outputs = self.structural_analysis( + scratch=ctx.scratch, + shared=ctx.shared, + pdb_filename=self._inputs["protocol"].settings.output_settings.output_structure, + trj_filename=self._inputs["protocol"].settings.output_settings.output_filename, + ) + + return { + "repeat_id": self._inputs["repeat_id"], + "generation": self._inputs["generation"], + **outputs, + **structural_analysis_outputs, + } + + +class HybridTopologyMultiStateSimulationUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): def _get_reporter( self, selection_indices: npt.NDArray, @@ -904,7 +1038,7 @@ def _get_sampler( rta_its, rta_min_its = settings_validation.convert_real_time_analysis_iterations( simulation_settings=simulation_settings, ) - + # convert early_termination_target_error from kcal/mol to kT early_termination_target_error = ( settings_validation.convert_target_error_from_kcal_per_mole_to_kT( @@ -944,7 +1078,6 @@ def _get_sampler( online_analysis_minimum_iterations=rta_min_its, ) - else: raise AttributeError(f"Unknown sampler {simulation_settings.sampler_method}") @@ -1045,21 +1178,6 @@ def _run_simulation( if self.verbose: self.logger.info("production phase complete") - - if self.verbose: - self.logger.info("post-simulation result analysis") - - # calculate relevant analysis of the free energies & sampling - analyzer = multistate_analysis.MultistateEquilFEAnalysis( - reporter, - sampling_method=simulation_settings.sampler_method.lower(), - result_units=offunit.kilocalorie_per_mole, - ) - analyzer.plot(filepath=self.shared_basepath, filename_prefix="") - analyzer.close() - - return analyzer.unit_results_dict - else: # We ran a dry simulation # close reporter when you're done, prevent file handle clashes @@ -1076,87 +1194,92 @@ def _run_simulation( return None - def run( - self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None - ) -> dict[str, Any]: - """Run the relative free energy calculation. + @staticmethod + def structural_analysis( + scratch: pathlib.Path, + shared: pathlib.Path, + pdb_filename: str, + trj_filename: str, + ) -> dict[str, str]: + """ + Run structural analysis using ``openfe-analysis``. 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. - scratch_basepath: Pathlike, optional - Where to store temporary files, defaults to current working directory - shared_basepath : Pathlike, optional - Where to run the calculation, defaults to current working directory + scratch : pathlib.path + Path to the scratch directory. + shared : pathlib.path + Path to the shared directory. + pdb_filename : str + The PDB file name. + trj_filename : str + The trajectory file name. Returns ------- - dict - Outputs created in the basepath directory or the debug objects - (i.e. sampler) if ``dry==True``. + dict[str, str] + Dictionary containing either the path to the NPZ + file with the structural data, or the analysis error. - Raises - ------ - error - Exception if anything failed + Notes + ----- + Don't put energy analysis here, it uses the open file reporter + whereas structural stuff requires the file handle to be closed. """ - # Prepare paths & verbosity - self._prepare(verbose, scratch_basepath, shared_basepath) + import json + from openfe_analysis import rmsd - # Get settings - settings = self._get_settings(self._inputs["protocol"].settings) + # TODO: fix these so that it works with any user defined name + pdb_file = shared / "hybrid_system.pdb" + trj_file = shared / "simulation.nc" - # Get components - stateA = self._inputs["stateA"] - stateB = self._inputs["stateB"] - mapping = self._inputs["ligandmapping"] - alchem_comps, solvent_comp, protein_comp, small_mols = self._get_components( - stateA, stateB - ) + try: + data = rmsd.gather_rms_data(pdb_file, trj_file) + # TODO: change this to more specific exception types + except Exception as e: + return {"structural_analysis_error": str(e)} - # Assign partial charges now to avoid any discrepancies later - self._assign_partial_charges(settings["charge_settings"], small_mols) + if d := data["protein_2D_RMSD"]: + fig = plotting.plot_2D_rmsd(d) + fig.savefig(shared / "protein_2D_RMSD.png") + plt.close(fig) + f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) + f2.savefig(shared / "ligand_COM_drift.png") + plt.close(f2) - ( - stateA_system, stateA_topology, stateA_positions, - stateB_system, stateB_topology, stateB_positions, - system_mappings - ) = self._get_omm_objects( - stateA=stateA, - stateB=stateB, - mapping=mapping, - settings=settings, - protein_component=protein_comp, - solvent_component=solvent_comp, - small_mols=small_mols - ) + f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) + f3.savefig(shared / "ligand_RMSD.png") + plt.close(f3) - # Get the hybrid factory & system - hybrid_factory, hybrid_system = self._get_alchemical_system( - stateA_system=stateA_system, - stateA_positions=stateA_positions, - stateA_topology=stateA_topology, - stateB_system=stateB_system, - stateB_positions=stateB_positions, - stateB_topology=stateB_topology, - system_mappings=system_mappings, - alchemical_settings=settings["alchemical_settings"], + # Save to numpy compressed format (~ 6x more space efficient than JSON) + np.savez_compressed( + shared / "structural_analysis.npz", + protein_RMSD=np.asarray(data["protein_RMSD"], dtype=np.float32), + ligand_RMSD=np.asarray(data["ligand_RMSD"], dtype=np.float32), + ligand_COM_drift=np.asarray(data["ligand_wander"], dtype=np.float32), + protein_2D_RMSD=np.asarray(data["protein_2D_RMSD"], dtype=np.float32), + time_ps=np.asarray(data["time(ps)"], dtype=np.float32), ) - # Subselect system based on user inputs & write initial PDB - selection_indices = self._subsample_topology( - hybrid_topology=hybrid_factory.hybrid_topology, - hybrid_positions=hybrid_factory.hybrid_positions, - output_selection=settings["output_settings"].output_indices, - output_filename=settings["output_settings"].output_structure, - atom_classes=hybrid_factory._atom_classes, + return {"structural_analysis": shared / "structural_analysis.npz"} + + def run( + self, + *, + dry=False, + verbose=True, + scratch_basepath=None, + shared_basepath=None + ): + # Get the relevant outputs from the setup unit + system = deserialize(self._inputs["setup_results"]["system"]) + positions = to_openmm( + np.load(self._inputs["setup_results"]["positions"] * offunit.nm ) + selection_indices = self._inputs["setup_results"]["selection_indices"] + + # Get the settings + settings = self._get_settings(self._inputs["protocol"].settings) # Get the lambda schedule # TODO - this should be better exposed to users @@ -1202,7 +1325,7 @@ def run( dry=dry ) - unit_results_dict = self._run_simulation( + self._run_simulation( sampler=sampler, reporter=reporter, simulation_settings=settings["simulation_settings"], @@ -1232,108 +1355,14 @@ def run( del integrator, sampler if not dry: # pragma: no-cover - nc = self.shared_basepath / settings["output_settings"].output_filename - chk = settings["output_settings"].checkpoint_storage_filename - unit_results_dict["nc"] = nc - unit_results_dict["last_checkpoint"] = chk - unit_results_dict["selection_indices"] = selection_indices - return unit_results_dict + return { + "nc": self.shared_basepath / settings["output_settings"].output_filename, + "checkpoint": self.shared_basepath / self.shared_basepath / settings["output_settings"].output_filename, + } else: - return {"debug": + return {"debug": { "sampler": sampler, "hybrid_factory": hybrid_factory, } - } - - @staticmethod - def structural_analysis( - scratch: pathlib.Path, - shared: pathlib.Path, - pdb_filename: str, - trj_filename: str, - ) -> dict[str, str]: - """ - Run structural analysis using ``openfe-analysis``. - - Parameters - ---------- - scratch : pathlib.path - Path to the scratch directory. - shared : pathlib.path - Path to the shared directory. - pdb_filename : str - The PDB file name. - trj_filename : str - The trajectory file name. - - Returns - ------- - dict[str, str] - Dictionary containing either the path to the NPZ - file with the structural data, or the analysis error. - - Notes - ----- - Don't put energy analysis here, it uses the open file reporter - whereas structural stuff requires the file handle to be closed. - """ - import json - from openfe_analysis import rmsd - - # TODO: fix these so that it works with any user defined name - pdb_file = shared / "hybrid_system.pdb" - trj_file = shared / "simulation.nc" - - try: - data = rmsd.gather_rms_data(pdb_file, trj_file) - # TODO: change this to more specific exception types - except Exception as e: - return {"structural_analysis_error": str(e)} - - if d := data["protein_2D_RMSD"]: - fig = plotting.plot_2D_rmsd(d) - fig.savefig(shared / "protein_2D_RMSD.png") - plt.close(fig) - f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) - f2.savefig(shared / "ligand_COM_drift.png") - plt.close(f2) - - f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) - f3.savefig(shared / "ligand_RMSD.png") - plt.close(f3) - - # Save to numpy compressed format (~ 6x more space efficient than JSON) - np.savez_compressed( - shared / "structural_analysis.npz", - protein_RMSD=np.asarray(data["protein_RMSD"], dtype=np.float32), - ligand_RMSD=np.asarray(data["ligand_RMSD"], dtype=np.float32), - ligand_COM_drift=np.asarray(data["ligand_wander"], dtype=np.float32), - protein_2D_RMSD=np.asarray(data["protein_2D_RMSD"], dtype=np.float32), - time_ps=np.asarray(data["time(ps)"], dtype=np.float32), - ) - - return {"structural_analysis": shared / "structural_analysis.npz"} - - def _execute( - self, - ctx: gufe.Context, - **kwargs, - ) -> dict[str, Any]: - log_system_probe(logging.INFO, paths=[ctx.scratch]) - - outputs = self.run(scratch_basepath=ctx.scratch, shared_basepath=ctx.shared) - structural_analysis_outputs = self.structural_analysis( - scratch=ctx.scratch, - shared=ctx.shared, - pdb_filename=self._inputs["protocol"].settings.output_settings.output_structure, - trj_filename=self._inputs["protocol"].settings.output_settings.output_filename, - ) - - return { - "repeat_id": self._inputs["repeat_id"], - "generation": self._inputs["generation"], - **outputs, - **structural_analysis_outputs, - } From 76000d1e50c3bae135cff429d6e809630b33a5ab Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 21:58:04 -0500 Subject: [PATCH 32/91] Most of the multi-unit structure --- .../protocols/openmm_rfe/hybridtop_units.py | 380 +++++++++++++----- 1 file changed, 286 insertions(+), 94 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 51564aa66..cac496c7b 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -16,7 +16,7 @@ import pathlib import subprocess from itertools import chain -from typing import Any, Optional +from typing import Any import matplotlib.pyplot as plt import mdtraj @@ -155,7 +155,7 @@ def __init__( ligandmapping: LigandAtomMapping, generation: int, repeat_id: int, - name: Optional[str] = None, + name: str | None = None, ): """ Parameters @@ -750,9 +750,14 @@ def _subsample_topology( return selection_indices def run( - self, *, dry=False, verbose=True, scratch_basepath=None, shared_basepath=None + self, + *, + dry: bool = False, + verbose: bool = True, + scratch_basepath: pathlib.Path | None = None, + shared_basepath: pathlib.Path | None = None ) -> dict[str, Any]: - """Run the relative free energy calculation. + """Setup a hybrid topology system. Parameters ---------- @@ -763,16 +768,16 @@ def run( verbose : bool Verbose output of the simulation progress. Output is provided via INFO level logging. - scratch_basepath: Pathlike, optional + scratch_basepath: pathlib.Path | None Where to store temporary files, defaults to current working directory - shared_basepath : Pathlike, optional + shared_basepath : pathlib.Path | None 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``. + Outputs created by the setup unit or the debug objects + (e.g. HybridTopologyFactory) if ``dry==True``. Raises ------ @@ -859,24 +864,16 @@ def run( def _execute( self, ctx: gufe.Context, - **kwargs, + *, + **inputs, ) -> dict[str, Any]: log_system_probe(logging.INFO, paths=[ctx.scratch]) - outputs = self.run(scratch_basepath=ctx.scratch, shared_basepath=ctx.shared) - structural_analysis_outputs = self.structural_analysis( - scratch=ctx.scratch, - shared=ctx.shared, - pdb_filename=self._inputs["protocol"].settings.output_settings.output_structure, - trj_filename=self._inputs["protocol"].settings.output_settings.output_filename, - ) - return { "repeat_id": self._inputs["repeat_id"], "generation": self._inputs["generation"], **outputs, - **structural_analysis_outputs, } @@ -1194,89 +1191,52 @@ def _run_simulation( return None - @staticmethod - def structural_analysis( - scratch: pathlib.Path, - shared: pathlib.Path, - pdb_filename: str, - trj_filename: str, - ) -> dict[str, str]: - """ - Run structural analysis using ``openfe-analysis``. + def run( + self, + *, + system: openmm.System, + positions: openmm.unit.Quantity, + selection_indices: npt.NDArray, + dry: bool = False, + verbose: bool = True, + scratch_basepath: pathlib.Path | None = None, + shared_basepath: pathlib.Path | None = None + ) -> dict[str, Any]: + """Run the free energy calculation using a multistate sampler. Parameters ---------- - scratch : pathlib.path - Path to the scratch directory. - shared : pathlib.path - Path to the shared directory. - pdb_filename : str - The PDB file name. - trj_filename : str - The trajectory file name. + system : openmm.System + The System to simulate. + positions : openmm.unit.Quantity + The positions of the System. + selection_indices : npt.NDArray + Indices of the System particles to write to file. + 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. + scratch_basepath: pathlib.Path | None + Where to store temporary files, defaults to current working directory + shared_basepath : pathlib.Path | None + Where to run the calculation, defaults to current working directory Returns ------- - dict[str, str] - Dictionary containing either the path to the NPZ - file with the structural data, or the analysis error. + dict + Outputs created in the basepath directory or the debug objects + (i.e. sampler) if ``dry==True``. - Notes - ----- - Don't put energy analysis here, it uses the open file reporter - whereas structural stuff requires the file handle to be closed. + Raises + ------ + error + Exception if anything failed """ - import json - from openfe_analysis import rmsd - - # TODO: fix these so that it works with any user defined name - pdb_file = shared / "hybrid_system.pdb" - trj_file = shared / "simulation.nc" - - try: - data = rmsd.gather_rms_data(pdb_file, trj_file) - # TODO: change this to more specific exception types - except Exception as e: - return {"structural_analysis_error": str(e)} - - if d := data["protein_2D_RMSD"]: - fig = plotting.plot_2D_rmsd(d) - fig.savefig(shared / "protein_2D_RMSD.png") - plt.close(fig) - f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) - f2.savefig(shared / "ligand_COM_drift.png") - plt.close(f2) - - f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) - f3.savefig(shared / "ligand_RMSD.png") - plt.close(f3) - - # Save to numpy compressed format (~ 6x more space efficient than JSON) - np.savez_compressed( - shared / "structural_analysis.npz", - protein_RMSD=np.asarray(data["protein_RMSD"], dtype=np.float32), - ligand_RMSD=np.asarray(data["ligand_RMSD"], dtype=np.float32), - ligand_COM_drift=np.asarray(data["ligand_wander"], dtype=np.float32), - protein_2D_RMSD=np.asarray(data["protein_2D_RMSD"], dtype=np.float32), - time_ps=np.asarray(data["time(ps)"], dtype=np.float32), - ) - - return {"structural_analysis": shared / "structural_analysis.npz"} - - def run( - self, - *, - dry=False, - verbose=True, - scratch_basepath=None, - shared_basepath=None - ): - # Get the relevant outputs from the setup unit - system = deserialize(self._inputs["setup_results"]["system"]) - positions = to_openmm( - np.load(self._inputs["setup_results"]["positions"] * offunit.nm - ) - selection_indices = self._inputs["setup_results"]["selection_indices"] + # Prepare paths & verbosity + self._prepare(verbose, scratch_basepath, shared_basepath) # Get the settings settings = self._get_settings(self._inputs["protocol"].settings) @@ -1357,7 +1317,9 @@ def run( if not dry: # pragma: no-cover return { "nc": self.shared_basepath / settings["output_settings"].output_filename, - "checkpoint": self.shared_basepath / self.shared_basepath / settings["output_settings"].output_filename, + # TODO: historically checkpoint was provided as just the file + # Maybe we should switch to the full path? + "checkpoint": settings["output_settings"].checkpoint_storage_filename, } else: return {"debug": @@ -1366,3 +1328,233 @@ def run( "hybrid_factory": hybrid_factory, } + def _execute( + self, + ctx: gufe.Context, + *, + setup_results, + **inputs, + ) -> dict[str, Any]: + log_system_probe(logging.INFO, paths=[ctx.scratch]) + + # Get the relevant inputs + system = deserialize(setup_results.outputs["system"]) + positions = to_openmm(np.load(setup_results.outputs["positions"] * offunit.nm)) + selection_indices = setup_results.outputs["selection_indices"] + + # Run the unit + outputs = self.run( + system=system, + positions=positions, + selection_indices=selection_indices, + scratch_basepath=ctx.scratch, + shared_basepath=ctx.shared + ) + + return { + "repeat_id": self._inputs["repeat_id"], + "generation": self._inputs["generation"], + **outputs, + } + + +class HybridTopologyMultiStateAnalysisUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): + + def _analyze_multistate_energies( + trajectory: pathlib.Path, + checkpoint: str, + sampler_method: str, + output_directory: pathlib.Path, + ): + """ + Analyze multistate energies and generate plots. + + Parameters + ---------- + trajectory : pathlib.Path + Path to the NetCDF trajectory file. + checkpoint : str + The name of the checkpoint file. Note this is + relative in path to the trajectory file. + sampler_method : str + The multistate sampler method used. + output_directory : pathlib.Path + The path to where plots will be written. + """ + reporter = multistate.MultiStateReporter( + storage=trajectory, + checkpoint_storage=checkpoint, + open_mode="r", + ) + + analyzer = multistate_analysis.MultiStateEquilFEAnalysis( + reporter=reporter, + sampling_method=settings["simulation_settings"].sampler_method.lower(), + result_units=offunit.kilocalorie_per_mole, + ) + analyzer.plot(filepath=self.shared_basepath, filename_prefix="") + analyzer.close() + reporter.close() + return analyzer.unit_results_dict + + @staticmethod + def _structural_analysis( + pdb_file: pathlib.Path, + trj_file: pathlib.Path, + output_directory : pathlib.Path, + ) -> dict[str, str | pathlib.Path]: + """ + Run structural analysis using ``openfe-analysis``. + + Parameters + ---------- + pdb_file : pathlib.Path + Path to the PDB file. + trj_filen : pathlib.Path + Path to the trajectory file. + output_directory : pathlib.Path + The output directory where plots and the data NPZ file + will be stored. + + Returns + ------- + dict[str, str] + Dictionary containing either the path to the NPZ + file with the structural data, or the analysis error. + + Notes + ----- + Don't put energy analysis here as it uses the MultiStateReporter, + the structural analysis requires the file handle to be closed. + """ + from openfe_analysis import rmsd + + try: + data = rmsd.gather_rms_data(pdb_file, trj_file) + # TODO: eventually change this to more specific exception types + except Exception as e: + return {"structural_analysis_error": str(e)} + + # Generate relevant plots + if d := data["protein_2D_RMSD"]: + fig = plotting.plot_2D_rmsd(d) + fig.savefig(output_directory / "protein_2D_RMSD.png") + plt.close(fig) + f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) + f2.savefig(output_directory / "ligand_COM_drift.png") + plt.close(f2) + + f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) + f3.savefig(output_directory / "ligand_RMSD.png") + plt.close(f3) + + # Write out an NPZ with all the relevant analysis data + npz_file = output_directory / "structural_analysis.npz" + np.savez_compressed( + npz_file, + protein_RMSD=np.asarray(data["protein_RMSD"], dtype=np.float32), + ligand_RMSD=np.asarray(data["ligand_RMSD"], dtype=np.float32), + ligand_COM_drift=np.asarray(data["ligand_wander"], dtype=np.float32), + protein_2D_RMSD=np.asarray(data["protein_2D_RMSD"], dtype=np.float32), + time_ps=np.asarray(data["time(ps)"], dtype=np.float32), + ) + + return {"structural_analysis": npz_file} + + def run( + self, + *, + pdb_file: pathlib.Path, + trajectory: pathlib.Path, + checkpoint: str, + dry: bool = False, + verbose: bool = True, + scratch_basepath: pathlib.Path | None = None, + shared_basepath: pathlib.Path | None = None, + ) -> dict[str, Any]: + + """Analyze the multistate simulation. + + Parameters + ---------- + pdb_file : pathlib.Path + Path to the PDB file representing the subsampled structure. + trajectory : pathlib.Path + Path to the MultiStateReporter generated NetCDF file. + checkpoint : str + Name of the checkpoint file generated by MultiStateReporter. + 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. + scratch_basepath: pathlib.Path | None + Where to store temporary files, defaults to current working directory + shared_basepath : pathlib.Path | None + 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 + """ + # Prepare paths & verbosity + self._prepare(verbose, scratch_basepath, shared_basepath) + + # Get the settings + settings = self._get_settings(self._inputs["protocol"].settings) + + # Energies analysis + energy_analysis = self._analyze_energies( + trajectory=trajectory, + checkpoint=checkpoint, + sampler_method=settings["simulation_settings"].sampler_method.lower(), + output_directory=self.shared_basepath, + ) + + # Structural analysis + structural_analysis = self.structural_analysis( + pdb_file=pdb_file, + trj_file=trajectory, + output_directory=self.shared_basepath, + ) + + # Return relevant things + outputs = energy_analysis | structural_analysisa + return outputs + + def _execute( + self, + ctx: gufe.Context, + *, + setup_result, + simulation_result, + **inputs, + ) -> dict[str, Any]: + log_system_probe(logging.INFO, paths=[ctx.scratch]) + + pdb_file = setup_result.outputs["pdb_structure"] + trajectory = simulation_result.outputs["nc"] + checkpoint = simulation_result.outputs["checkpoint"] + + outputs = self.run( + pdb_file=pdb_file, + trajectory=trajectory, + checkpoint=checkpoint, + scratch_basepath=ctx.scratch, + shared_basepath=ctx.shared + ) + + return { + "repeat_id": self._inputs["repeat_id"], + "generation": self._inputs["generation"], + **outputs, + } From 0a4e4712b2e725deeffec75ea8b74ab42e35bb3a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 22:10:12 -0500 Subject: [PATCH 33/91] cleaning up some things --- .../protocols/openmm_rfe/hybridtop_units.py | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index cac496c7b..74cf186c0 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1360,11 +1360,13 @@ def _execute( class HybridTopologyMultiStateAnalysisUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): + @staticmethod def _analyze_multistate_energies( trajectory: pathlib.Path, checkpoint: str, sampler_method: str, output_directory: pathlib.Path, + dry: bool, ): """ Analyze multistate energies and generate plots. @@ -1380,6 +1382,8 @@ def _analyze_multistate_energies( The multistate sampler method used. output_directory : pathlib.Path The path to where plots will be written. + dry : bool + Whether or not we are running a dry run. """ reporter = multistate.MultiStateReporter( storage=trajectory, @@ -1392,7 +1396,11 @@ def _analyze_multistate_energies( sampling_method=settings["simulation_settings"].sampler_method.lower(), result_units=offunit.kilocalorie_per_mole, ) - analyzer.plot(filepath=self.shared_basepath, filename_prefix="") + + # Only create plots when not doing a dry run + if not dry: + analyzer.plot(filepath=self.shared_basepath, filename_prefix="") + analyzer.close() reporter.close() return analyzer.unit_results_dict @@ -1402,6 +1410,7 @@ def _structural_analysis( pdb_file: pathlib.Path, trj_file: pathlib.Path, output_directory : pathlib.Path, + dry: bool, ) -> dict[str, str | pathlib.Path]: """ Run structural analysis using ``openfe-analysis``. @@ -1415,6 +1424,8 @@ def _structural_analysis( output_directory : pathlib.Path The output directory where plots and the data NPZ file will be stored. + dry : bool + Whether or not we are running a dry run. Returns ------- @@ -1435,18 +1446,22 @@ def _structural_analysis( except Exception as e: return {"structural_analysis_error": str(e)} - # Generate relevant plots - if d := data["protein_2D_RMSD"]: - fig = plotting.plot_2D_rmsd(d) - fig.savefig(output_directory / "protein_2D_RMSD.png") - plt.close(fig) - f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) - f2.savefig(output_directory / "ligand_COM_drift.png") - plt.close(f2) - - f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) - f3.savefig(output_directory / "ligand_RMSD.png") - plt.close(f3) + # Generate relevant plots if not a dry run + if not dry: + if d := data["protein_2D_RMSD"]: + fig = plotting.plot_2D_rmsd(d) + fig.savefig(output_directory / "protein_2D_RMSD.png") + plt.close(fig) + f2 = plotting.plot_ligand_COM_drift( + data["time(ps)"], + data["ligand_wander"] + ) + f2.savefig(output_directory / "ligand_COM_drift.png") + plt.close(f2) + + f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) + f3.savefig(output_directory / "ligand_RMSD.png") + plt.close(f3) # Write out an NPZ with all the relevant analysis data npz_file = output_directory / "structural_analysis.npz" @@ -1513,18 +1528,26 @@ def run( settings = self._get_settings(self._inputs["protocol"].settings) # Energies analysis + if verbose: + self.logger.info("Analyzing energies") + energy_analysis = self._analyze_energies( trajectory=trajectory, checkpoint=checkpoint, sampler_method=settings["simulation_settings"].sampler_method.lower(), output_directory=self.shared_basepath, + dry=dry, ) # Structural analysis + if verbose: + self.logger.info("Analyzing structural outputs") + structural_analysis = self.structural_analysis( pdb_file=pdb_file, trj_file=trajectory, output_directory=self.shared_basepath, + dry=dry, ) # Return relevant things From d655ecac16571b4455d085f0baa92a8f1b1ddb7c Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 22:38:16 -0500 Subject: [PATCH 34/91] ammend unit creation for multi units --- .../openmm_rfe/hybridtop_protocols.py | 50 +++++++++++++++---- .../protocols/openmm_rfe/hybridtop_units.py | 10 ++-- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index 7bfb04720..c49866c68 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -590,23 +590,55 @@ def _create( Anames = ",".join(c.name for c in alchem_comps["stateA"]) Bnames = ",".join(c.name for c in alchem_comps["stateB"]) - # our DAG has no dependencies, so just list units - n_repeats = self.settings.protocol_repeats + # DAG dependency is setup -> simulation -> analysis + # |---------------------> + setup_units = [] + simulation_units = [] + analysis_units = [] - units = [ - RelativeHybridTopologyProtocolUnit( + for i in range(self.settings.protocol_repeats): + repeat_id = int(uuid.uuid4()) + + setup = HybridTopologySetupUnit( protocol=self, stateA=stateA, stateB=stateB, ligandmapping=ligandmapping, + alchemical_components=alchem_comps, + generation=0, + repeat_id=repeat_id, + name=( + f"HybridTopology Setup: {Anames} to {Bnames} " + f"repeat {i} generation 0" + ) + ) + + simulation = HybridTopologyMultiStateSimulationUnit( + protocol=self, + setup_result=setup, generation=0, - repeat_id=int(uuid.uuid4()), - name=f"{Anames} to {Bnames} repeat {i} generation 0", + repeat_id=repeat_id, + name=( + f"HybridTopology Simulation: {Anames} to {Bnames} " + f"repeat {i} generation 0" + ) + ) + + analysis = HybridTopologyMultiStateAnalysisUnit( + protocol=self, + setup_result=setup, + simulation_result=simulation, + repeat_id=repeat_id, + name=( + f"HybridTopology Analysis: {Anames} to {Bnames} " + f"repeat {i} generation 0" + ) ) - for i in range(n_repeats) - ] + setup_units.append(setup) + simulation_units.append(simulation) + analysis_units.append(analysis) - return units + return [*setup_units, *simulation_units, *analysis_units] def _gather(self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult]) -> dict[str, Any]: # result units will have a repeat_id and generations within this repeat_id diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 74cf186c0..767dece79 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -195,7 +195,6 @@ def _get_components( stateA: ChemicalSystem, stateB: ChemicalSystem ) -> tuple[ - dict[str, Component], SolventComponent, ProteinComponent, dict[SmallMoleculeComponent, OFFMolecule] @@ -212,8 +211,6 @@ def _get_components( Returns ------- - alchem_comps : dict[str, Component] - Dictionary of alchemical components. solv_comp : SolventComponent The solvent component. protein_comp : ProteinComponent @@ -222,8 +219,6 @@ def _get_components( Dictionary of small molecule components paired with their OpenFF Molecule. """ - alchem_comps = system_validation.get_alchemical_components(stateA, stateB) - solvent_comp, protein_comp, smcs_A = system_validation.get_components(stateA) _, _, smcs_B = system_validation.get_components(stateB) @@ -232,7 +227,7 @@ def _get_components( for m in set(smcs_A).union(set(smcs_B)) } - return alchem_comps, solvent_comp, protein_comp, small_mols + return solvent_comp, protein_comp, small_mols @staticmethod def _assign_partial_charges( @@ -794,7 +789,8 @@ def run( stateA = self._inputs["stateA"] stateB = self._inputs["stateB"] mapping = self._inputs["ligandmapping"] - alchem_comps, solvent_comp, protein_comp, small_mols = self._get_components( + alchem_comps = self._inputs["alchemical_components"] + solvent_comp, protein_comp, small_mols = self._get_components( stateA, stateB ) From 0bf13e84c69426302ac2c52270d7011b0d514716 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 22:57:02 -0500 Subject: [PATCH 35/91] Switch gather to only use the analysis units --- openfe/protocols/openmm_rfe/hybridtop_protocols.py | 5 +++-- openfe/protocols/openmm_rfe/hybridtop_unit_results.py | 10 ++++++---- openfe/protocols/openmm_rfe/hybridtop_units.py | 5 +++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index c49866c68..fed57706e 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -646,8 +646,9 @@ def _gather(self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult]) -> dic unsorted_repeats = defaultdict(list) for d in protocol_dag_results: pu: gufe.ProtocolUnitResult - for pu in d.protocol_unit_results: - if not pu.ok(): + for pu in d.protocol_unit_results:a + # We only need the analysis units that are ok + if ("Analysis" not in pu.name) or (not pu.ok()): continue unsorted_repeats[pu.outputs["repeat_id"]].append(pu) diff --git a/openfe/protocols/openmm_rfe/hybridtop_unit_results.py b/openfe/protocols/openmm_rfe/hybridtop_unit_results.py index d3a6dc78d..88e641c92 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_unit_results.py +++ b/openfe/protocols/openmm_rfe/hybridtop_unit_results.py @@ -34,7 +34,7 @@ def __init__(self, **data): def compute_mean_estimate(dGs: list[Quantity]) -> Quantity: u = dGs[0].u # convert all values to units of the first value, then take average of magnitude - # this would avoid a screwy case where each value was in different units + # this would avoid an edge case where each value was in different units vals = np.asarray([dG.to(u).m for dG in dGs]) return np.average(vals) * u @@ -200,9 +200,9 @@ def is_file(filename: str): replica_states = [] for pus in self.data.values(): - nc = is_file(pus[0].outputs["nc"]) + nc = is_file(pus[0].outputs["trajectory"]) dir_path = nc.parents[0] - chk = is_file(dir_path / pus[0].outputs["last_checkpoint"]).name + chk = is_file(dir_path / pus[0].outputs["checkpoint"]).name reporter = multistate.MultiStateReporter( storage=nc, checkpoint_storage=chk, open_mode="r" ) @@ -235,6 +235,8 @@ def production_iterations(self) -> list[float]: ------- production_lengths : list[float] """ - production_lengths = [pus[0].outputs["production_iterations"] for pus in self.data.values()] + production_lengths = [ + pus[0].outputs["production_iterations"] for pus in self.data.values() + ] return production_lengths diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 767dece79..49642ff32 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1575,5 +1575,10 @@ def _execute( return { "repeat_id": self._inputs["repeat_id"], "generation": self._inputs["generation"], + # We include paths to various files here also to make + # life easier when gathering results. + "pdb_structure": pdb_file, + "trajectory": trajectory, + "checkpoint": checkpoint, **outputs, } From 92eca197dede0ed0c44442cf6fccc633795339d7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:02:33 -0500 Subject: [PATCH 36/91] fix some imports --- openfe/protocols/openmm_rfe/equil_rfe_methods.py | 6 +++++- openfe/protocols/openmm_rfe/hybridtop_protocols.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 14d3c3eb5..6e3b7ccc6 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -18,5 +18,9 @@ from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult -from .hybridtop_units import RelativeHybridTopologyProtocolUnit +from .hybridtop_units import ( + HybridTopologySetupUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologyMultiStateAnalysisUnit, +) from .hybridtop_protocols import RelativeHybridTopologyProtocol diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index fed57706e..5c7cb4b66 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -49,7 +49,11 @@ RelativeHybridTopologyProtocolSettings, ) from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult -from .hybridtop_units import RelativeHybridTopologyProtocolUnit +from .hybridtop_units import ( + HybridTopologySetupUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologyMultiStateAnalysisUnit, +) logger = logging.getLogger(__name__) From a54f1677bdd1656cc5273f8a1410b4df937bdaa1 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:27:11 -0500 Subject: [PATCH 37/91] various fixes --- openfe/protocols/openmm_rfe/__init__.py | 6 +- .../openmm_rfe/hybridtop_protocols.py | 2 +- .../protocols/openmm_rfe/hybridtop_units.py | 46 +-------- .../test_hybrid_top_tokenization.py | 99 ++++++++++++++++--- 4 files changed, 95 insertions(+), 58 deletions(-) diff --git a/openfe/protocols/openmm_rfe/__init__.py b/openfe/protocols/openmm_rfe/__init__.py index 137b641c0..2b9c317a4 100644 --- a/openfe/protocols/openmm_rfe/__init__.py +++ b/openfe/protocols/openmm_rfe/__init__.py @@ -4,5 +4,9 @@ from . import _rfe_utils from .hybridtop_protocols import RelativeHybridTopologyProtocol from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult -from .hybridtop_units import RelativeHybridTopologyProtocolUnit +from .hybridtop_units import ( + HybridTopologySetupUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologyMultiStateAnalysisUnit, +) from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index 5c7cb4b66..6223b9fac 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -650,7 +650,7 @@ def _gather(self, protocol_dag_results: Iterable[gufe.ProtocolDAGResult]) -> dic unsorted_repeats = defaultdict(list) for d in protocol_dag_results: pu: gufe.ProtocolUnitResult - for pu in d.protocol_unit_results:a + for pu in d.protocol_unit_results: # We only need the analysis units that are ok if ("Analysis" not in pu.name) or (not pu.ok()): continue diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 49642ff32..949e899bb 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -146,50 +146,6 @@ class HybridTopologySetupUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): """ Calculates the relative free energy of an alchemical ligand transformation. """ - def __init__( - self, - *, - protocol: gufe.Protocol, - stateA: ChemicalSystem, - stateB: ChemicalSystem, - ligandmapping: LigandAtomMapping, - generation: int, - repeat_id: int, - name: str | None = None, - ): - """ - Parameters - ---------- - protocol : RelativeHybridTopologyProtocol - protocol used to create this Unit. Contains key information such - as the settings. - stateA, stateB : ChemicalSystem - the two ligand SmallMoleculeComponents to transform between. The - transformation will go from ligandA to ligandB. - ligandmapping : LigandAtomMapping - the mapping of atoms between the two ligand components - repeat_id : int - identifier for which repeat (aka replica/clone) this Unit is - generation : int - counter for how many times this repeat has been extended - name : str, optional - human-readable identifier for this Unit - - Notes - ----- - The mapping used must not involve any elemental changes. A check for - this is done on class creation. - """ - super().__init__( - name=name, - protocol=protocol, - stateA=stateA, - stateB=stateB, - ligandmapping=ligandmapping, - repeat_id=repeat_id, - generation=generation, - ) - @staticmethod def _get_components( stateA: ChemicalSystem, @@ -860,7 +816,6 @@ def run( def _execute( self, ctx: gufe.Context, - *, **inputs, ) -> dict[str, Any]: log_system_probe(logging.INFO, paths=[ctx.scratch]) @@ -1323,6 +1278,7 @@ def run( "sampler": sampler, "hybrid_factory": hybrid_factory, } + } def _execute( self, diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py index 322c0f950..9fda51710 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py @@ -6,6 +6,11 @@ from openff.units import unit from openfe.protocols import openmm_rfe +from openfe.protocols.openmm_rfe.hybridtop_units import ( + HybridTopologySetupUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologyMultiStateAnalysisUnit, +) """ todo: @@ -23,7 +28,7 @@ def rfe_protocol(): @pytest.fixture -def rfe_protocol_other_units(): +def rfe_protocol_other_input_units(): """Identical to rfe_protocol, but with `kcal / mol` as input unit instead of `kilocalorie_per_mole`.""" new_settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() new_settings.simulation_settings.early_termination_target_error = 0.0 * unit.kilocalorie/unit.mol # fmt: skip @@ -31,13 +36,45 @@ def rfe_protocol_other_units(): @pytest.fixture -def protocol_unit(rfe_protocol, benzene_system, toluene_system, benzene_to_toluene_mapping): +def protocol_units( + rfe_protocol, + benzene_system, + toluene_system, + benzene_to_toluene_mapping +): pus = rfe_protocol.create( stateA=benzene_system, stateB=toluene_system, mapping=[benzene_to_toluene_mapping], ) - return list(pus.protocol_units)[0] + return list(pus.protocol_units) + + +@pytest.fixture +def protocol_setup_unit( + protocol_units +): + for pu in protocol_units: + if isinstance(pu, HybridTopologySetupUnit): + return pu + + +@pytest.fixture +def protocol_simulation_unit( + protocol_units +): + for pu in protocol_units: + if isinstance(pu, HybridTopologyMultiStateSimulationUnit): + return pu + + +@pytest.fixture +def protocol_analysis_unit( + protocol_units +): + for pu in protocol_units: + if isinstance(pu, HybridTopologyMultiStateAnalysisUnit): + return pu @pytest.mark.skip @@ -51,14 +88,14 @@ def instance(self): pass -class TestRelativeHybridTopologyProtocolOtherUnits(GufeTokenizableTestsMixin): +class TestRelativeHybridTopologyProtocolOtherInputUnits(GufeTokenizableTestsMixin): cls = openmm_rfe.RelativeHybridTopologyProtocol key = None repr = " Date: Mon, 29 Dec 2025 23:35:30 -0500 Subject: [PATCH 38/91] Part migrate serialization utils --- .../protocols/openmm_rfe/hybridtop_units.py | 4 ++ .../protocols/openmm_utils/serialization.py | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 openfe/protocols/openmm_utils/serialization.py diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 949e899bb..8ae44ab35 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -59,6 +59,10 @@ system_creation, system_validation, ) +from ..openmm_utils.serialization import ( + serialize, + deserialize, +) from . import _rfe_utils from ._rfe_utils.relative import HybridTopologyFactory from .equil_rfe_settings import ( diff --git a/openfe/protocols/openmm_utils/serialization.py b/openfe/protocols/openmm_utils/serialization.py new file mode 100644 index 000000000..9b624291d --- /dev/null +++ b/openfe/protocols/openmm_utils/serialization.py @@ -0,0 +1,64 @@ +import os +import pathlib + + +def serialize(item, filename: pathlib.Path): + """ + Serialize an OpenMM System, State, or Integrator. + + Parameters + ---------- + item : System, State, or Integrator + The thing to be serialized + filename : str + The filename to serialize to + """ + from openmm import XmlSerializer + + # Create parent directory if it doesn't exist + filename_basedir = filename.parent + if not filename_basedir.exists(): + os.makedirs(filename_basedir) + + if filename.suffix == ".bz2": + import bz2 + + with bz2.open(filename, mode="wb") as outfile: + serialized_thing = XmlSerializer.serialize(item) + outfile.write(serialized_thing.encode()) + else: + with open(filename, mode="w") as outfile: + serialized_thing = XmlSerializer.serialize(item) + outfile.write(serialized_thing) + + +def deserialize(filename: pathlib.Path): + """ + Deserialize an OpenMM System, State, or Integrator. + + Parameters + ---------- + item : System, State, or Integrator + The thing to be serialized + filename : str + The filename to serialize to + """ + from openmm import XmlSerializer + + # Create parent directory if it doesn't exist + filename_basedir = filename.parent + if not filename_basedir.exists(): + os.makedirs(filename_basedir) + + if filename.suffix == ".bz2": + import bz2 + + with bz2.open(filename, mode="rb") as infile: + serialized_thing = infile.read().decode() + item = XmlSerializer.deserialize(serialized_thing) + else: + with open(filename) as infile: + serialized_thing = infile.read() + item = XmlSerializer.deserialize(serialized_thing) + + return item From 2630f92c215d622ff975293add3e48eac5364f27 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:36:39 -0500 Subject: [PATCH 39/91] fix typo --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 8ae44ab35..97c28da2a 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -802,7 +802,7 @@ def run( npy_positions = from_openmm(hybrid_factory.hybrid_positions).to("nanometer").m np.savez(positions_outfile, npy_positions) - uni_results_dict = { + unit_results_dict = { "system": system_outfile, "positions": positions_outfile, "pdb_structure": self.shared_basepath / settings["output_settings"].output_structure, From c4938709390be2b2b28f177c950b0309666ed824 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:43:01 -0500 Subject: [PATCH 40/91] more typos --- openfe/protocols/openmm_rfe/hybridtop_protocols.py | 6 +++--- openfe/protocols/openmm_rfe/hybridtop_units.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index 6223b9fac..abc2a6b68 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -619,7 +619,7 @@ def _create( simulation = HybridTopologyMultiStateSimulationUnit( protocol=self, - setup_result=setup, + setup_results=setup, generation=0, repeat_id=repeat_id, name=( @@ -630,8 +630,8 @@ def _create( analysis = HybridTopologyMultiStateAnalysisUnit( protocol=self, - setup_result=setup, - simulation_result=simulation, + setup_results=setup, + simulation_results=simulation, repeat_id=repeat_id, name=( f"HybridTopology Analysis: {Anames} to {Bnames} " diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 97c28da2a..956ad1339 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1514,8 +1514,8 @@ def _execute( self, ctx: gufe.Context, *, - setup_result, - simulation_result, + setup_results, + simulation_results, **inputs, ) -> dict[str, Any]: log_system_probe(logging.INFO, paths=[ctx.scratch]) From a90e926e9a43a357313f4918e00e978fcd32736c Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:44:21 -0500 Subject: [PATCH 41/91] another typo --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 956ad1339..3594f6ede 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1295,7 +1295,7 @@ def _execute( # Get the relevant inputs system = deserialize(setup_results.outputs["system"]) - positions = to_openmm(np.load(setup_results.outputs["positions"] * offunit.nm)) + positions = to_openmm(np.load(setup_results.outputs["positions"]) * offunit.nm) selection_indices = setup_results.outputs["selection_indices"] # Run the unit From a28c2b9800a44da650d608fa9ed7f490f5c892ed Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:48:23 -0500 Subject: [PATCH 42/91] switch from npz to npy for positions --- openfe/protocols/openmm_rfe/hybridtop_units.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 3594f6ede..ca31d9963 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -798,9 +798,9 @@ def run( serialize(hybrid_system, system_outfile) # Positions - positions_outfile = self.shared_basepath / "hybrid_positions.npz" + positions_outfile = self.shared_basepath / "hybrid_positions.npy" npy_positions = from_openmm(hybrid_factory.hybrid_positions).to("nanometer").m - np.savez(positions_outfile, npy_positions) + np.save(positions_outfile, npy_positions) unit_results_dict = { "system": system_outfile, From 3500f87787bfa6c6e1e65a4bfa929ca2f449d2da Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:50:30 -0500 Subject: [PATCH 43/91] Fix some typos --- openfe/protocols/openmm_rfe/hybridtop_units.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index ca31d9963..a6afde6ca 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1222,14 +1222,14 @@ def run( integrator = self._get_integrator( integrator_settings=settings["integrator_settings"], simulation_settings=settings["simulation_settings"], - system=hybrid_system + system=system ) try: # Get sampler sampler = self._get_sampler( - system=hybrid_system, - positions=hybrid_factory.hybrid_positions, + system=system, + positions=positions, lambdas=lambdas, integrator=integrator, reporter=reporter, @@ -1280,7 +1280,7 @@ def run( return {"debug": { "sampler": sampler, - "hybrid_factory": hybrid_factory, + "integrator": integrator, } } From 15d81e7b291782bcfae81b10315f63c78d980efb Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:51:56 -0500 Subject: [PATCH 44/91] more typos --- openfe/protocols/openmm_rfe/hybridtop_units.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index a6afde6ca..2a7845220 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1520,9 +1520,9 @@ def _execute( ) -> dict[str, Any]: log_system_probe(logging.INFO, paths=[ctx.scratch]) - pdb_file = setup_result.outputs["pdb_structure"] - trajectory = simulation_result.outputs["nc"] - checkpoint = simulation_result.outputs["checkpoint"] + pdb_file = setup_results.outputs["pdb_structure"] + trajectory = simulation_results.outputs["nc"] + checkpoint = simulation_results.outputs["checkpoint"] outputs = self.run( pdb_file=pdb_file, From bbe50b1cebcc8311c6b29680ca9157b36ecbc140 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:54:08 -0500 Subject: [PATCH 45/91] more fixes --- openfe/protocols/openmm_rfe/hybridtop_units.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 2a7845220..760a4ad64 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1487,7 +1487,7 @@ def run( if verbose: self.logger.info("Analyzing energies") - energy_analysis = self._analyze_energies( + energy_analysis = self._analyze_multistate_energies( trajectory=trajectory, checkpoint=checkpoint, sampler_method=settings["simulation_settings"].sampler_method.lower(), @@ -1499,7 +1499,7 @@ def run( if verbose: self.logger.info("Analyzing structural outputs") - structural_analysis = self.structural_analysis( + structural_analysis = self._structural_analysis( pdb_file=pdb_file, trj_file=trajectory, output_directory=self.shared_basepath, From ab611e0e53978292d83bd51fb3b7fd44d0b57e15 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:56:12 -0500 Subject: [PATCH 46/91] another typo in the analysis section --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 760a4ad64..78de4e69f 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1347,7 +1347,7 @@ def _analyze_multistate_energies( open_mode="r", ) - analyzer = multistate_analysis.MultiStateEquilFEAnalysis( + analyzer = multistate_analysis.MultistateEquilFEAnalysis( reporter=reporter, sampling_method=settings["simulation_settings"].sampler_method.lower(), result_units=offunit.kilocalorie_per_mole, From 7fa8e86b3090ab4f4beb4283d14609231f4ded72 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 29 Dec 2025 23:59:52 -0500 Subject: [PATCH 47/91] fix settings passing for energy analysis --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 78de4e69f..fbccddba8 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1349,7 +1349,7 @@ def _analyze_multistate_energies( analyzer = multistate_analysis.MultistateEquilFEAnalysis( reporter=reporter, - sampling_method=settings["simulation_settings"].sampler_method.lower(), + sampling_method=sampler_method, result_units=offunit.kilocalorie_per_mole, ) From 2acbc040baa33ffa2541fc9aa6be769c4f19ba72 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 00:09:25 -0500 Subject: [PATCH 48/91] Fix incorrect self call --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index fbccddba8..957aa1f39 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1355,7 +1355,7 @@ def _analyze_multistate_energies( # Only create plots when not doing a dry run if not dry: - analyzer.plot(filepath=self.shared_basepath, filename_prefix="") + analyzer.plot(filepath=output_directory, filename_prefix="") analyzer.close() reporter.close() From a3f68f94a812517edcd637362a91824adfb099a8 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 00:10:29 -0500 Subject: [PATCH 49/91] fix typo --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 957aa1f39..1d7cfee51 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1507,7 +1507,7 @@ def run( ) # Return relevant things - outputs = energy_analysis | structural_analysisa + outputs = energy_analysis | structural_analysis return outputs def _execute( From 8723ed598303691d823f930c4e0f43b7a8429aa8 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 00:11:57 -0500 Subject: [PATCH 50/91] Add missing generation key --- openfe/protocols/openmm_rfe/hybridtop_protocols.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index abc2a6b68..4904073cf 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -632,6 +632,7 @@ def _create( protocol=self, setup_results=setup, simulation_results=simulation, + generation=0, repeat_id=repeat_id, name=( f"HybridTopology Analysis: {Anames} to {Bnames} " From dd8c0785474853493f01e6362ed7cbe6ee9b488a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 00:13:28 -0500 Subject: [PATCH 51/91] fix tests a bit --- openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py index a8875142d..61ee4adc6 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py @@ -67,13 +67,13 @@ def test_openmm_run_engine( assert pathlib.Path(unit_shared).is_dir() # Check the checkpoint file exists - checkpoint = pur.outputs["last_checkpoint"] + checkpoint = pur.outputs["checkpoint"] assert checkpoint == "checkpoint.chk" assert (unit_shared / checkpoint).exists() # Check the nc simulation file exists # TODO: assert the number of frames - nc = pur.outputs["nc"] + nc = pur.outputs["trajectory"] assert nc == unit_shared / "simulation.nc" assert nc.exists() From 08db4136d61af98ae42edbf5056c6cc1ad13e4eb Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 00:15:12 -0500 Subject: [PATCH 52/91] stick to analysis units only --- openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py index 61ee4adc6..719214396 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py @@ -62,6 +62,9 @@ def test_openmm_run_engine( assert r.ok() for pur in r.protocol_unit_results: + if "Analysis" not in pur.name: + continue + unit_shared = tmpdir / f"shared_{pur.source_key}_attempt_0" assert unit_shared.exists() assert pathlib.Path(unit_shared).is_dir() From fe6482066f76b197c33a41ed82764da7b9fdf7ea Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 09:22:13 -0500 Subject: [PATCH 53/91] Update results to have full path to checkpoint file --- .../openmm_rfe/hybridtop_unit_results.py | 2 +- .../protocols/openmm_rfe/hybridtop_units.py | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_unit_results.py b/openfe/protocols/openmm_rfe/hybridtop_unit_results.py index 88e641c92..596a1bf9f 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_unit_results.py +++ b/openfe/protocols/openmm_rfe/hybridtop_unit_results.py @@ -202,7 +202,7 @@ def is_file(filename: str): for pus in self.data.values(): nc = is_file(pus[0].outputs["trajectory"]) dir_path = nc.parents[0] - chk = is_file(dir_path / pus[0].outputs["checkpoint"]).name + chk = is_file(pus[0].outputs["checkpoint"]).name reporter = multistate.MultiStateReporter( storage=nc, checkpoint_storage=chk, open_mode="r" ) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 1d7cfee51..f1c7c7cf7 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -852,6 +852,8 @@ def _get_reporter( Settings defining out the simulation should be run. """ nc = self.shared_basepath / output_settings.output_filename + # The checkpoint file in openmmtools is taken as a file relative + # to the location of the nc file, so you only want the filename chk = output_settings.checkpoint_storage_filename if output_settings.positions_write_frequency is not None: @@ -1272,9 +1274,7 @@ def run( if not dry: # pragma: no-cover return { "nc": self.shared_basepath / settings["output_settings"].output_filename, - # TODO: historically checkpoint was provided as just the file - # Maybe we should switch to the full path? - "checkpoint": settings["output_settings"].checkpoint_storage_filename, + "checkpoint": self.shared_basepath / settings["output_settings"].checkpoint_storage_filename, } else: return {"debug": @@ -1319,7 +1319,7 @@ class HybridTopologyMultiStateAnalysisUnit(gufe.ProtocolUnit, HybridTopologyUnit @staticmethod def _analyze_multistate_energies( trajectory: pathlib.Path, - checkpoint: str, + checkpoint: pathlib.Path, sampler_method: str, output_directory: pathlib.Path, dry: bool, @@ -1331,7 +1331,7 @@ def _analyze_multistate_energies( ---------- trajectory : pathlib.Path Path to the NetCDF trajectory file. - checkpoint : str + checkpoint : pathlib.Path The name of the checkpoint file. Note this is relative in path to the trajectory file. sampler_method : str @@ -1343,7 +1343,9 @@ def _analyze_multistate_energies( """ reporter = multistate.MultiStateReporter( storage=trajectory, - checkpoint_storage=checkpoint, + # Note: openmmtools only wants the name of the checkpoint + # file, it assumes it to be in the same place as the trajectory + checkpoint_storage=checkpoint.name, open_mode="r", ) @@ -1437,7 +1439,7 @@ def run( *, pdb_file: pathlib.Path, trajectory: pathlib.Path, - checkpoint: str, + checkpoint: pathlib.Path, dry: bool = False, verbose: bool = True, scratch_basepath: pathlib.Path | None = None, @@ -1452,8 +1454,8 @@ def run( Path to the PDB file representing the subsampled structure. trajectory : pathlib.Path Path to the MultiStateReporter generated NetCDF file. - checkpoint : str - Name of the checkpoint file generated by MultiStateReporter. + checkpoint : pathlib.Path + Path to the checkpoint file generated by MultiStateReporter. dry : bool Do a dry run of the calculation, creating all necessary hybrid system components (topology, system, sampler, etc...) but without From a98c799af2393f1e1f7b3985f1845579c38b7a38 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 09:46:24 -0500 Subject: [PATCH 54/91] update module name --- openfe/protocols/openmm_rfe/__init__.py | 2 +- openfe/protocols/openmm_rfe/equil_rfe_methods.py | 2 +- ...{hybridtop_unit_results.py => hybridtop_protocol_results.py} | 0 openfe/protocols/openmm_rfe/hybridtop_protocols.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename openfe/protocols/openmm_rfe/{hybridtop_unit_results.py => hybridtop_protocol_results.py} (100%) diff --git a/openfe/protocols/openmm_rfe/__init__.py b/openfe/protocols/openmm_rfe/__init__.py index 137b641c0..c5b59b543 100644 --- a/openfe/protocols/openmm_rfe/__init__.py +++ b/openfe/protocols/openmm_rfe/__init__.py @@ -3,6 +3,6 @@ from . import _rfe_utils from .hybridtop_protocols import RelativeHybridTopologyProtocol -from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult +from .hybridtop_protocol_results import RelativeHybridTopologyProtocolResult from .hybridtop_units import RelativeHybridTopologyProtocolUnit from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index 14d3c3eb5..0b3a72cc2 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -17,6 +17,6 @@ """ from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings -from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult +from .hybridtop_protocol_results import RelativeHybridTopologyProtocolResult from .hybridtop_units import RelativeHybridTopologyProtocolUnit from .hybridtop_protocols import RelativeHybridTopologyProtocol diff --git a/openfe/protocols/openmm_rfe/hybridtop_unit_results.py b/openfe/protocols/openmm_rfe/hybridtop_protocol_results.py similarity index 100% rename from openfe/protocols/openmm_rfe/hybridtop_unit_results.py rename to openfe/protocols/openmm_rfe/hybridtop_protocol_results.py diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index b2bcc1ab1..41c488d56 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -48,7 +48,7 @@ OpenMMSolvationSettings, RelativeHybridTopologyProtocolSettings, ) -from .hybridtop_unit_results import RelativeHybridTopologyProtocolResult +from .hybridtop_protocol_results import RelativeHybridTopologyProtocolResult from .hybridtop_units import RelativeHybridTopologyProtocolUnit From 3e201e1a85f68ef8317e1b13c29516251bf60590 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 14:56:36 +0000 Subject: [PATCH 55/91] fix slow tests --- .../openmm_rfe/test_hybrid_top_slow.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py index 719214396..f658e9207 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_slow.py @@ -61,27 +61,37 @@ def test_openmm_run_engine( r = execute_DAG(dag, shared_basedir=cwd, scratch_basedir=cwd, keep_shared=True) assert r.ok() + + # Get the path to the simulation unit shared + for pur in r.protocol_unit_results: + if "Simulation" in pur.name: + sim_shared = tmpdir / f"shared_{pur.source_key}_attempt_0" + assert sim_shared.exists() + assert pathlib.Path(sim_shared).is_dir() + for pur in r.protocol_unit_results: if "Analysis" not in pur.name: continue - unit_shared = tmpdir / f"shared_{pur.source_key}_attempt_0" - assert unit_shared.exists() - assert pathlib.Path(unit_shared).is_dir() + analysis_shared = tmpdir / f"shared_{pur.source_key}_attempt_0" + assert analysis_shared.exists() + assert pathlib.Path(analysis_shared).is_dir() # Check the checkpoint file exists checkpoint = pur.outputs["checkpoint"] - assert checkpoint == "checkpoint.chk" - assert (unit_shared / checkpoint).exists() + assert checkpoint.name == "checkpoint.chk" + assert checkpoint == sim_shared / "checkpoint.chk" + assert checkpoint.exists() # Check the nc simulation file exists # TODO: assert the number of frames nc = pur.outputs["trajectory"] - assert nc == unit_shared / "simulation.nc" + assert nc.name == "simulation.nc" + assert nc == sim_shared / "simulation.nc" assert nc.exists() # Check structural analysis contents - structural_analysis_file = unit_shared / "structural_analysis.npz" + structural_analysis_file = analysis_shared / "structural_analysis.npz" assert (structural_analysis_file).exists() assert pur.outputs["structural_analysis"] == structural_analysis_file From e0bac14853adf36df99858af6fd4b8242cefa23a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 16:35:52 -0500 Subject: [PATCH 56/91] fix various tests --- .../protocols/openmm_rfe/hybridtop_units.py | 7 +- .../openmm_rfe/test_hybrid_top_protocol.py | 362 ++++++++++++++---- 2 files changed, 289 insertions(+), 80 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index f1c7c7cf7..09843e303 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -811,8 +811,11 @@ def run( if dry: unit_results_dict |= { + # Adding unserialized objects so we can directly use them + # to chain units in tests "hybrid_factory": hybrid_factory, "hybrid_system": hybrid_system, + "hybrid_positions": hybrid_factory.hybrid_positions, } return unit_results_dict @@ -1277,12 +1280,10 @@ def run( "checkpoint": self.shared_basepath / settings["output_settings"].checkpoint_storage_filename, } else: - return {"debug": - { + return { "sampler": sampler, "integrator": integrator, } - } def _execute( self, diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 53809c62d..8c6ea711a 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -29,6 +29,11 @@ import openfe from openfe import setup from openfe.protocols import openmm_rfe +from openfe.protocols.openmm_rfe.hybridtop_units import ( + HybridTopologySetupUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologyMultiStateAnalysisUnit, +) from openfe.protocols.openmm_rfe._rfe_utils import topologyhelpers from openfe.protocols.openmm_utils import omm_compute, system_creation from openfe.protocols.openmm_utils.charge_generation import ( @@ -38,6 +43,16 @@ ) +def _get_units(protocol_units, unit_type): + """ + Helper method to extract setup units + """ + return [ + pu for pu in protocol_units + if isinstance(pu, unit_type) + ] + + @pytest.fixture() def vac_settings(): settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() @@ -163,6 +178,46 @@ def test_serialize_protocol(): assert protocol == ret +def test_repeat_units(benzene_system, toluene_system, benzene_to_toluene_mapping): + settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() + + protocol = openmm_rfe.RelativeHybridTopologyProtocol( + settings=settings, + ) + + dag = protocol.create( + stateA=benzene_system, + stateB=toluene_system, + mapping=benzene_to_toluene_mapping, + ) + + # 9 protocol units, 3 per repeat + pus = list(dag.protocol_units) + assert len(pus) == 9 + + # Aggregate some info for each repeat + setup = [] + simulation = [] + analysis = [] + + setup = _get_units(pus, HybridTopologySetupUnit) + simulation = _get_units(pus, HybridTopologyMultiStateSimulationUnit) + analysis = _get_units(pus, HybridTopologyMultiStateAnalysisUnit) + + # Should be 3 of everything + assert len(setup) == len(simulation) == len(analysis) == 3 + + # Check that the dag chain is correct + for analysis_pu in analysis: + repeat_id = analysis_pu.inputs["repeat_id"] + setup_pu = [s for s in setup if s.inputs["repeat_id"] == repeat_id][0] + sim_pu = [s for s in simulation if s.inputs["repeat_id"] == repeat_id][0] + + assert analysis_pu.inputs["setup_results"] == setup_pu + assert analysis_pu.inputs["simulation_results"] == sim_pu + assert sim_pu.inputs["setup_results"] == setup_pu + + def test_create_independent_repeat_ids(benzene_system, toluene_system, benzene_to_toluene_mapping): # if we create two dags each with 3 repeats, they should give 6 repeat_ids # this allows multiple DAGs in flight for one Transformation that don't clash on gather @@ -183,7 +238,6 @@ def test_create_independent_repeat_ids(benzene_system, toluene_system, benzene_t ) repeat_ids = set() - u: openmm_rfe.RelativeHybridTopologyProtocolUnit for u in dag1.protocol_units: repeat_ids.add(u.inputs["repeat_id"]) for u in dag2.protocol_units: @@ -193,7 +247,7 @@ def test_create_independent_repeat_ids(benzene_system, toluene_system, benzene_t @pytest.mark.parametrize("method", ["repex", "sams", "independent", "InDePeNdENT"]) -def test_dry_run_default_vacuum( +def test_setup_dry_sim_default_vacuum( benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, @@ -214,17 +268,27 @@ def test_dry_run_default_vacuum( stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] + dag_sim_unit = _get_units(dag.protocol_units, HybridTopologyMultiStateSimulationUnit)[0] with tmpdir.as_cwd(): - debug = dag_unit.run(dry=True)["debug"] - sampler = debug["sampler"] + # Manually run the units + setup_results = dag_setup_unit.run(dry=True) + + sim_results = dag_sim_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + dry=True + ) + + sampler = sim_results["sampler"] assert isinstance(sampler, MultiStateSampler) assert not sampler.is_periodic assert sampler._thermodynamic_states[0].barostat is None # Check hybrid OMM and MDTtraj Topologies - htf = debug["hybrid_factory"] + htf = setup_results["hybrid_factory"] # 16 atoms: # 11 common atoms, 1 extra hydrogen in benzene, 4 extra in toluene # 12 bonds in benzene + 4 extra toluene bonds @@ -263,9 +327,17 @@ def test_dry_run_default_vacuum( ) -def test_dry_run_gaff_vacuum( - benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, vac_settings, tmpdir +def test_setup_gaff_vacuum( + benzene_vacuum_system, + toluene_vacuum_system, + benzene_to_toluene_mapping, + vac_settings, + tmpdir ): + """ + Simple dry run of the setup unit to make sure that parameterisation + will work with gaff. + """ vac_settings.forcefield_settings.small_molecule_forcefield = "gaff-2.11" protocol = openmm_rfe.RelativeHybridTopologyProtocol( @@ -278,9 +350,10 @@ def test_dry_run_gaff_vacuum( stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, ) - unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] + with tmpdir.as_cwd(): - _ = unit.run(dry=True)["debug"]["sampler"] + _ = dag_setup_unit.run(dry=True) @pytest.mark.slow @@ -292,7 +365,7 @@ def test_dry_many_molecules_solvent( tmpdir, ): """ - A basic test flushing "will it work if you pass multiple molecules" + A basic setup test flushing "will it work if you pass multiple molecules" """ protocol = openmm_rfe.RelativeHybridTopologyProtocol( settings=solv_settings, @@ -304,10 +377,10 @@ def test_dry_many_molecules_solvent( stateB=toluene_many_solv_system, mapping=benzene_to_toluene_mapping, ) - unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] with tmpdir.as_cwd(): - sampler = unit.run(dry=True)["debug"]["sampler"] + _ = dag_setup_unit.run(dry=True) BENZ = """\ @@ -376,7 +449,7 @@ def test_dry_many_molecules_solvent( """ -def test_dry_core_element_change(vac_settings, tmpdir): +def test_setup_core_element_change(vac_settings, tmpdir): benz = openfe.SmallMoleculeComponent(Chem.MolFromMolBlock(BENZ, removeHs=False)) pyr = openfe.SmallMoleculeComponent(Chem.MolFromMolBlock(PYRIDINE, removeHs=False)) @@ -392,17 +465,20 @@ def test_dry_core_element_change(vac_settings, tmpdir): mapping=mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] - system = sampler._hybrid_system + results = dag_setup_unit.run(dry=True) + system = results["hybrid_system"] assert system.getNumParticles() == 12 # Average mass between nitrogen and carbon assert system.getParticleMass(1) == 12.0127235 * omm_unit.amu # Get out the CustomNonbondedForce - cnf = [f for f in system.getForces() if f.__class__.__name__ == "CustomNonbondedForce"][0] + cnf = [ + f for f in system.getForces() + if f.__class__.__name__ == "CustomNonbondedForce" + ][0] # there should be no new unique atoms assert cnf.getInteractionGroupParameters(6) == [(), ()] # there should be one old unique atom (spare hydrogen from the benzene) @@ -425,10 +501,21 @@ def test_dry_run_ligand( stateB=toluene_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] + dag_sim_unit = _get_units(dag.protocol_units, HybridTopologyMultiStateSimulationUnit)[0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] + # Manually run the units + setup_results = dag_setup_unit.run(dry=True) + + sim_results = dag_sim_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + dry=True + ) + + sampler = sim_results["sampler"] assert isinstance(sampler, MultiStateSampler) assert sampler.is_periodic assert isinstance(sampler._thermodynamic_states[0].barostat, MonteCarloBarostat) @@ -489,18 +576,21 @@ def tip4p_hybrid_factory( stateB=toluene_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = [ + pu for pu in dag.protocol_units + if isinstance(pu, HybridTopologySetupUnit) + ][0] shared_temp = tmp_path_factory.mktemp("tip4p_shared") scratch_temp = tmp_path_factory.mktemp("tip4p_scratch") - dag_unit_result = dag_unit.run( + dag_unit_setup_result = dag_setup_unit.run( dry=True, scratch_basepath=scratch_temp, shared_basepath=shared_temp, ) - return dag_unit_result["debug"]["hybrid_factory"] + return dag_unit_setup_result["hybrid_factory"] def test_tip4p_particle_count(tip4p_hybrid_factory): @@ -585,7 +675,7 @@ def test_tip4p_check_vsite_parameters(tip4p_hybrid_factory): 0.9 * unit.nanometer, ], ) -def test_dry_run_ligand_system_cutoff( +def test_setup_ligand_system_cutoff( cutoff, benzene_system, toluene_system, benzene_to_toluene_mapping, solv_settings, tmpdir ): """ @@ -597,16 +687,20 @@ def test_dry_run_ligand_system_cutoff( protocol = openmm_rfe.RelativeHybridTopologyProtocol( settings=solv_settings, ) + dag = protocol.create( stateA=benzene_system, stateB=toluene_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + + dag_setup_unit = [ + pu for pu in dag.protocol_units + if isinstance(pu, HybridTopologySetupUnit) + ][0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] - hs = sampler._hybrid_system + hs = dag_setup_unit.run(dry=True)["hybrid_system"] nbfs = [ f @@ -646,7 +740,7 @@ def test_dry_run_ligand_system_cutoff( ), ], ) -def test_dry_run_charge_backends( +def test_setup_charge_backends( CN_molecule, tmpdir, method, backend, ref_key, vac_settings, am1bcc_ref_charges ): vac_settings.partial_charge_settings.partial_charge_method = method @@ -670,13 +764,15 @@ def test_dry_run_charge_backends( dag = protocol.create(stateA=systemA, stateB=systemB, mapping=mapping) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = [ + pu for pu in dag.protocol_units + if isinstance(pu, HybridTopologySetupUnit) + ][0] with tmpdir.as_cwd(): - debug = dag_unit.run(dry=True)["debug"] - sampler = debug["sampler"] - htf = debug["hybrid_factory"] - hybrid_system = sampler._hybrid_system + results = dag_setup_unit.run(dry=True) + htf = results["hybrid_factory"] + hybrid_system = results["hybrid_system"] # get the standard nonbonded force nonbond = [f for f in hybrid_system.getForces() if isinstance(f, NonbondedForce)] @@ -710,7 +806,7 @@ def test_dry_run_charge_backends( np.testing.assert_allclose(c, ref, rtol=1e-4) -def test_dry_run_same_mol_different_charges( +def test_setup_same_mol_different_charges( benzene_modifications, vac_settings, tmpdir @@ -742,13 +838,15 @@ def test_dry_run_same_mol_different_charges( stateB=openfe.ChemicalSystem({"l": stateB_mol}), mapping=mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = [ + pu for pu in dag.protocol_units + if isinstance(pu, HybridTopologySetupUnit) + ][0] with tmpdir.as_cwd(): - debug = dag_unit.run(dry=True)["debug"] - sampler = debug["sampler"] - htf = debug["hybrid_factory"] - hybrid_system = sampler._hybrid_system + results = dag_setup_unit.run(dry=True) + htf = results["hybrid_factory"] + hybrid_system = results["hybrid_system"] # get the standard nonbonded force nonbond = [f for f in hybrid_system.getForces() if isinstance(f, NonbondedForce)] @@ -774,7 +872,7 @@ def test_dry_run_same_mol_different_charges( @pytest.mark.flaky(reruns=3) # bad minimisation can happen -def test_dry_run_user_charges(benzene_modifications, vac_settings, tmpdir): +def test_setup_user_charges(benzene_modifications, vac_settings, tmpdir): """ Create a hybrid system with a set of fictitious user supplied charges and ensure that they are properly passed through to the constructed @@ -828,13 +926,15 @@ def check_propchgs(smc, charge_array): stateB=openfe.ChemicalSystem({"l": toluene_smc}), mapping=mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = [ + pu for pu in dag.protocol_units + if isinstance(pu, HybridTopologySetupUnit) + ][0] with tmpdir.as_cwd(): - debug = dag_unit.run(dry=True)["debug"] - sampler = debug["sampler"] - htf = debug["hybrid_factory"] - hybrid_system = sampler._hybrid_system + results = dag_setup_unit.run(dry=True) + htf = results["hybrid_factory"] + hybrid_system = results["hybrid_system"] # get the standard nonbonded force nonbond = [f for f in hybrid_system.getForces() if isinstance(f, NonbondedForce)] @@ -922,15 +1022,23 @@ def test_virtual_sites_no_reassign( stateB=toluene_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] + dag_sim_unit = _get_units(dag.protocol_units, HybridTopologyMultiStateSimulationUnit)[0] with tmpdir.as_cwd(): + # Manually run the units + setup_results = dag_setup_unit.run(dry=True) errmsg = "Simulations with virtual sites without velocity" with pytest.raises(ValueError, match=errmsg): - dag_unit.run(dry=True) + sim_results = dag_sim_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + dry=True + ) -def test_dodecahdron_ligand_box( +def test_setup_dodecahdron_ligand_box( benzene_system, toluene_system, benzene_to_toluene_mapping, solv_settings, tmpdir ): """ @@ -945,11 +1053,13 @@ def test_dodecahdron_ligand_box( stateB=toluene_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = [ + pu for pu in dag.protocol_units + if isinstance(pu, HybridTopologySetupUnit) + ][0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] - hs = sampler._hybrid_system + hs = dag_setup_unit.run(dry=True)["hybrid_system"] vectors = hs.getDefaultPeriodicBoxVectors() @@ -1014,7 +1124,7 @@ def test_lambda_schedule(windows): assert len(lambdas.lambda_schedule) == windows -def test_ligand_overlap_warning( +def test_setup_ligand_overlap_warning( benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, vac_settings, tmpdir ): protocol = openmm_rfe.RelativeHybridTopologyProtocol( @@ -1046,9 +1156,12 @@ def test_ligand_overlap_warning( stateB=toluene_vacuum_system, mapping=mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = [ + pu for pu in dag.protocol_units + if isinstance(pu, HybridTopologySetupUnit) + ][0] with tmpdir.as_cwd(): - dag_unit.run(dry=True) + dag_setup_unit.run(dry=True) @pytest.fixture @@ -1067,28 +1180,111 @@ def solvent_protocol_dag(benzene_system, toluene_system, benzene_to_toluene_mapp def test_unit_tagging(solvent_protocol_dag, tmpdir): # test that executing the Units includes correct generation and repeat info dag_units = solvent_protocol_dag.protocol_units - with mock.patch( - "openfe.protocols.openmm_rfe.equil_rfe_methods.RelativeHybridTopologyProtocolUnit.run", - return_value={"nc": "file.nc", "last_checkpoint": "chk.nc"}, + with ( + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologySetupUnit.run", + return_value={ + "system": Path("system.xml.bz2"), + "positions": Path("positions.npy"), + "pdb_structure": Path("hybrid_system.pdb"), + "selection_indices": np.zeros(100), + } + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.np.load", + return_value=np.zeros(100), + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.deserialize", + return_value={ + "item": "foo", + } + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateSimulationUnit.run", + return_value={ + "nc": Path("file.nc"), + "checkpoint": Path("chk.chk"), + } + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateAnalysisUnit.run", + return_value={ + "foo": "bar", + } + ), ): - results = [] - for u in dag_units: - ret = u.execute(context=gufe.Context(tmpdir, tmpdir)) - results.append(ret) + setup_results = {} + sim_results = {} + analysis_results = {} + + setup_units = _get_units(dag_units, HybridTopologySetupUnit) + sim_units = _get_units(dag_units, HybridTopologyMultiStateSimulationUnit) + analysis_units = _get_units(dag_units, HybridTopologyMultiStateAnalysisUnit) + + for u in setup_units: + rid = u.inputs["repeat_id"] + setup_results[rid] = u.execute(context=gufe.Context(tmpdir, tmpdir)) + + for u in sim_units: + rid = u.inputs["repeat_id"] + sim_results[rid] = u.execute( + context=gufe.Context(tmpdir, tmpdir), + setup_results=setup_results[rid] + ) + + for u in analysis_units: + rid = u.inputs["repeat_id"] + analysis_results[rid] = u.execute( + context=gufe.Context(tmpdir, tmpdir), + setup_results=setup_results[rid], + simulation_results=sim_results[rid] + ) repeats = set() - for ret in results: - assert isinstance(ret, gufe.ProtocolUnitResult) - assert ret.outputs["generation"] == 0 - repeats.add(ret.outputs["repeat_id"]) + for results in [setup_results, sim_results, analysis_results]: + for ret in results.values(): + assert isinstance(ret, gufe.ProtocolUnitResult) + assert ret.outputs["generation"] == 0 + # repeats are random ints, so check we got 3 individual numbers - assert len(repeats) == 3 + assert len(setup_results) == len(sim_results) == len(analysis_results) == 3 def test_gather(solvent_protocol_dag, tmpdir): # check .gather behaves as expected - with mock.patch( - "openfe.protocols.openmm_rfe.equil_rfe_methods.RelativeHybridTopologyProtocolUnit.run", - return_value={"nc": "file.nc", "last_checkpoint": "chk.nc"}, + with ( + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologySetupUnit.run", + return_value={ + "system": Path("system.xml.bz2"), + "positions": Path("positions.npy"), + "pdb_structure": Path("hybrid_system.pdb"), + "selection_indices": np.zeros(100), + } + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.np.load", + return_value=np.zeros(100), + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.deserialize", + return_value={ + "item": "foo", + } + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateSimulationUnit.run", + return_value={ + "nc": Path("file.nc"), + "checkpoint": Path("chk.chk"), + } + ), + mock.patch( + "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateAnalysisUnit.run", + return_value={ + "foo": "bar", + } + ), ): dagres = gufe.protocols.execute_DAG( solvent_protocol_dag, @@ -1895,11 +2091,12 @@ def test_dry_run_alchemwater_solvent(benzene_to_benzoic_mapping, solv_settings, stateB=stateB_system, mapping=benzene_to_benzoic_mapping, ) - unit = list(dag.protocol_units)[0] + + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] with tmpdir.as_cwd(): - debug = unit.run(dry=True)["debug"] - htf = debug["hybrid_factory"] + results = dag_setup_unit.run(dry=True) + htf = results["hybrid_factory"] _assert_total_charge(htf.hybrid_system, htf._atom_classes, 0, 0) assert len(htf._atom_classes["core_atoms"]) == 14 @@ -1978,11 +2175,11 @@ def test_dry_run_complex_alchemwater_totcharge( def test_structural_analysis_error(tmpdir): with tmpdir.as_cwd(): - ret = openmm_rfe.RelativeHybridTopologyProtocolUnit.structural_analysis( + ret = openmm_rfe.hybridtop_units.HybridTopologyMultiStateAnalysisUnit._structural_analysis( + Path("."), Path("."), Path("."), - 'foo', - 'bar' + True, ) assert "structural_analysis_error" in ret @@ -2022,10 +2219,21 @@ def test_dry_run_vacuum_write_frequency( stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] + dag_sim_unit = _get_units(dag.protocol_units, HybridTopologyMultiStateSimulationUnit)[0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] + # Manually run the units + setup_results = dag_setup_unit.run(dry=True) + + sim_results = dag_sim_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + dry=True + ) + + sampler = sim_results["sampler"] reporter = sampler._reporter if positions_write_frequency: assert reporter.position_interval == positions_write_frequency.m From 1c47f321822907b8c6dda782173ff4c8002cfe86 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 21:52:54 +0000 Subject: [PATCH 57/91] fix slow tests --- .../openmm_rfe/test_hybrid_top_protocol.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 8c6ea711a..f1ff16403 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -1053,10 +1053,7 @@ def test_setup_dodecahdron_ligand_box( stateB=toluene_system, mapping=benzene_to_toluene_mapping, ) - dag_setup_unit = [ - pu for pu in dag.protocol_units - if isinstance(pu, HybridTopologySetupUnit) - ][0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] with tmpdir.as_cwd(): hs = dag_setup_unit.run(dry=True)["hybrid_system"] @@ -1097,10 +1094,18 @@ def test_dry_run_complex( stateB=toluene_complex_system, mapping=benzene_to_toluene_mapping, ) - dag_unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] + dag_sim_unit = _get_units(dag.protocol_units, HybridTopologyMultiStateSimulationUnit)[0] with tmpdir.as_cwd(): - sampler = dag_unit.run(dry=True)["debug"]["sampler"] + setup_results = dag_setup_unit.run(dry=True) + sim_results = dag_sim_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + dry=True + ) + sampler = sim_results["sampler"] assert isinstance(sampler, MultiStateSampler) assert sampler.is_periodic assert isinstance(sampler._thermodynamic_states[0].barostat, MonteCarloBarostat) @@ -1595,13 +1600,13 @@ def tyk2_xml(tmp_path_factory): stateB=openfe.ChemicalSystem({"ligand": lig55}), mapping=mapping, ) - pu = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] tmp = tmp_path_factory.mktemp("xml_reg") - dryrun = pu.run(dry=True, shared_basepath=tmp) + setup_results = dag_setup_unit.run(dry=True, shared_basepath=tmp) - system = dryrun["debug"]["sampler"]._hybrid_system + system = setup_results["hybrid_system"] return ET.fromstring(XmlSerializer.serialize(system)) @@ -2117,7 +2122,7 @@ def test_dry_run_alchemwater_solvent(benzene_to_benzoic_mapping, solv_settings, ["benzoic_to_benzene_mapping", 0, 1, False, 11, 1, 3], ], ) -def test_dry_run_complex_alchemwater_totcharge( +def test_setup_complex_alchemwater_totcharge( mapping_name, chgA, chgB, @@ -2161,11 +2166,11 @@ def test_dry_run_complex_alchemwater_totcharge( stateB=stateB_system, mapping=mapping, ) - unit = list(dag.protocol_units)[0] + dag_setup_unit = _get_units(dag.protocol_units, HybridTopologySetupUnit)[0] with tmpdir.as_cwd(): - debug = unit.run(dry=True)["debug"] - htf = debug["hybrid_factory"] + setup_results = dag_setup_unit.run(dry=True) + htf = setup_results["hybrid_factory"] _assert_total_charge(htf.hybrid_system, htf._atom_classes, chgA, chgB) assert len(htf._atom_classes["core_atoms"]) == core_atoms From fefbefd82e03cefa502f1fc4ac43117ba0f6e4a3 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Tue, 30 Dec 2025 22:02:39 +0000 Subject: [PATCH 58/91] fix serialized tests --- .../openmm_rfe/RHFEProtocol_json_results.gz | Bin 340286 -> 26759 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/openfe/tests/data/openmm_rfe/RHFEProtocol_json_results.gz b/openfe/tests/data/openmm_rfe/RHFEProtocol_json_results.gz index e0bd6e8336234cf493fb7f7976a703ee5ebbce1f..e3a327d232b2097d56a9f47e9efef76cb98246ba 100644 GIT binary patch literal 26759 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ɍB$$NqE=&bmS+-+IKB!i_2v8qDSTv>B-kn3}_LlUbF1&v@5F~D0@NU z>x9l3Db);we5+`E=d5zZKD0&R1F{!APx3#Mco6fbkH-*{9rEv22sLIVdqoH}SQFu16N{W_YTb z%3c#aCtyoL2xGHnAy;Tq>z$kWtj~nd4=)0#2y-!?Wb_>fJ0Y%8@(8^3_Iedt;w$k2 zgS88*nrw2^g<#CTD!xq^{mLu)FUOEeTd@htT5O^*shlj8*3a5;bh2yfb);NlS^-aSTb~cKSQLS=9hibn|`e|uqefM0L<0sLd0B&*)&f>G``{G;UbSQ3cIMn5x zD)9^<@&~gO;fK5T@YAMPuD6mtkoleDrYa2!$Omlg?43efZ3-2PIStuRuH9YVx)oNp zAO#(TnVIX4!^N6(LWVy()|_UVn!p302Hi}71)Qc5N!wp!d)asYj+JCh5XIB6aA`8- za9TT#sZh!!hc>RnznVTR(-~;}(q$w;Jq-PJltFW^MQ>DQ9rs?dlModkonC3P#73y8 zS6~;&Yh1xY?Q*VrKw-n(Az|g6p3EvtOP=l^QiRI}{~Jxlc2` z3OytE#*Bl-9f!E~qB8t(57Z$A7j%q327S0f$q*y4{+LxX5G+8T&_t@3luLtjyRc*1ii_j`5t zp!{_l)`!r>;F2{+-D`#$ar@Wj_t%LoQBj1aQMvgpMgLVeYQigQNv)ZowBv6x#ZsJ; zXB3vEC`?0+Lr9_VK?^yo+Spz!iD5(nG{!alL$_eRzru>z&sd#O;au@-mSOxn~%y0-hJ((Ks^CVJxJ-J_GuMJJ^h};<*E4bzi>8e0pO4xw`uE^ z-{-cE_PVDL6_wB)q2@q-IuR>1>lj$959VGX|W8$d0(f| zPTt7aARR{lsRB^mh0R!>QhqJwJV<*_y$V7@L4JI9T&5=J(i8>+gKIl> zy_TBiuv>q$GpKvJa_cECHy)aOJyWq*w+V=H#V0WOafWEo zbIYykV=1f8jDjKR?+TYRJY`|R>wmz9wsXqlo~d&D z_4S3;6z5GM_I_<%UnMwt8)9=5Qg(tss9c|{Y@SWq6L(xvP6_yR$@@C?T==YXx%RI? z#;JX#HjT%E$El=0l)_cyo`0SWbD1zTna%TNEq?i_|7VY z`rI$FOv!M#Os<^al2z<-38vJCRwhl>McYBUzzE|Trj2E;w*xt59{Oh=)Pn~_(+lN= z3S_v0T`#)Wf{RB{n4270Nx_1q8A6Ly>ID5aV=mhEJLyczIC6AO+0 z((}Y2T7KfyiJp+;fh(tLliKwL3)I=6X4uK;Roa4eed`oKaA;Ke3EPt>Cw|y1Lp@^# z|Ilf225Urgi}k8YAmUeS?jV&{kEAGV4i6ujKaPobD*7P9;I}4!a2_IJj=;jwsTk=- z1XG)fRPLlo4WU;jo)%r6BR!WikAH8|W@bPRx}JX`A^)v|JKfu)zJop@d-lYblBysG zqJ{GK1C`u}ARHTIlo?3w(RVw26>mq@p?}_{a$0KdUp92arLqNuTp!7RXA}Z_Y0B={ zSUPN)!t6BnBGZ0Q7JYNxZ*~}An!(JPAdq;w=dhO@JCNvX+27QWXKg#UnT!+t^m8wS zkr*^Z?Afq^k9D;--LUGFY1eE*P_GbyPsr&>{kMc3!VX|j9o({Q)}Tl6`XKU4o_TTt zB!Y?%MuZ>~HOu^{iwbE5Um75^L(Rm#VCikIat)UQA1D@HnP-)$0AV0pOx{njg4TfT zG5at_#K3~Vougeh0!96P<@genV@DuD$&tR-FjiLC&O(t-1${)L;%GZT9u;ez?W@B^ z^N2GedGx>Qan9jsk7g(&IcHZk20+`To&QDW*ZweAl!V35#`xU)69h}IaV@x!+Z8+S z9J zorJ-B-zGmpOT{(GRatE9Z-M@aF_S6i4Vt@r?w_%=&$!D$X*gBQnBVpMra-&sj=IZl z`E_i{Z0NvcLuv@-heUq-7ar#$QXl}3%tf~1=F4Vj6q&2CA=`5n;2#zsk;I=oy7`*PMy2aSH z1ecS9(mpB9(J8U6I=vMw{xu_V%y$nT$H()NXe7Ejh7uFhi(cJs4-t_|S95n_K^UG` z^Ff0i&{WutuW}6sxvpuAld;ZRLX8|X=J3LwX)+(Bdw!>7G9q}72aqG&<@Qh$L&4|B z@@Pg7bkDiOSmz*+l>}gp{w#rysLyX=2C!gj^WWIjfY2$%Q!=HN@JZAPW0NU?*`oru zQn7}%VAylqOW5OFWN<;QRZd;He8}KGI5RDoZ4Iq$Jnu`ULZ3oU1dRE=R1*Q^ZqOgL zW#Yf|=_H|ydrg}pcQmSLDvIx>ItjW6KKO9j@h=jX(BYxalO$6U6Vj74F+K`^ z%U5REbsSdcDF!RY);Aflhf@l=<P!M=UXN$z3S1LPsowN-!PsvAX#^Re9Nw5?VI0(h+o9FO{r!*6<(b z$#2VlsC(lw_sw_q9}|%*zMx{a{QaiaK89n!VcqBKsg&-Lem@8f$QU$e6Z5oImj9wf zhiU9uV6$kGgY!YlQ}XYqqx!&tj9N;M*q3Ci5{nL-I&=A+YSeTfXFhRs0FTzaDq_{m zLILf+%!+wD_5YFxKjU$judBcNEJnJCgD-$59GmF{Z~M<1!X`f>6d#mZL&mc7{EyKO zJ4VB=)PNkDP&L>&*i-Z5TQ}od5|}LBh;AjN#ZGlgVZ<)W4jTs{*XN35>sGXKRyOSg zRQGJ_2FL_sCfRcJuhGx`j^s|_c{Hs_`F{=RQoes{R%ci)PS?OrTi+3Fud`m#BDTvK z{XRAdHe~zHjCM67HhknHO_gdk z1`441vAI1qh5P(iEduTF-p*~q3Kk_Ii(;;|)^m{n7gkt_?TRRz7WU@rmr;Ie`c1}H z{`p<2Y{OtI!Y;+PYJ+&vDa}K_$J~mM;0GT{!79zQ@Eqm1FY>?0%Ex9Te63$(K1s2H z3_Do%nH=WREc{qdJ2PP5mUqC=U`;O)A~P$pzvCI354UJzzs zwRGBu_m}PEamh&1M1=%i6Gg)|ix#ziYT3^bovV>ch7yFw`1vmrOR}OK+Qh%{)+*D4 z$Bf}~$?OJqS@NFvvEZko?`e_0y0ANX5)o3#9}X<4MLmluqUewj2e$SL*Ow~a@K+_Z zg^2DjI=P#a>*HeE<3LAAl4x?DKFXkltvS<&Ej+QQTJyk!1%UjWf_KI-ezc; z&!?#5ST@!&c6OnrZDP7=KX`*4;i4|3=&HJex+uqKqr9ozYQtGEle`KKG?Jo?ERl4P z0kD&}mb5mhSR?nJ6%HNO=Uk<;!sVV5CkB#gLemm~rjwofhzMnUQs->D0UoH8FlMY1 zyX|MZHE+fW-17_!0HR}^SM)uz6F}a~4TRlkNzX&6<*!8_n(FW;mP)u*W{vSl-HTNb zUw-h&uw#?L-yT@^CNbxSW85;8Jv~+xaVEXD0$V==(k5$<=2mKFKsh<9lkYY}b<0)W zAJNxO)Psuo){H_SHyT;@7G>|)DYFv;qf|jRvqLIBn$6}5G>ReI(yrZ02bye~lajVX z)PXaoZ2D6ob;X8Z*$gBZ#%W}yJwu|tcxT#@wTn$K8O`2g&qCHIi}XfA1-2SZY%!ypRzz);bTj{o`y_ukz`n= zK=Vg=-8Hxycd7(AlQp54{ICu>f~?m;OBt6enfNm9X|C0aLR~y%ji_@FOvdoxly^^y zZ##rO@OX&S#^h~nSld6A)K4M=U6^Zqmf`qCWrw^;_IsCIuxAg?N3>6<9dn#Qg4pX` zqO#Xr-5q-LADYIA_yGic-;bg43<&dxhiU$$m!I}ZZ>SKUKQ0FtFnao zm82k?iL0Z%=e;{j5sYzA>MM;8b0lk(e&-(cR+SH9^Auy$ckCt-_}1l?q(m`ggs!Fe za|k^hH9NXFudPNNbt1g|MnCeQ_6kY z1?lTPa|E-7#H^zhBpH4cvI{6qW8h}RoNUh#dBYcqG3lWyE-QPj-R})mcsu2gZEQ+Y z>`w@RdOCzX%f4xjHEs@0my%`3DDlPSuc4L1@~$}P8Y4IW^gnRzZG>RXwQWPfP~jK_ z5>lh8o%i1Ys6i+xRNBAdl$;B#24AQhI#Z}>nON8}6t0N^k9PiphHvIt=G0SXY2R9~ z{sPVX$S<295gvK-v!z&1d6v12D|1TwkuV#q2RCwvO$`Cb@B(z3R(BSqhc%I4Tz%nLUO-=no_SsjST|3N~^ zx)cMQDgSBkJJ&-)uuGUx9IHybr3mx8q4A`)u4h&hvrHhmGB?=@f=*-{u~|I%-;eHm z6$x74Namo^7d=W9@39uU&ySfElT^aB4e=s;%p9jtKis0okEO!a#D^x@3;* zf-!?Y{TIyU!=@v|f*p(Yr7G%0>{+FsBP=oMn3feYqBmzp<|~>vG&viF$<2fWH)&EI z6$NP1YM(pY{@|XQv%z3pHk1u31`alT{c|f$mu`<*t0=Y%e@og;@FqUWy+yM8a0D%U zH0>WVLF%uM9}GGJ17-I*tm*x~8!f*`bbk5OI%l~WwSyPIS8E(5V4=5%KK(tohU_j{ zoGP|bzTET;jksao)8{pHh8QIMXvboOs`BIpM$-nH#Gyxvk)~q zhMd%BV%(8|R`}Yql-h3*KxdPT2&E_JBhO4)&3Bg&zYC(N|3Xx5EjOG4Y1lc7Ths!* z@H_g{@s`~2pp3_s^q6~gan9nRIVmk0%Vto56s?abcP&!EI@oU0({rA}M&umK+rnEC z>qlZ&O&G0Co00@?-qQ-H61((sP(XL=mIyBJ!;JyO*a24Cp1n9+%7Q=waCoCl90l|TLWyHhAeunAsR zNG?-<>p+!?vXbkSL8nt8l{8@LLfn~X=w%cY>9>|m4g|(28q{0+}3CV@%%Ph4e2&n8*Q0Zm;VMZ z??`w|co~#chqddV_UYU-6NI}gqrfmRK4l)E1ny~GAhwyGn53FS$4>71S^e>%_V|{(ZiDiY*8R^?byC2n(~N6Aeu>tXQkm+e^d~w-;*c~CY1f01 z-wu)nLec^3df;VCHMHZuq-1X{)jX)UW}}2q6%iZsBGF_Cro^gT*F!G8 zbEH>@VAu?O{geEUKI$=QtwEBfpQTs5%zemB^A~Y+LKfjV+VL5AOzfEP?X|ytrg%Ll z=VYHd)w9Zls%!zByt^{|{1=JT+9m^LzuvCXgDnQEwL*WpW{o={>I|f7?5Fj0fGnJd z0U$wmYoo?@qxkQr`76D6x0X+d_JFL^R|X`&cOC~s7WGmvookrRk5Z)eH;`G3ERB#)*yRPXB7@=JsA zyT5+_w*qF!B*xP3Qri=+vJI+Ok9b~u7Y*atE94ghQV-yusf`yI#At~Jy-B@~nWQ76 zJznzDMj6w^CFw9WM;0kWPrjRI?80P?SP8581Rr9IkrGMrQo-RRV;f1Gm56$sB5tf> zCAH*g;pvn`ZPo?d!wgbv%h?v0D9FMHnKYe2so+_9hyly{Ur962%H0IOaB8u-a=ws_TDHlS`mzjTLQoC3+KlfKRdwR?c@dq}X3>EX40J(xf`*$rx zanHUC^35xi3nBBZSHB=+GpLoQplpy_5GI@j3($O*Mf!>U4Z&B>dAg4uJpVwo;ugib zv(S>vhA$UHcy0AoB^+=^&hvHC!(&qZ!+vq44{v#sQuc+P4)6ZG{g>B&o;)(s1QciJ zO%~8laOP|_A!I>%giM?FOuii_%UXO;c@zCbBM{y#WlnGQb_d|Z8?T|-NZ%y3rC)q| zhj94SsFhj47O2y*&??iYfu=(+GC;TG1!}~wQHp!=qH3Y2{=k;QgNcxuJx_rbDqhCX z5ax7OpM*5dng1qm(nzArtV}AhC0m2q70J+spw8vLf9I30!f)aU5=q(4f+d(et1A>H4F(*= z9Th@Hh;c7;XnC`gcLM*$I*p(xV80>pPmBvn$8jYvPr;C9128gIj)KcdVW@{+CXF9B zxKn$khiJ>WS68UmyB&yC<^+BLj+dyu_!P@4C!5G7oip?lWm*2~@psG5ObHtj!2E1Y zx6Bk_X0VTw&xL1?a_63b?;#b!yP~yBMcSiPj$UdSG2rL0%7(qpjHHwdr|zdB0H*y6 ziDb|qTSeXId1#Ge)En_;RL@C1K=Y2dNE8XI!)D64?5%v|a??j~B!a|3L~8~jWy1bR zn#r*8;slSN&b$LlpJ-#v<34i9Tj?7ddI`O$4@RxXSkoc2`Z|w>y$0x&zxlV%*@RQI z?)^3vHvuIJ&t0nUR3qr!mab>P_fW;EYt?I0nLT5zW{pdIj9qDR5BM#)35i7D8HLFRa%~pK!?wbIYcsboY-DO3Oms>(gjkf(ZP^Z7JBt{?y6}+n%{>jnK)X-B3sQ( z5?isxc9-ltW`nwHVxm?!B8=E0x}t+b0CkybS0=Ogld=Bge0hYq^&%cTMeFfcRgtwmz2Wzc^Wxv5CPp;E(=osPqs!i( zfDJ{Xj1`pxH}cO`#ivu2bV=+-+WHpNx_1C5(IoU_S7q}2g@(R$>_sPpvz))|Hhrco zfVA-jiM?r-8P#a9yY6N#<)W8;G?ZAJJw!8en4%;Wj5eU(Ty#{oo8@Gj<4{Os*y`t7 z4W34IX*)Q@5{1a-96ntxX#OlTl-G_dW-3dw5g#e{I_UO91-jA|6Wg9xNphnFaIK_7 zWQ?#Iji}k{6zQx$JPf%yDxwha!XR7qkmLQu{SP^6NDLX#D%;zSa~LxEC1%#19tr<> zl_jeA$YoAwrbClhN_aZ}VQnZ=+U|@iM`+Vb{@C3eK~FTiSSh zyO$3=A~h_=PTEefh1gZu@gX^DP){H4rv#1!o z6fMnWHWN7I;NlGx>G{Jm%5ZLjPt`Zf*?+1N)oP&ypmf`!a6^7WpA_AxWso_BIp~h= z>y~IvQ9OY|*;XK>@hlcv1?`W@RN_t_u@@4!IAw!z3~J)YzW@L|HQpME7nWTgVBz)S zb4V*MSv+l`SVA@}gQ#0R0|`(M&-Ja5L%-}F>Fvt^rl9`WH@awTfALa;@LUtNvZ_D& zVv}6&kMwHBLebpx87sc3IGcBW85~&po9WK|nPCydC%8@Hij9c%kRV1A%?iiHNQN_G znHC+6@!B=w;MY_CzDO28xb|`5`m(p7Hq<>c$k>6gkm$z`ZYq%3_{P9Au51lEKq#{VxHBdclfQ$ zCHyC2_cn4ge#21qbCUyg$UE_OpUQliogQ7Wh>KM*1Y5L+AmVU2N567RF99TLZOHFp z((5hiMnO8Qc_(ur8jLIfvbf}&3zH7(Et1yZ*>9|0v9pz3*q;-~KD0d85ahY>-`mmp z`rL6(&TEHNb6^{WD{lPF9Z@YWo94lh?&SWN#A-0E_{-7aD54D0Elbb3SSqNPu^2z0 zxTKPX4$7~n-tqp^S(}x|Kqj08m(BWGc^lvTk{qfqQ$l1En55+R^P=c*F8VHiuBa#a zzh+Y_cih8(=sRLF*ll~V^X z*zciWFHLLb2ouiAFx;l_SsP328h#8E*b*XXrh2!;YN$iXo!?6f*=+6nNs+~dhQbgz zO~WK?(h)0eGRLkecv8yC?LnZYlAf`1h(q^f+dj^ld5e+p6V1w;jrC_R=9VzQTB;fo z8@{%ZZBX*3>1LuO_TP1O&zR`h;mM=(XhS<|a$Bg5Bbhhu$-eY5%+=L?yt~Dxw2m`j zqtAw>k5x8rzGd+;jmnvHO8X{1vDQz%6t6Ft9T!Sgj}s+QTaaw#*{Rk>3yzw9bG8u$ZhW0)^u1r8ROae zwRAt<%@%A5DssXRD@%y7LSQ}NwxMl3pQEp4*EZ@$Yhm%MSy|uk?|9G$=*L+sk&1ck z)tu1(;4I>rD@|$*?*i{;^q9COcnE6!_x!Zm&jXyuvMEJsrr*wl-=X)Sx#C@GP~i5d z>^MhnMv|ympib^^3dNxnxjM|GxVA13-&9mQEB%aKt3S2N z>y0i}Kb+TwkNGn6p~>(wOVfEGIyT}cef`M3fW2;LN_3P!!ArKDs88HhlDzs%nb^;KQ^8AYR7Hs)PrMAN+RyQhQTj zJ$W#H{ejnexPso2bdV`PhAZ0?#aFo7EB1*BL9n#$Qh}{zKF27UF&|NZhLI|Ue$>64 zNhh0yF-z|859(#84*2HyjM09k(E$He33bR9{&Qd4MX}yo+;{iYwU{Axoj!}5-8Ml7 z%1GkeQH@8<@VD#@Rz=%gkL5Bwo1uu7X<3O-VX`4j|HnAE;imwFWr8);qBB!{yvMOs zoA8wIl0s*IxtiBQv55yf0`!pzB}+1lj$ZsluitVeQH|kp(22v6dYW_T%dp0gi341< z#PI(BEs!&7L@TEhuWHAG{9Qippq?m0x!6FZhPhsJ4m{iji zamQP(231d0aXohy_!JE!ztBhOU{&!NQXEIk|Z*oKCCc?JL=j~Nu5u9f$nuE96mU< zP+0aGwCjQ2Pjrx&fy&yFAxEk3fjm3-y3#{&eLdt5nP54vvEVhhCp}1$Rizwouow-l zR2>P6sYuQcVbGWh8qNX6BtSv-4sq{N^+Zq!nm~b^p*bo5qCMV58lZM}0|w~%4%1Yw zrp76qkvYlA_*LSp6ieV)sEnoTq%br{(RQUh>i)h0>TaPzO`1^axCAaV1$Cf#UOsQe zG?!2j!#T?WNio0^FtT|cN1%2V%nhBy8F1T?`WeFlUjBX?8Nv92wjLU; zm0s+LWOV6H#wG`EC`y{BCrb>&gp<3$TtR(S!H{-eU}%FR4QND~MWF(efgqH>O|u=1 zJbDFj>TRtwQi$~g3|1%ENdodn$0Gndg8@-*#JUD$!m*W*Oi<+mXOlCrB-&}Iii3-A zz^t}sav%-FQFdV*BtFqCtyND_kt0|XNf@gWuL0&vngReQ+NN_z34s_Xd6ktWR@re1 zgt<&iIHhw?*IH9Gv=B6s0ArNmt4_v5gaUu7crl6+ebq#XqDpRpFoK_?Fi2-TYzq+d z42Mhi06(%YYwVWkh;U)kaUly5ko7hfVNNtN;wn7c@#QjQgG$TMS9j!}zrBqi@! z5#vbB1uSQd!DO4fi8e)Jxs$u86s!XYbBwuJ2XXlT{6P&+9P>B;(?w-4$#Hh0;y#N) z(C+TZ5gJjm)Q9Q2p9wi?D1$s2dckm&)H$Iw9dKB9*8;sa1HS`)9M7&3(=A#gSm4Sr z;bzZi$1gJ~LrZ`o8^iAti3^}2WYL%CiHN8Ob$q+-d3y2caWQE;v_iT1_gHQUf!!PB z3a}o;kW50JcyWksaK3p8FYJpnfiT3M4U-2`Q*ceWES&a}W)7ko9MP{~;_Gp4iIs_3k&(z6`5fu?d{mJ3nI6?Gk0uxJKy~~=Hs_M1g_?$qDNE@L| zokLa=%?)I*(~D`lVy0b-bc<_$s*n}z4|c(;gI%f6z(md7l&G|!yoe`mk!I%_C}(O0 zx!^2`EofH+HOt`rD&UY53J$B8w-UkeKurPyG=D5YU@AYYX-x0UZc!BsayQ|Q#5&-~ zaHoeeP)P6tY6lRnQ$3QVZN$8=8pPR7MjsXQT-wKJRdRCTuw*Uw?H4DWSRN3JdkEAoEQ3Otkej02e+q3jf8)!-1Tu8C;!4zy`H z%BXAqHb#>)(rh{BerSii@ztqBQ25sV))pMuLCB_5D9|%F)vRXG=#s=dIgEo~87>46 zjShGX%6iZ=cORT!KzjnU@d1#Q#~j#Ue0=f`wvY%si3S_OP7`5ZiA_ObV4JFA3>qvo zpd*V{0BGFrT2SFD9Pdd=lPC(zk4GFdG16$lLk}m3phPPVz)f7D2PUBZXc-73Xgwnn zHIWmQ|HM;kERzPs0O^s(0rDN1*ru&G_nCqt{Q$L8(TLj4S?cfWr?9By_rGa6i zdOf@^64X6Tb%e{zd&M;*QQUU&j_OVbPc2vP`{Ir4$QM+J)HbZp1lFA-Er>Gh<&SzE5F`!b)pJLAi zAFTL=K`WQ@CqMuIqX+#n6b@uOs1h}>H9(szv7 za6^<^oG6a^2_8F^hOS>%DWHU*25Z{Ml?FU4q@H|q* z0pjb-?1K_IT)Y&bz44SHuxvu4Q*N?RQsoz`MFOc$gu5&_WUc-d51NaTZ7FXO9R`lt zB6RAMN8zt_e(Vg?g<5vhLfD!LG)%}Qh0PS6kXm715qyv%wnN>2NERjFo@Ee zH(4-fQKT#ePk}d_;bJEhW+c))5AB?Xi%Dy`UZ|$9s$JRIN$TKuS~eMTNuBv6u02x`a`#%Wixa(ktygjAlq1V)gc2Tp2S9coIT53pYR<>-B@ zgbvyjn4F-flU5iWrKI5?D-j6o2&4>BC)9_;Wu&TXA_XRjMvYEne3Psoq1-fEru5{K zzr4_W1pjbb?0q#TCmi(}(9e#JCw>HAP9`kXY4rgW@~pF~aZ!>AVmHWcod{-8QcP?M zm3>Qokv~&1DXI+3O&qoiN3+mu>L?MaTEI%P2~@?81@ng)6lZrYqc-Rx2HOMEx0e*q zY?uttqt$(1oP`BvlTL4|Ai-#XB^Xp=9MlNg$O39d3l|;?0fdD*u7P`7Q)*B;5XW~% zybPMR@iLXJrcDePj7*BAtDbsWlmi|jS``lO&jHXoAVtTdSiX_QB!KN5=yi7f+eW>) z5taszhiURr9oGkp6q9oo?m);s$fG$;FqO}$8MEe9;xb6&aNtVYW8c>)J8ST&#cQt! z<8whW!mpXViX>oZ&*R5Ef+c{f##R`I?H|RgrU28TP=K3;3g{|~)y%wNZBp!@V9O*l z#2@M!5&`L(Tvt%!XhMc&TSpm?oFUxDKXf2B{wTPHL9?C{nLzggDZLV#EpsXX;4~^V zW1U|?ry_CmFpF@Mq9v+#F|3K9>fZrtOu(af6^tK~rfI?QNNO zYu4?YX0svmi%12!!M)F@rmyG_0Z-9qMUyDqptYGsC8rulcp3bUcmjScHA3y0(M)mn7AUI(d0DiXr{uI+)Qf^8m&vw+H%hHt zX_vhn4ip|;ILdr#DHL+NWDIBtOOwumu&`;8-Qg^xwt!7_^_gb2mMD5G+EMAK=S88? zAgikrd@+EbdXaVUXtQ)h#RsYdm=r6$i*lfI7Cv#GkdT#mcPKz{IA&ExLnt)@xT`Va zYC5Gy@X8;3yNeMJl_gJDBQ2Ux6p=sihf1V{m4U^n{_w7$BcNvDIbVq#0I4Yds|Q9w z8GopQ(h4$B(4aBkqtbTM*@E5~E1>l49zTP#)YjNw+^@LtL;Jbu;*K+s0d!Z(De;E4 zB_y_*wiNP;4jzF#qAG<9Ga9^@N79=|QDC40_p9<4gUBD=k&)B_X;Q2>112zh@P!F( zpi&)6po~{U(ZvED>rr5g3VFvGEUPR6gQ#-P;M;a=9oS@~8W`JjT5&SzFNYmtIz>7( zM~N?b@!}RRFlC9QjOa5%@TTcmpqI!mtX5KRbd>GDg9>u~q8hVOL0kB_P|tz8Cn)E& z*-S9`lq4%SKcASR?t%ETu@!9C~{R~s*^JiLIh+3a#e6&ZwdkZI;E#DuASuS*RN*R{4Pp$004@et)>9!OawsBI z24VKX=olVF)Yj?2!cR%roXHW66(`9X@XDwNEsj`0BXN{e1U%tLs#vKyL6>_WDJdw1 z&{S(UK2WKHcmv5Y>EH;(AVvi#>@!oe%Vj7Eb8K6^1Jl@4&rYyQfQdMupy)MsM?*WVfr%{1Q?3h7iTMO9Gik;_p2PVL zcGl-8D4Dsof6}9Fiz6)sm8>EM9b!WnRcT_B3)q|ZWp$w9=3`)F6KP-bX*P{Ia881>}q?tcHUaVHS z?FrF>2_d!8Z^+d61aS@}y=r|hG=@c$o1wW!Z+;~G0D_A8lqWe90IoB^n#0zpE<)D^ zhA2CxfR=t7O4681^*#s+5ajv6ji%Y2u882M1M9 zCk1^^kG&h$-H{l^4KiOa!$^l62Z` zrrl{a8Nu}OJYH;u0hJ*MPCBN=_=UhTd-(^rg8C`J=q#M5KWpd?@jy1GP62A9RmvX! z6%HJv6J0qAy?8=DB|J#WO=C1))~b?n)L16@H%Se$hr@`is7+RF<wmY$S8#~VRIEmBL=?Y9RE$S4|G&Z#hHHPE#%A1yv)hiVljU->9} zL>=$y1nde^@rv?Lc4`nlB+>WA6a)aVB#jyYl=q-e$+zhM_<^19aZ4F0G1o7snHn-AWG>L zJrRztkAfiK=b0}hVF87L#`Y;Kw8PPTiqdx0DWE`FhJ#?cdapWsUGR3%6;r$)6G|K% zNO^-1h5A+dp_{TocwYvHhuSp#a?>(Ewm@mvm5s-g0V!Z+hb&>vtfUK4Ig-?qcZEyZ zDeD-9;q9Un-T?7MA|dzWX~k96hLE!XUx;C$IIkMb1c%+>URPzm2$o%>775y9(dK_| zTcx2}YrQ&1)I%E^Qv~Ox*Zm7>3#P$N`+V`rQq>7Ruou$pFdJw|6(#6iJYfK^OksjW zch#(B*AXgw*)nd3u9;s}biRh8oJd(4!z$dD6E(#Zg#UxCNwbDZ?inc81ZAf8ruL|F zE$M0PZZsGzh3tr?bOKvZzMws!H5xMnqFE2{350FV9Hf&zsMCs}v>YPy2(<{X-hu9rr})m^=c_?!Lv~-^m{;{gXp5fRr&AA*+7!XMpmqD&Eu?(t&bND*?rp zCS8Hr6>Svl$>$jm76IW^T>Q9cYU+CSL&p<%0UMAwG^y2h9lb@43Gcr5I{CV2K4IEY zAAo;46`)V%SIk3K!iK73$&QrQ zNj@cO53eOX7oEWdF&uuSk6~+?fQ6)R@x~waLsP4u;L;7j0@QRALuLnsGP`Kl1=YQe zX6wphiH{t*=K&a~LI(sj!5LM9O`t)p>a0&s@fMwJ3~^KT_gP~vgJYu&t!VLD0K-BWc2bo8>gCX~}C(#)I; zAPeFnw!(84lxwIRz%=a5ok6L5xCs;@o!o=Ur)MV}|b92cFt z=FT%&UOLff|Md|^u_=660wx~LTqgz~(J+aFG8@xU;oTu~MGIN#lpzU~ed24W? zGe?v`!p1j)ZmBwThT6^aC^7JyCQha$YnatVUXwA=#y3h84E`5o$M!U&A~WgGO-@s& z$t9(oCz_evm_^HoWK(C`6D4B$ADN5k#51T^M#9n^)SZ8l-Vwl8WJxB;XK~66S|`1i zHz=bf=y;`V_Q7eiZ9l$RajuB}mv@?=nVbv%+19#Tc5eC()z z3@Ib@UEtuHj~iYfn~vG@%rX4dI;$Lt6zAH93xe)u{6w>(g9cj7 zF-AwI0IUZtgQP&3Z{G%$AA}!+=?7Nq2jAEr3@fGHc+av?)I`e6LNafLqQgStzt1-8 z&@4ed#toRmiFCFh@p)*IYfLj^B<#~HxWJRuv;eI=i}{8`OQDfLCpAx+1FAPeS(Ke! zBa;@>it|hm635f9sx)kqxC(d*qu+62CSAU_ku_^*b#xOg^b~>do}K13YIYQc1whji zQIf4fi*b%l%1Fjs=Wmd+t?oxr{UgVvS@{Or(%aqIGO#1Cz1+A{#o%4_h*lM=MhX;D0uC1a!z)i(P^z#1rX zXVn2Kj-pe#UGUqCNXXs@)O0ieTHYJFn^MCEZIYa&4M-?D;g?pQ`^cq;ORtp?-eVUkVtE0%rtMp4K`t9E)tFHh>|RFDMBTXh1*u+x7JO0RiWy3Q{ikZi*8?I}`<{B*McHgOz>lFKP* znhtL>E86u1GG9AXi_7R>SQr!-)mu6k(W;|MptyIRMycl$>28ddCl*le)B_;Y^h6cK z4yvx5ikUNdC2i~^w9|$I^Yj$eOT?lF@Ay?t2TNLRm~QE@ieU6&nRpyjB@l^X8~~08 zX*g?eI7PQ+O?l)%_=w2+i0GzFE(QAQFzG*mr(wP*@+Y6)i_^lG3zf1wBi2Me22c^) zhvI}46vY$MU$UoLqFYd!FxYCJRMzQqThx%=%*TUr0K6xlac5cX+J*wnaP!GeIx~iL zIS^(edNW}l7n+pjWlnXLn+_^DDa)FjuBOBpO`;@Bu9PV?(&woNOV9S^s%)BtEX<9O zL-EO?0YrLp8$euoe&ML}LR;i&$Hmw*teCIE-I_w?fCPKOiI9U9DEwR316hY0fruvI zR3=QRz={H)*QVsnP(TZ<@6v@0rq__ip~W( z?X(W`6EGW&Awe}a&$oc)0@JO1mmco0e8$z&P*a=~gz7HL_n1R3(gnRfNgeq~s0)Jw zbD=f2>tH0su23rDsh3%*SB4uMdF|ljsQ5Ngn5Ex9%!d)uGsVHtVW2)}ufU`B(mLbN z8CTylZ0|lhDs~Nwfe*W7p)S}UcGH>d#X)|GyNVi4Se}n2G$rH5-EssEg=3n)puD6J z#d2x1I#V1BI#AI(Gg8SUV2F+;npV^NpcAM-I&p+zi>u~Fe3}CP6_p4_dn4URMsGLh za=^zq%8D6hgaAOh9Zigi19KFjjuW=E;F2q~FzjuUL7FTiv3s8l5Cnx-Rj5ms$(=!d zofO1?#$$g9D0LjL*inZRJ9%|iv4vH34mV7i^wHcxNY?kPR+-^&(Dn3*65(A8{)iX} z^Ww0$dV1LPiDxH+r};pEambz`_G2S_k`FpHGAU-;;Vj_!sRYpNkepryKdUNoFl5W9LX%ik z!z5*VoO8n)3Rlqw-qlA**XMu1j6Jz-l(%D1h<@>r9AfJho!vouyZ1jpf)2k0)tBZz zIEAEPEt-pE&`7*+UzH&tPIiCzdr1=@eqM8cbymAmloxSMG1CbhRp+HS;t1b+=&Z*% zbC;XI06cmvq7R3FxrU}###`Pn`D?Wge$tA2?tT0yxkdS3+YJCgslA!MNAF&CW-;s< zp}0+FSKMHx&4s}YlieWdP%PTIplS1rbOc_i)OR?^wiH}QDE4(~r(x@;(I+3L6P)$~ zX1ENskJfIT^H~E9fGDBaTwgf)b?~)vctpn>Qh))!Va?H1gUq%MPly=h@3SFN0+p57v-PRWkqmXkC? z&IF>==727aFM{E+;kwhPWi8W9Y42#rBHs0h7QCyH$%yG@pmJs4A*lG=?Gh2e(XXmw zcDx5aGVKX6INa4Kj{)vVx}mvUvlN~cI;g#y36_;M+no(Ep$rEQx7g`XySBx3?b{6Iz;_Mq zA@WQMGG4J4vt5z21kdfhE~%FjD+K#+&@zr?9M}4WufU} zQ-V-6L5srFcCck05h-mvW%E$I@vYF>9kfMC=R7*ho|=AF$d3ol+TVn?oeh07QI$$J ztBnJ$WS%DB zLD(5Qi2x)S+AE3B^Wu|zvkn^38Kq8qt6)<~(s%}ra?bkQG@dVQR<()!QJGN1i0yQ! zI^iUD2BxEF!7_>!Cc@#>6NHrs=ajf7p9IEcQNkngs6NviLLk%uK*i+E9YH&valf2W zgEV;3qSR)RBZ}Q#%Spf6GmSu(?*%ZgG{{Dt{hVDj>{({fKvoa z$-O7wiHyI{kWWl(_EE~d|Ec#I^-z^eGy|zxbMS+;8`<%%IdqMG%c@1f{tQ0($rhH>&)Fo;JY^TP+>Gkl7 zq5B|n7yAvSO1bEPOGF3o5}U&l0%Et8$pJ-;git7sF$WD-E!PnQ+_c^jE)&9W*Ss&5 zQZEGv{LXSyX5!Dlz@}l*F8B(@3dQH}Lo0xBF-Dqv2Ncz{K@PR)@gY5?loY7g<>{K~ zYS9C&nggsX+5o~!)cz`G5+>dQO~MS~Nz%%Y>ZssFZzhCir~ynRSa6cJq6F)3`zvFl z&oU3V4kKt@Zuor%N*A@G#0McTL7aLjdDJ)uA5ljj!9YWrBXCt_M$mR|Z`xIUWWdq1 zj?_FY2-HJ59@xgR1*NG40SGT-nmIJO9CV-w*+1uM3W`;LuTa+LqkEKjKPW=RsX`bB zs3QSE*0>_mglUR8;KmWCHJf7+l+Darv}w&HMkeQxlH&syQ!;dn;!-V7$0%wj__d)u zJAWAUmZVhI(akxLvDUT7q!y=sQgUf+?qxozvlQv<;zqdMy|)FJ3bjWNnBMJ4EY!@C zUR-scnV?mKMkWW(=0WU&q-*ipC-P!pm@3wkFxLkVH-*yRn`G~%qydZ`eVS)HOQ%y3 zn)bp6hwmsUC@epn0-bU)!Jykzd;0F@#rUMwLx z8DFc8e%GlFH!Vd;2L%8C>67+%i5lcHezwgKhLlFB(xP0v9gm4*Nm--ZuV+VC9Uu{` zod4_sJh(BgN0#se^;%G{0)rZdH$L&Kj1&ay;C--4y2W+C#dL)U$^*Uu)j@FrkI>1m z*?~%OYEW7-N%?mQL308Qov|dIo6;RxdJ*dlRDrnLd1;a!C3{Umc^Ys$7!)gMIY)8e zAHll-T-6JNReSP-@_u`IHux-LhQv2#mQUJ{qVsQvbbR2Gwr}cqm+FkOND-74vcdT# zHc&>D&PYn?p_V*H!I7XKo9i=yVP~wG@Fm0QeofG!N($MS_0UBLKT5ZuOg^^`lL0>u zUc=2a1V+;VD@+UmzhQ&T1|KM4jto_BBvqb1Cje^(rzpK}b3F$XJ8NtkPz$j=1M-C9S>rl#~9*(Y46(O?BkkY;`}&Pj*v7NrI^Y%j^krnVD$HAmYa zc!@v??jg_bC#JGiMM*L^?q(hUKQh#tH9G+oTb%`UExl0|#1L&#JM+6Q0)CUs;6W>A4<0)v(w%)?|52}w|LHNnj!B)V7yC>(Ngj5-k; zy{kIo%HcV1Bk4dk82K#Gp)77hwA3m*yaOjxbrL$z&slyVbAU`CG_x08bu!MBCqI~e z2GSf)s74>pQt28kAJHakPPlM{po>osGdtg$xBX$w@zlgahm&Qf<^CWU(m> zKIA}CLjcg4aS|#J9iqmlWqhA#hRGjA`IMF5+O~y*(Wb}dR-T%A0OpS1I*pR1`<23+ zI+1(4h(6>%nkjQg4Ld}3O>Li#jE7{ssH%Wv*(bG=@d0s5vSCi&p$U!REJH`DvqnTE zqi4ENa~f=B?P3!|VDu}45*c!LB`j#0$e1wwpGveOCza_`EZ zI4A5hok0V}VT9z2xYU?4x!%wxH7J8X7v)*5-nd1tO9zs|N5|~2j!8+85fmT6N&h>n z0?{J7JZHUgyC9qmUJ|aFQX^+aeSMiDZIf1%@WO-7_+Ye=DAza?2I=h@;(NgNuCqGr zz=lq4>dN zzzKU~ zPOwZJfdN%rGfi_sDAfMy7c9=&zzhOHxUNKm-odPG&&oju)ZHU*%@$}odU0T6z#Ats zU$iI9e*-J4`19)bE-ODBV>F^pUiWRGy2g~lCC)q(JttgkYFs{<8Bd#zF&$7=+C;Qy zGc?Xlog)j3KbK<6;^1p~W+9L&6bL6{Cd2NA&MI7-G85D}7f+#n-b060Q~nGkI|-g_ zhoz$#;?vuDsaUcGhQzegohV%A95dizw8?$1#1*P0luykmNFldVV8<{`jQAJr#>*h9 z?S5t*rHMm}?sD&&u|1@u8_0R@%mw*D3Kh_i;{>VU8f}Zas7lC=7sxm=A8Xrn*z!`e zqk2+LF#$*6Ho>?El&LvJW3WW``1C<){J3XPF+tvQg0D|-gAbACASzrm<*JJo9%{2O zXc7`ca||`?MleEXy3`YV;3|M0Q^4_vR@-Qll~j}Iv*DDB)0HGC&#|6l()#*&qbFuRi;vko4l5U#<0wk|3&t6|*`bt)Tj6Kca_V^q?Mn|bmHbmGzloT#I8w>>uCaJ=_)ykag zy>!(LokQ@{T89abwl^}1vqPV8QdQ@+c;XMfI1nNVH*yjh!b;%cP&hlZUi5*|lw=)^ zFx;?m4QVJM)O95aoJ~uYH-IE~nTuSdlzT4`rz(kqxJ60DI zu)DG%F_b&{B!{Y+vvYLaTa@vbHN{L@LAQ()t_jIm23>>}=AajjjQSn9xac_xVo>jA zF0&g0r9{Fg2gi0KO~WX(5G~K8{2g@J0>qrW(s*W zg)Y@@03X0-nIZ)vZ^|4SFPf0d0cx`6iBF(NM{;+u<&nxrI=ver`5V$;jJ-18pu*y0 zG|(x}Y+ZG2m2}d2rjW5Xn zM;i>qfbWGa!_6W-@o1cWQYIP{oeng7AUu8}j6XU%%sh&U8;Y}e1Itprbe*Wd2y6E| z=#X91=p>`GH^WQZ!FG?u5`SFZh1M`=7^9!sViQx@M$ns1xXxtIsEo+P*}{VMgzAXu zn$sL&t=0~Hz2MO|^V5nJFF1J(Ij9RYI= zq^Hil^agW2@t&iu8O)TDaaE+n!D>(jgKXw#hnYaehS0C8Gm3_f%SzhNFP>ynsDPy( zNZitE!{7@OKGCaH{lIKwqV^kT3ms{dtG8oYmkGQone2z@f2D9>(jwX|I|(0j0JK#_ z9cS)*o3utyHSF0>oE8_F?U)oXxv&TYfC?#)GyZ>EolC2=O?Q?5N=QpVU+R2F+pZmL zN(h2M0vbaU{QK55>RvVGBjk%_e|zm`t$WpB&N0W~tA(Yn=M?Gk*ZcULO9?0pNPZqQ zEKv+^2&f-FE6XIh5Ld|8_9Xdt!=z&}_c62^ic9ECV6~qN2~&Que8qSCPNku^l-?8u z_Th3&d6*$Fb15HulP*dMhb`v|HZX@JXH`b2pKA%FJNmM8lRN8g45#S?4*jctN$zAg za`T`E{S>5}!E|9XZ=Z9^DK#JsiKF?8OOo<&Z`D%20NbpjBf6EVxsbbpi&)Pl%-^BvD| z^`!#IPaK&GDAq4{U}>a}yI}6WbZX@0V1XEU{rm088tk_*)Lvjo zTXxCm3y`AcRP+a2yw60H(I8P^5bx{9iR2{1L7n>W4r6I&ibBuD+_@wj30pxT#{NaY zN>t7n#3gx8(b;8=#B*BxmkuFg4#l^3%KvH zw1m_Am7^k_&Z++D7jz+adu39mua%;;MIPH?AE{BC`Zxk4k>uH?_S73xO5 z;oUG?^G7v+F9CV3f9(uRY($au#v6W70#GoiG%1pP76y>sP%_fFeupEZbrFHk1$-(U z#H}D+$O=C)gUSf{4yJVJ2fwnq6U^XW9xfjq2caS2%;$E7QhhV&-_lQfMn?b)z(e~g ztQ7D`I+~i`k6V9<=QzA$UHY9|m@+}$1V;Gidszo9T^h>&&xwKWHfOjcJdB@dDm>CI z4D738xf@^%L4ru1A1u+_Mc^w`?D})h5Wlp5N|jIW(l0`(4Et}=Pt`zkNw{kIoURaM z<3G-DX#Qe6m~LwZ-SW?`;k*`^SIIx^gFqKPJ3b0uzFi~cNlsE^rH@Y_p5%N1nR5P6 zN~r&Ux>Y7LpI&&2T8k#|izb9^XR*Oa004U#KFkl zKT;C33^p0qOZz=^nVTb&yn6>$7FFJGpLhs#8#)P+gRfm=qKtPSbxP0aE2NUR6W~@a{))?o zI)~Z9{@CX-;=7hrpH)AzGwL2qt;Rzf8sZknzEe)j*En3g)0S)Scg)c5a*1Xc#N6w=%5aOt!-Vle5iqx5#7nVJ ze(|AL?NFDUCvguRs$S4tWOVJvhLA5C5PrErL?QLmp>Wsu#J3Y6O;L&P4JiDZRNym3 z2`_7&&wMK5a>^GDL{NUCE76sZ+kN6mO_2ns!P)sack++QNu2@huaaVvU*#43F_f-( zKa^)Ip}$fRNS!;LFu6;3k zqB={{m40Gi=FK#(TT=dyp_p_m0brD`7iJl7dvn>0{gcN|p)$t{>^8-SW_B)N;P->1a|8G6J1Xk?V#h+EHno@V}4V%%yhU`G6( ztvD{2DyOMpzqC`#$rTq=;jMfw28cX4TNna;O@;E@G_qNrnF#=NhH#GzyMM}7P-EsK zhz@_Lf57qyn5VUGnF4cjZ$J|JDJ$5y%zFA*AMcIf0w;6f%KbHqj2%yRCqCRa-L03x zep6+>6jS1|#K6wtRbAx~mTpZ6QD5jNV_{eZ8>^BZK?fCORiZdU$}f>#N$2M#!6f&y zC8b+ai@#79eC9x-|I!QFSBRd!2mNZ$Rn`4DMsmF~PAngCK?w-64Q|HF?b$j&TL%3| z9~dOnEWZI)X8nY-indjZOZ}#Jnk5Zcb0nIXj}?;Xppo>Q&+voXq(A4g^v@TZVI2iL z%Ea}{X*!p(VvZk)uqi!;#SfL`%VSWiG#~4n`d`K@4FO|I7UcNmT3I7z+%U1w{l*Q< z?mnZUpVfXvc*CfMS3sZ!PEWxgTW|t&N z{6;DAm>;sDeoBxnvw7J6>*w+WZZ35} z`?=>Sokzh1a>@DDlt`nR4)^cqsk{}O=lcz{`NYHs#56VP{l|DgO~#kE#7O-mxl`zh z!0<8uyZE|vs+_9da6FM&j3Q!)1pLi`WsZdGv5n6hh8Y(}Hb4HX-DtnX6;o{TcjS@e z1a}XMg3sbT!;c@bgu2Qp*-fR@OIXo?T{_?6hpmn=o~2T>D!pxK*pG{0ex_5^@P8sB{_U{VdruZ^i1GRQEG z3h#GI!6RfuLcq<>G>P{GlZy${_l%K(Vuw&m{i!f8*x&%I>*o^;-j3tVzC7@+S)L;P zOStuU7U!E+*pjq-`4e%G_X@;M7QsE?u=-@oBwx2r`*i+K0nb$#5Z^aOwgQt{Yn zmsJI z0o(pOWx$uSv@1|*zc!n48Y^73<*gH$+8{P)!1GT`v{3M8RqnrxWtlAjz;PIU9wRWY zk_Is2qb0FH_)29}nm&1nvZysnnC08=OgXl~Q90p$iRqA@y6oWd`~|2kxhpe{oj-{* z2A2x_h7$65P-UsSZ=}~Hl7Y}}A&x*vzwQZ_J@h_!-Tz!V+1!ktfrNh9KLul69a%py z9R_c-W{NDk5Dlk0>xX>qFWDHT#Fq35QTxShn2qw3kT;a}!NFPPHZ&dm3jjujp}j&} z>Mwq5I>fa zodx;X1%a(MWuxw&!U`*5`~)QIo1bVCK7ukvdj5puRN%mcw7~OoKxE_qiym3(FDDv* z25=nQ6U*1dkWCJFgF$?AG8pKTI6a>Ziy0>j2(Irue(1*5Ad{*4{1lr_c!%UUw9sFB z29N`SJKF~vSKtbg`G6_*X9U%CrctF=tQWqVabf12dZMJylOMtv)FmPaJ^>(joW>y> ztL+QGt6Smicp!W}S_RaBa1Mn!eL+;p0iP0@w&#yabBT%O7v8|hGQ09&_RsCHiA4?( zw|zt{MOh^rfyl}EzE&1Bk{@V^>2re4Q;6)jjK84pB33K`43M8&`K44)-IXHZ^@n#< zKvlBq$FE}|6OIl=Mx$l_GFX_!;<{M<#q zT$;>!Q#$$noWl{D;jIB4^;woe=O9i?qUHW`^IfWLDlE)TO+pJV4iGL^`6HJT>dDQ` zuN}FYnaAA160ba_2Fi>2id>B26;Jp}HZQQ( zDHqo_+9e`^B3k$6hj$?_Vn(Ck=FcaEsbte=s(lWQ*hg%2khtak{IdY}vD1J-@C)dr zh+)v2%l@U(py#RN){5=%mFZwdD0VSu{e+`T2$7dlFn`2XI&4TPN>}^1X56iCq0?Ei zAI-M`Qlq((yWmIh9ysPfT*BnPz4{R@o4l!i=pJKah@VI^zUu@rD@Q6%;fc)Yrz>MA zs@3&DYE#x_1epBHwv&!;&Y1NX4JSF@Xp3>-eI*OB+$GzA{>`xoc9wrMSnUT2p*A8M z2>NjS+=A!;b&Fv@|C89ji=fpIpY)gENj0up^=CtgTOKH8Q?SMt*d^t35`SvtZ`v&` z)zoeNL+hMOVU_A5dsPiR3mJb^y`n| z>M{%CjichZd^!lk;W|>w(grSj8(|^ypib(Dh0%a=ssI75%Wo`pY3x) zOhj1_jUs#2FLG{6AIuZkpFEp1;I1&OA;#F3G03DCZ4f@CAK(K08uB{^Q@2TJ+>)Hc za4&x9DXF&>$*I4k!ib4Rs)Cn&l>+!6qi*H1)-Q1h;Til!F7oudP|?$I`Ig`4xJzqE zYldn*Itpo`OSP&HeXtd>*(-nquVNJExPv(WeW9 zsqfD)=7wIgHFf;`nF4yrnY@sygG~P3JVtRk=%_+YGxPiXbK`RDD_@F|@4X$V>rvf< zwyxiwApi!_d#=nl|V9%1%jqlAvc+60~7@#C{jlX{s*uG+4 zs!z6m?{RA4g=asZVD z)ZT9|GUM?q#$51wC*!&{pxI!~H}dbDP>BMRfc<0W{QGsNc~DzXTb)Ma_ir*t5|GG# ze+z)9IXyjzK;9dqdOPbulgsKKpBKv?4jv$<^6#xMmtbKqg?OL7|3w5umOb)0=}x`% zz95W+IFy(*odeHLtBEv>liN8$-kYRJOB$&&VtQYN$nAjj86Cv?;yBK`{9!R7_$@jS zv}9F~6^)hr{Zjx@jbV&b-%g_bfnd!<5FW?xP2wCx?Ml$*X&n6CmnwW6{qF&nYm~A@ zG{6|-{dGN%p@j2u&b-}D`GlVo4cUDAQI`Kg2nIva_f`mK7?SC82EQe$B?P|;vYoe! zLZo3_U4Z~cK)Ao_@5k`^mS(%$$Zsze4dKZ@WWw0y?=kAUfp5MPR9-uJtc0T*^{j*Bqgkz+L=lXsh!l`V$!S$#24C`en2v)WEd%*gwE~VY+Ki{|7 z6vd4OjUwtD0+^KIS5^hi{eBElm}_NB6$0;DUFg{4LC$7sZxIlJL()Mb@MFJUKkH%% zEG%Nl*Z1C~FvQRnIAgt)s`MrGJJ|TG_a+JG0lKCnIsf_nJ~V_4=tPI=eV`|cS;>_4 z>Ye!#3In%7?%D5*6~!p&$UYn5-6+bN!~YS-ys>}pMTt07COVjsz4tN!D3!+pIk9-X zaM zvHhTMZc0CVS2j-uTp7MNE~@Y6Q_#m*9%m802ZLh{(>_ugfvSC6;kUJ~SMc;5*`@R3 z!Wr+N;pixe$6yY=d@nPnP@cLHQQkX;^}q+MvVrz@&54>csiH30zo&~r5yKgTV&RWd zD5oUq10$h#1;N>aJjb}>_v4B3R`4z}_Vla4pmG7s0DJJ%$NMV?kFlXq`eb^0gO9D6 z7}`n-pMMH{!dl@R`!n2@(mi*Rwmu`@hDgFbI@9hnY zsW2A=GU)y7jkD6}RaMBtDR_(h-0KiM$nKuMT4u?-v8z!_uq=ODW}qB7CtFi6cYY5z zNiS6J#MfVXivWA;ndL?UcgmFl(&n)xNUGihlry5t;$!uD#6tF*Lz}&4zE8su*-YrkPYDgp&Z@ieSKYIK>j_fqC+jq zu}rHBdG~jkp5@-JnL5gCsdmrc;fCZecpLQwYbQ9BD|zh>1@mxOF(WL#1`y=1JYnE1KSYSC`9V5{6VBZ><8F^2$bHM!@WR`B2C1>*M?{+ zB3dEW>brX{0=#CkNj#O8FGVnFyHab#$BvL z2b3x)`*fTV`Ek0XR}3~RcgWQcb9D|^PWp(nwE$w?k%juZ;0OnnKi%tM$cbDW&S^`p zrjW4?$QMNJw7qDS29d}#(Zj!XUsn3-nq~SHCsUzcH%=ha_uGl9n!Ab^5Vv$VjTV(9 z*gu7qaq3&!_0mFp`Jj0i*Cr$tV^#QmU<0b~`Tee0x_Um(pcShnTn6x+Xc{qE|NZ?9 zOpGG`d@KAj^H)4zN)5wlO3Cj!5$G6_2E3RON;&*iWP`BYE-RYouH5Gf@s-Xo@Y>UG>&{W1J6l2?2-S45W3CdT#g{YWl3{ zC(}0!q{BK$FiMAJYk5@z4OOXXi9@dU_jb@FpR13^!WCBEAaRePn^W31j`Daou&@WK zKI4gz+FO|o0MEbE6S}?dMuiSmpZs(uR5(%ydl#cCT{lqp{3(9^D5XsS$z7gWp~Adq zLu!cfF!Oue3Q9ysvpDb4wZ8*OS@!ViV7|V8JD--0aIL0r`_Wuufld%F&Qi&5?J%dn z;soM)3B6-w?Gb;%5Y+8~$O@z?iwgEW@K)d-ae4acU3+7p*ii$Nw@&~7iSa>^2XxcJ za;0FDP>%!SeWbGz^kF9EAN~rVBe0zjGKLZZF9f(+A{+cFnE99Ld{m?K_jCR(z)bI< zbTtuDT?91w4U-jxWZlijkcus(cGWp=57K2y#D~-7tAnspN7kQ(NWJ%1v~wud3QP|- z@(@7^oYNzt8=1ybHr!?9?46+1^u6$%MF%$Z@FZ712?Xbt(EQyYiCz0Yzh5B~&{oy@1Y(r2Srx7=7Y$kwYg05Z_UsZIl=W399mkie&HW ztrzMLP*}y-xmd@q3VvWwZ2SzxSeL7G0;1d9_%ahJQg4`jKWGNAiJZE4Gw%dAM`x0o z2xS)XruX$%#TqKFH;1BG{y^!@8M~`Aya>CTmhs0b{`?t$kavkI#5?q9Y>{L0{i^yv6_ZQb0iBNbLFocgp%@0eze21!|Z4?}#^ zvlw$HS(Q$=SrndHkRT`Tw~LgisM*gN+peVXbfr8=%dI_r1?OC+!rA?eDH=x?XbseT z^!=~S!=q-)2!XBo-Z%qC{tj+(uNtTrLva}MZJvwSdy705uqM*LhyOZP5g;FTz#9t` zcqef4uCTtkQsQ#d3x-_&y&7Q|p{pmEuC6fGp?F1`)*_6-RFjHcsoMT44BkkMDLP|4 zFrh1~V6l@Kv41XbyNJn7sInIfLsSfd07X38ll2gqj=b2DyQ1WuM_^crJOf{M7CmdhTvay&P=9XA7) zQ2uCE1fu+U7>XEh5j?WoBEwL``Vz-elLrvffx=?+GJHIevT(_pcviiCkn4fuMS|8~ zxxDMA+y+9Tkzrs0?xwPLSB^u9ER72lHx6TA)(WJr2A>&s31vyr)ed4tGw-kZCQva_ zZ2b*8px83%;Fv^w|EMKP5$JqL=VU3N)I#+v%H7&Vi=lHVgAw_?fxe`aO+~a5&h1*B zr0R2UDr?TLxcmwjR#XB54Xy$ykW{byNIx1JL^=lb42BV~bI)Dong`>oBzukK(_MOc z2)9j9xjr|QCdMCvz4HFeDb24eIx(f|p`}9+KCgz#lNTiNSaB6b6|OX;cL8cc!=J)< z8)Myiza#G>na#1;NsV?zm1QJ`GkAdQfi{Q|=OkTb$xurqjLzVlx*b1fL6Q415Kc1! zbsov4+LujR_r|EIJg@BeY&&0_`SSKqY%zFwKd(6a%c~W`2>*JM^RYTz7rjc)fsqe! z(bfHs-Dr^!*&ncsLe#75G2Uk9^3nN%tBLsJ@6q6@M|Sr{8L|Bpp(sF;fnv{K+pU<{ zLRFDkZ?d~?$8 zK;NgHn-5Chh#||fsHvZ=yJ#U75k9zjU?2GGPlA}pyI`TX5XW@2;OUq=)qo{E5$y8_ zLKVV{9lI1t(>6Wbqz1>8x{_^{Lf3-vLY3Jou`{LvC?{sg%QX90ZZ(_02!swA1;@8X z<^sr_0~nMPMSRh}=0!iSLg#d+iT7!O&W0%s1d?6@t7}QTtdnYmw}N=A5|^>eELqoh z7*+qmZo*#&q$PP~leAup+@hJ3@i;U#pFW(h@bMv42E3n$4)hx`?rGK4@{lnWEN|v* z@AOGBGgi;t&8~r2$Jn$;?q`aXTK04`;<4PKt3XNxzjU}s%L;0m$N^BG?R5!pkxt)hU!h^?VZGjqqUYPHNB`!>6 z%vxR3pQ25wAjtPOm(*X>O4q{i2Bx|i-=d*l2$6<&0)Cxzblj|uBggU>vR=rDocnrj z2Pc9e)oD>Ox=}dn%DPr}3x^GuDFy*h0OOxe6VE zS2kcKC<0oAyu530AntONPhm0zrqP7FV{_ty6ACLc+e*TWZx#g2^o`h0nO!44E)IUY zA4VJkP_^7pBGb%ZLdqE&m*bhk^t=oCyk7Ft6Mm?pR2o~Q*E`(k7x1%G&sW^P(sndS zGe?B>G$cOd%>V{@pC4MId`;NQ&Cv3uESXTs@Y8wUbOFK;ubz7DJ;nlTj~5n(^36M2 zndZn!_)x=TSW~IIO$lsL$u3LRGLmLZ3;@y^4bCNn_A7mKD7i)`qdF3aTnh*KUOz(1fj zi5X6%y&pdYj&kNuA)y&N{%!&mJXhp23NKwh#2>&#DI{BwC2!d)Su2r-#7*z>xj3UOtW)>Br%2Dm9Z%qhr_KmV6SnJc6LTO8cR4h}8TOy%Nt6A{DCL|)<_Id3 z2onw_c}6`*hRMWGDTO}JBdDtwIb)n?Qj&x@=bTY_xt6yJ_6u_7KYPP)CLeQOmRR07 zP|txK7*d>7TEneMYAeZ&+uzXm3)?>YUM6#M&pvm;H36aq7`SgC=onCZ;h`cgdmnY( zOg&sh=6>62DAG#dq6i$kOQ9x;+>WvB}DoH6EkYyaMrp zQP+@{$@2VzqF148OcI8xclmjs#UXXKyLv^7-xT`A7}fQ)#5jcH;*qeMfd+!1;dnwa z2sqi0M3fc0d-muTbv}L&h;uzcM)|tY`Uv6hGS#T?ORLR3?Ife90s_EKjQydU$5w(9 ze|qeIP(?&uz)ouzBt2eIgiS9bk{-A?fZUe9q$x@S#2J~i|G)~O+a_f?xI%wpH!@T) z8HXYl5CWp-WQT+dCUsBwJLX~Mn?PLX*(mcSB0w>uPCktZ$(~)7Dmg7fuILw(W!LcN z55r)F#KamwsWa4+6=V?;k zBq$drvmTh8UAbv&#s}Q@zdiy9=E!)5xXl4ML_XZ14!J?$q-#{$Nr|f_z-(I4E`LOS z5m(a!?fw+Su6G#dqVWW( zdejA-G%NdX$l>~F%mALWDl)0{Z)HJ($ol+X32%F1`odiVUl$bBu=ja)i8pyxS52nP zA@XJhG&S;b1;kkllNq8869&vZxKzWz`JSTVdNEBogAv27j;YGBREeJL+i>s)137x8KrM_ib!$k!=GOGa-yMoy%BRgQY5e%zVbvKS{Z6GmFWu`#s8?zao$Sm%ZDd+y)=y#57-e%;AHXfRG1#l08?VeiW6KC$;JdM zglsgQ82T?yr!iLAH`Zu+6~V&*aglfNDxu}TJnN>zo({_;)RuLLWN_5_2Bi^OV`eYi zuqUx3(;%o&1fsQmILp%a!fo2Go|9!7Lu*FE9-I{dy}L-1ocHhR7^`0>Ju{_rM~ycR z)dsiDF#A}<5@NR)DQ34bcr=MBQMP5hG%WJ+#wji6a;_8_ULi>;(^!h76~E9CU;Y|IzH&CtO+vL zh0nQ7)+3-ktoa=#fy{A{*1=?}ZbUMdbH$pd%m@r~dJ45=`~Di*p$ZT744{FT-SqUC z@K!>hiX|4X&J<*J+0)xUGD$y!9gkUOK!n{Xb%y8)%28upOwxmb2sk2xI z0!&kJ7zD5h4ein#fFlM~qY0|G;ln4-vZ-EyMbgASr0IUkad5&S?-`s^56jY(M))5p z90p2WrC8ODtt_wNsRVoj0i3wVO}Ar96Ll^Vh>X)yEa0&7)h?M)yzhfaSMH*_aVS~W z43NwY1FYTw0T7D>=ptnom-q^4w~MZyT6mJ{X4Au}cL_j|$mAE{((kOE8dcz4v4D13 zIDNqt&UV>yh4d%IAs#>Pd7trHdd$tKAuwj!?TII7$Do(g>sEQIp#2wvXN=`AeJSfH zFi=4&u8+Oe9$yLq0w&2w3r~F*gp1@SA=E8xy4oZ74M%0vOIxsK6B#@sVyr2KvPTei^wZSB#xbh zJH0Lz!e%(%b`VpeHo4#CF6L2TwsR_buC{7g#Z<7K*yI>V84qP#NsJ>753f?|=sc4m z#bgN<^cO|56m(lDfLe4*8j)K12Ok*LBDe;M>S8tW2D{PUS*icn#q8P*@fgof6alQ5A#Cb5{XE{W(0o==O&ex2ZIaoA8 zF)+Yu7Q4}L4LLflYL&=0&lYcd*C~{r6~-)FvweoDqM8Oh6*5~4M+;;1HB{x6e&d+ zd1g~jP}OiEp!zJqn&5mz)V^I2lQ}?0On}s1O#_0QDcE^1z#_)NGeQUl6&K&O!<2(# zLep~@j+^d72Px`rQzEiiY)hGiNZ00e(aky%v+W!(-Ke-Q1n3Fqk@pBvU5;&(*9}V} zKVPnF&G_M7zvqh=VnNl$+@WxS^zN}${WsZ4LH9N;0m;=>du}lTx|?p z=|)!}%V^RFuga$wZSDCEF$Ju<|A>Z_+lq6Kbi6PxQqmBEB>sLNH$g?miWe=cN8E^R z&hf)Nbe^bp>Q+Jr)pB`u6C5*505v3|F?yiTQblZ0Ue4icO+71xxkXhVY7T1n%*a8p zXRq4Cw?6Pw^iT5wOj85R6JW7m(@@#AVdWOk*}aE`cxHU~&yuKadw(WD{Rl#2V6scZ9xF zmXS>1w`rkGIo6t-P$rw1M4}Kxx_*P?+?z9GO>i0kn7oe}0f1&yjfDJZ>0v>R0iH$+ zLJj~2j9_|oTS7QTQ-|}&DBJY|zCw$r9LyG$j?jVqHl_t5N^5qrAIGK=h z9JZk+1tJV&?2K319swf&nZ{`vmlVS-;E#o`zT=f{grB=%iYkFu6E;iO@QJ!L*}1^l6~%M?IfNYKu4V9(+% z23IW7$|Pr>t-D4mu;$fjVy77zL3nC5`<2p)A) zN;=P6gkD1yq%bSKLg`JGjQSXmxJD7biCY3M;lN79ceDyh8siWAL@5@6>fGXI*a#d0 zelYlh6d6)WBND#U0!5B`5g}6B{ZA$rSRpQDC0zfiA~2M1)8;vljI;bhsrb!m1+m1u z7*9&lMhuIMuZ0xS?sk@WBcZ5^f8P}fl?aM)3><>)l*xQg(!O-7cttlrV+!ixzktN5rQA+%D32b0m_+MQKT3Z_Wrq zIt+(!@iUGpHU_6Ph@`D8Aw7z-3X7%q9w$LnZlw?`eE|{GkVOnZMBp13HWr@NTNw*R zwJY;0)VgCo^E!Gp4h@)1U>+vTVUJxD0oT002J}OD{(xY*GeCSr%>q>Wb2^ZNCy~wx zd2Ok_tugyk3el9iRIr?5AcaRVEB#$Rc>Y#`y(+BaKLoa*2fW3(M-TymAmc*7rnX5G zX~uj*gx@vyjc7gjq=?gkWe>v8PIM4v+QBwafV*hm=Zp+n1H^-i4;jk2b88?xOc|VF zERaIGj+UQ~3a8p+55g#*FmfZN8P!>Gp`SM&$7TumuOJ)1$Nu~}=JiRym29ON{vxhhxvH~%S_8h-;C32IF(t@P|1AGW(w2s|fd{fBf zn!4JHxBHT@Am zquUosSl;)ens zie@|?`n8;2isarFK+&sT$IEWE|G6Hp@dOAbelBoi6{kzL9)=PuXgu!~?vp*M%Cgr7 zRs(?cC?D}0O(e>RvTPjyADqFC3|Rc-K|;ak5m|nVyt3^(j#()5yFLNQo3HWc((T#v=*FO<&`io4TP&K9vZq!KI3S4Gvhl5uL{lH>; zhG~@bJQ(4iFQkN;L6}Sf3x^O#;sE?NuxA!A2d>>OK*_-7CPr>?d{`BTh`rPdColwT z)aJ;olfBn4tjr8gbMyUnW7c^Vy1Mebw@CG2mkkI#BqE;pKD zxcJ*iT|o(T1!8bc6D(;A;+!w3aD^phc?MD9kQ|MFxCi$+9t=!J;EHL-Z)C)bh^SJM z_g^EPT&C9W=xQ*V#lKzlFu{JWUm>l+-)boTn}!Q`8Or3-@13D9QF=_XOoPMHyvdkQ ztejW=W3!3A4sC$v_Rw)UF4CZ4L50KSoG6nO>|27zIQm@~eLUxiPTn0i6{*@puez1q z^d}PUZ7V50Zv463lbqaZU^;`{7G0~^DtXLYIAgI19naEP{za#GkbAY&T<8usr07K0 zQ^AyvFO4G-{a)xt$xS?e2CCj8<4UtGsj^bCcYroTYePqZ1lbz&B3%Twm1PER0%o&# zE#fNrepyL;LtrDN;(Lr$UfD&U#*CuLPK7rW99M0)OY$a3Q%YQUQ^Fzs9WEc|*=7To zki^w0plA$2P3AnbiV7K7p?)HH7u_8)Mh~4A%AmTOk`v*?ZXPTGp7Trx0PPOMeTzIwkQM9@f1~)g(T0_)y>8Q?bVitmA%&Y^5Q1mFRAnjK4F;zd+@4*BF$b@XXnsRUx z-_YPH(!;)N2nE>^I1s0#k>VW$;g}o^5MS4q8x9_v7$gDa!=WF-KT;vXC2UuCujqw9 zmRj&N(HLSVZ*&?M2k(*AqPnIp*8-5Vh!i3Vm>QkanwhG*nyPsZMvL=udQFm>ihADi zw+RB|tw0;{wjlV~F@^IP|2Kf$*DRQWz(VoZ+KSwwe(TfNN zXfS%X3lnVEs#L7{PT$7pig;n7!fXsdAO; z(6R9H)B_g^fSCi3xy=ra=2+Jgs7NxH&3thMVOmCn@@o6J=`%SidayaXZ>z#`jjr<)eSuJg!}Ee#T@&A zMvasdB29W=Kq(LhZU$9epuXoN?Sq^$`U*9*g!2WDrPa1SsBABRQ&R91-ZH5ovv;E8 zw$t9Vy2fr}_Zr*)@nwbi%Z$#jG%P8K^^9>{3cVjq2~V;hMGFZhn{oWP@QMU#p+qdo z-KBO9GwcpqQ3@(i9lVn;Nxtdsv$(CNIL7KpgZUbXtd~v*PP2UQRIMFWrHYR!n6+m!Obh zIvQ#Zqgi zmA}sh^P5S9(FCB1D(>`T`~TUxMkfIyci(@!u5_ zA6B?qafoE-LKsQb;POYQ3MrN;RGyv_(C@&TsWi*kl|NQZWPC{H>~>b^w(Z*L1-2$0 zoOrQQ4EkXV$@;eG^`;C&jt^rp z`=SQ9TyjdmdEi~hoy#ABZ-?z=VM&$zMX{Q}5j3ZDiQLqx!OfkCk^DE{X*kdeV1kR3 zeq;td7zI`($q22x+ap>If{ytBE1slRpgILT@0b$ABm{>!9Clqav!a1F;FDN$Iwk*y zHHoiUbDg=BZt0u_&*36Bxd3uj1zfE$Iw?c%RA|9QerXwH+Ixm^i>P~pMIahhD) z()n|7Cg@5nT#s17FoK1AI6XlF7|SxfZi?o7`c*Phk&Ocr-%B}Ia98Pf;2U8CIt?0W zVz_m#hNBZLXs1?2oMuMU>9F{(cyN#V+#pEsL9-2s9x!9 z5%r%{HhiPmJ6&^kzUQ;p!iWw|^^ZFdrsnV{z?~NZ)*Mm*&~V@vh!WYB^HpOpk|^kLT=(qAYE225ci>^Mkq31uK5S+3CCy%23#K5T@K6PY*Ua~i$UoHCZ#sc9fEr}63{af+hVA`-!T)>9@|JQoMFH=k{&Kc z#0c&Nz(jBh)z7{mWE8URX6V0mKQ+q)>2=#X@G7CGUO^eKHCg;rchM8BrQNHW=TltU13PqWNZ>oZE&GDrlet3*9p zO@x49sehEYi%b-V}nn{`QnH@g4_XaV&6wNlO!LNXY64hZj4+3a=mz| za9vOf(oQT*m!*d)v++0Yj#7cmgYdfX27SswSZ0PJv-V;tQO3|hws1RzI1iwTzLh=G zs;9UdVv1%0r#JIVb6tde*uWH1rUr8aQdJrJ*$4^WmX3lI{4fX14kIi=Fo!LXNz_#|&l4?th^gxv!Z9jP3|vv(i!GK59d0#U*a_H^G>O)OHbXze0v zNcBg-s{}!Zh;fxW_vMi)Rt6uetuWQMp(!G%u1*;?Eo;Q&keLXI+Q6SLMe%7IjX>N1 z-$osX5oUX%zNG!5`-c7z0M02s}!X>A0~6N>6z8kV$KpC?LpH-X z0;cga;BM5BW(|%8jpViC+B3VOq`@gr{WY4R99*26boV5HO@nxp^Ryw5c1Gk_FnvOq ziFDN`AZjtF;nUdpIqLB4MZ}Y%51>SoFyByL`ofhxX1?(ZFJ1#LAU?&?PH6#rR8BBw z;!vp2`zstcjpXIFgW=Vw5wL<`f3Co3Akq-gGWHq{M>>^AldW}?nlfa=P!V>eh;9Rc zQo6{0i6hor^bIfSR8Md}bC9H>c9KnO$CWLFkS3Ux-1OVhW@fi2XQayxiy3c~v-|O7 zvwP!`!E6=uI#LL*$EA}-p2fbx-~`uj>LrS)ATQWZc?b^(=OoTDN`?N?SAta+`MrwL znenP%tC9JImc|m0vjS;FaGJ_NNTP%z3|Ewj68?G;+X79`ijolqKzJ81^Z3IqfD&oR z-8I-YSKSh@gj*b95`&?1lNxmSY%&a*cnB`S0DvtaG|}OM!4h_2*DSDV9H)Y zTyQucr)cTQgkmGD#eU6!r1aDD>u@3VD$W(h$YO(}JKO-eT*btg?+4EOGpHHo8^F?? ziz)l`g#ET5d!RkbK}JJl8Y6FN&fi1Xe@R-#H^E;sV@LMnTyj`8yj3nYbo}lI${rh0 zEUGn#0~+wUHRTXunn6_X@Z=QDd80dl13LUkrJ2NI4R~gbVCa%acA>_DQ|;!FFhpjA z=$f*l#J1cyxpVdtp3@KlP4-**m--vwVYz}CcGvL~hzaZmXv~Dh1>dx;h&fRlW;KCc zR+dRz$51L0OexM957NgO9Cj}24Q8=+upWR7AUEE<9@d05A6p^33!9J1{qt|Gg7-Y9 zNf=k4!f04$Ue!3JJy@a&{Uv;J=r%p!gL?894^DJg} z#skr5K>cHgpfCje_52dkcPO_4i)?|2JUuV@I(*WfK+Epj2~cCRZoqe+!!*xgreI6I zf8XkaY$fm)bz=(&Zm42tqM`bj2@E2@)Ejb^Ibq-pip;vyz7-rn_8m^s3;FxY>^c}R z?BN`EPoN)}CLl=W`@Ka)AKJA?$MtjcC}OehP!`kSpM!wQt_LY3Nf;op3|4Z`hQa?u zKY~Ecol%jIG#G3{loJvX7ZOG;q&2)EtAdJIOw;M~O>Q6+dDzxgcn4S!w_lz2vNx6> zJ`{y$zO1b|ZI8O@tI~1Z+7#V8@L{ZBLQL8v)#Fysf?cK+D+}XQAxfq+lEp{9A6(Pe z7X#OFWR5=!=tN>+aLA}}1=j?ecW04tMB8Bk2^V6@Cem0RmHq@n6lprG9_*@-K4b4y zS}^G^%C%0!RF!OAdP8rn0BY&G0=*J*F&SIDS|+z69ZnSbIQKlK#V$;%>knn74#7|w-)WhQwGdW^m7t;xB^as! zF_qdagBY5%Lnt>4S|#nkDcD>Rt^#;9F@r7|?9bGnBKAi!VIE z5!WJgCCWMknZdvg7zM8&>;TOB0CJH?2m9UP?;?ws2M|Eqki+!o!tan30N4!GHtr;w zRNor>Ezt={Qrb1f@XkYEJLIcRuN|}ZTH>Ks#XUvh8e~IM0yQ{<;Ob5HbB&RVY@lJq z$%JNL1>N8VTSB0+b%DCpA}&siSCy-|qi?`V2X$teBxg7*Zr3vj?E&d3mwsQ24T`tQyJ87m#Yb=9;qbRgg}&Oa2VV*=q1sfbgDai-W;1KCz8Bmtaj`8eS1r2(Xi&3Tm)_`O@Ebt z&8#7qbx1Oe$^u8IWfHeMJI-n@H~^S7&ppRTn1iF5j@GqVxKX&>LNlX~X3jZ?i2)eG zd1y+xRO<}c2xWLM|{4AYMydL+gSXD57qVz3D^<6q&}AvAE#Ta~C>P z6?z7eXi+-O-wT$u5L_VBa^nRbtAbS(`8Bu-1>6xM7R+~X_*x-}m?3<=rbR1w2IH^G z?h}(>MBNr$Byy%T9Y6rR2Lud!u(=Fn32}sHWt;|AcHSh}fT+3c!Oyr5ce5e8R-Yz~ zYY;vP*F+8O_JqfZN|&rS!$p=_4W{KedqdbCxR&fpkswCriY>VnF86lwL;#qS|4V>Q zD14PxsuZMI4fogkQ%5vJGJno5yZL9TkH^0qLct9fG`W?mJTV>0A~{30RX0P<2h;KnNfKF==nJ z#bxyE7=tpc%^|sza4-;3@aHsx^F-GGT=E#_lH+A5vN@EQwS$xFtrLf0!h8_RgbM~b zF)tPjNwqH!K8Rs41xO@?AUxrVbgM>3bj}(IT?BG(#pCuv<9Irr6upU%T~HRnEKPw2 zN$rlc0g!f3Dru^Z{+T6ZD!zhmK<$umwHmBxD3s3*sTkgws(4zFsrD1MuNg8yv14HT z4h$a*RTyG1%3C!Y=Q~}$)damGjJzsgw3!}o8Jy;@ty#un4-Zyu0Xi2^S#}S;-{*u- zdI_OBD==glb6ElnQgQ{|i;^7?H7NJd<_byq+ml@S5a)t8G;{HFwaFgYPz>Xkh?X+6 zbdbOl8iF>^-`#1>&O>K#R^@;08UjcBabcEbbt^OuXtP8>?CkrpvQwPAo`2L zkSw|K))GnzklC|c_jE8E#W=hlW+ilTmcNty1B9+ojM3~SrV4fe%pO*NXbIPqN_Hn# z*FmWu+V1j{?#*b|U1GbDI9L`QdGvM3sw za=oTC^c;FLBkX!R8e%^hF|7c!R6OUQ3=nSB1 zuzo%_7r=at%A8YgNXdjEaJhTt?gokG99`kp0ZbLBr#XAD=q3q=@*PYnn@j_Wb{+*# zXEYxxcuC;Op7os>Fa!X`u3>Z71drSsGAmisEBZGonJp7|G{&|5$~&l4Rk*qM*WOcd ziv@E6X*a$~DlCa_E~<+f(5BE?LNe`8id#|w=_l8=hdTlG3Z)v9+3jg1)wA4FGB}_w zI>vxx;SdHESgP>usMB^)B)i7|RXy2x$Z-@569pC}Be)fe_IHv*MDn`gJDR}@t&zNNUqxdNdQ3ya0<1cGP|+I^)T zv0+94zcAJ$KQ0q<xXYfK7lf8-AgA!oLFNR+-uuIH zBlyzT6g-MdX2FqGj*$m6_VDF{A$DlMBL&Yi=^C(YC-|ZGb1Isn5opwc5VOb;3@!(G z2bL*A(q?EhZ`6dp{{fDPR~9_{7Ob=3wUhU(pm#TMvJAjjwPyF9d===(5*3$+yQgep z8cL->_L{q40fOY<4U(xtR}Id0I#QV^4xdJN4@}89cX5opWlS7i@b8U7fg;5U6t)yE zT8b21v_P@8Q25~#r?~5~NP!k8+TvE+-4|Hg-Q8Ulm)&J|pZ=fR`{E||#Z68!$s}{m zo0F5wnVIj0ka#Lqj8(&nj7sIbvDpH{+g?1HjO@+wdMxVl^Y2-4x}-L1AOCAHKCm8z zFHcWu(4_ESM}(G*cq?Ha&LY@soa=F`|H-#K`;|;jujlE{oo3_fc@yQ1LacyDoUIsS zdpb$ps7rPpLN6E>cecS$V!PL_)D3LQM;i}hMcY~F|N`NBZ-a#B%J z;|cH8dOYW9zaBiX$*}31(2Fy1-dY;f8X&V`qhnbg=VjO#tf$4J7F-vE^?NXefBus3 z$Mt!Jf04SnpzYOkj?}9ciKNZ&=dYtnZM};l6roF$8H*|~j)g!Xi#^R`pM1#zt3P}YmQdL|y{e|ei&^n(Ls>{E7x=J2Z*ZHlOFe&a&7WYPTRC0rIuF#b1R z^nDOF<58YlV2wYH_;7uq=VA?RT92-Ac;;6|=8>tfi-1<4+~~Bo?8?q>8ha|KwSx`{ zb32>hJSQBaIP^UmV)#yKQ$G`MiXL1HS=V-n3ODb!gOE>tk`zTdWCyD=Pak4YX_2KUxW>I*={}lAG`@70!QTlb~J&0BUl56rk z?AMEFxH1Od%^@t^<-_A>YYr6MgmP8R2%AzHIa)vjanxT&4HI> z7#aIN{6hYm^~GY}YegZxkT0Q}WXXXR7ixMm(K8=M`DbTlFQlhQzY54_@oXV?%sT?P zS0jTv#_F~JHw@)?ifmy?vO;#bSi$cyr$P{_&knd%yz&h~sj5Auzk3Q6)&)H6 zR|#_xY1QBx!qxomC+RFJC>5s8rI+ZGvm~WS?|N`4FG~7^s6LI6&Mk(lyJwVHz0`_x zX3sc;krS+J@|uT8C>7_Kzjzm;!6<&`&kkJ8`;_hxHsx_y`rj+_N<=5#f11#Oj`EQf zH=DKM!Yy<)RM8@%2Dk`?D{4`#)&`@-xOnz;={>{fCqP3Ql!k}NeS3iwg4mA>XtPsZ3yDmnI)Tdu{{MNlq{ zCr4D_&Ru`~N}5;oKkd==ziQUki8_s~I~-~QR%@txrQa=Ta(xc}Xg;hJd-JB`qk|ZWZsSIu{Fg;KgnRZ^ zA6f4F1iMyz{%6!GS7Gi65qyROx~uBbxhaBz)bGeuD8jwXnkdX^Tt9oy*7OX;ry84J zl11{Zg-Wgp4~LF4o&d{b{C375Cy7%+Tk7vU%Dnk^ ztS|3f0XS|e%XnARSsx2X^_-tkyrzy3c!TF?kvpWkV(!vvuxNP7+Qz+4#G^JM#K@L{^Da-)4!4x)!@S>j_>y2SsG=y9Ay{&IJlVh zL3Fbqd&DWsRButV|6fz-F>^p1WLUrr|NSyntA`Pq$>;z68MIeHlt^Wy6y8te)$eoT0=wEq`gO!%uW(&uunw z>6tHN_P)mP?*MrtG(Ijr8sOcO*ogm|vB%4g&L>R3r)gRZA&s+})ZI?2>dmy=|IV?@ zeaXnqZLX?wknOzFp`eiv$LSPK)Nh@`{rQ)7w?Umh<1x@m!ZWpai5_4m)GgfC=}C$I zHD#(dB$wuu(5wW{bh!IF!Esr`22$P2jH=XXM;9XvCR{Cz|f&n*|fn0sJF*S#JnW*+w?_=zLu*_ZND7V-3>Ajj7Zj~q#B z`Uz$7966p)97d-}LoHl!pME^&5cBl)wtr2@td!Gp)y6bWf7((a&0`l|YrN8|zhYSF zzf-1*y?v}spHQe5m>ysm=wa%XpovML*|5J9&Wf3InZheH3;G3@>bp^A*$MqC94}iu zoTfo5avb`H)M-kd8jJbCNU5#IOn~fTEC*3`>%qrg%*+MSB7zsC1C`Sy|1dAw4aL+A zlno>vE!VuDiXeb-hI80&H=>HtBNsE$j`%NawH0Oj=?4UNMdox;OD2CxN&cpopECc3 zQ`9v(=(~T_$|C%=u6KFnq?$5F%3#Y~KI*63+?9h^Rpgk6igdpdoElE}KsYQTgZMr} zvHeYh`}=k|%&8!bvg=uaO5a@i>ytUI=Zh3BQSa@`J1+;6`MGOGueGk^pI9qo#i^OG zcaG`NX*Zq#?8a(NE3a6+g{Y-dyQ92&aV=wu|D*ADe)anOLlDcB!;pP^9F3*9<2S(r zN-|cwzebC1mMq1%qbP~q5k4WdP#W^Gl%h@ivFjkuVa&i{NWaEtD)hc=u3xy`S7(8= zyH0icNxQu=?Xqn5x8G@Q_M4$!+#RHztH#TJnkSNYlJRTnu@h^p%bfHOdkCj6ZA^W- zZZSu8i>_1ERwwayRq0bBsf2U0XG$@@TT-E-hrN|h8}ec2+F!*0iBK@ElE*q8h5U^{ z7t!-~fu$q~1wZkB4Fi>^<&MpX7}%y0Ul{}juy~{~gUid5^fp5p+zP>hQe`M<^|H?XzWu*SR63RKaShvw)n zJFx&Gs)<@6ExDdl)>OA97SeED$r9O4>vlWF_7Hm91y&!cqN)+>7KD+jG&4C;F2l>}SIE}+=r;()8N^!Hy6CfH~ zH+4>I-!~3-pz@aSpxU72%S?~NSI_Ab@COLfAN)@TiCx1$O|s`^sgqrPPT>-DA8kpS z>f;>XqMRaoWSW_^JLScnS~d*h{?MettIyJE1hHyM(RL1Eh6?!lfBJcH7Pv*4-8MZ>qhQjY}oPQ z!}C~I8n1{x;kkYMs9azEiZw<|UwXcW>6Z+DU7y0m=n~-~caNu^%tdoc97vA4D{fov z;`u9`$jz~rlVlG03-ZABx%i&6!f?jarD;;lTihEzyIM#2wvz(3 zk`4ccvnjyPN1vn?l4X-(Nbl`uOzeR4!{pDU`F#AkQ*>(imPoT)gr~-Zsr+AP8W4Q5 zrDXo^(-wiE%DfVm>G=xthvyN#snm-!>LXl-h2ssjv70vCVd|Xylp`;Owum zD4&|86njJ`7UkhkC-NcRexx85v1)SD#d#}XYHBDIz&0yH|HK26w40}(+fEZ`>(;r2 z^>v0wxG><)$5}gZmAt=a-o=e>$94G6m|kTwAKRuj=2?7GHG1{ot)Yyt{wlfMQH8F7 zVXqoJdbR^M5?lG@lcb5Sej&;Iolx8K6a_^p38U5Aec~gLA0eO2d0xH6!po38Xu~Qb zX2$4z{QbcrMtsuYfQhPG>xng|Pa2Kf`oWaJ1^#eQ5dJ%HMbnSiylMA15dK{YZr|4< zI0L6aWy<9YgwbLaEGv)_c0y?bxr4yD|GtkJ7KN3FaW-4lU@LJfR(bFppY23N+a6hS zn+=&=*Mxu73n?EjA~u_oK7H;GD^{A?I~Fu9VjMyC_V>^P^-005N$3IBY<|t1=b-QA z%=Y_NB&|y`(b!Mud8u%iN_?_(RRi;a@}D`dYTj#*R8jfP(kC1?wOvzAB~y!LKGrOG z5gD!$RKJP8*SQo5a1GS;An_a^Qpa!J>^wP4kni32-L5z=V9tr_?(tve^7MjVYmbj1 z7|<0^d&$rl%p0DAIm;7>Avs`7fcbdj85Pw>m-{r0-|HT?BUGw(oLYkUIWs3U*r+I`r)l}qY(X{7X zAKM~d!>Gy2HP#1bxxpryazc)G@}FOhU|7iNFL9L?Rb9L7?4i#^}-r$k?MS+f58p!RR!3L>uAkuR}NV`v%(YL6IQ&1zq` zsQ@$Nzx>Q5Qpc+B^23;-?XHZ^w&2h=>&90=iP%pJ>#`dw&tGDFl&y55=pFu`v(r2_ zg$K0In9ko+lG^*qOV4j4a2no87pkuL{w?}e#DP2#;To|*90~t5c}PdV_cBTRWT05a z23L=GO3abqXcpek@A*DZT}`xM$NxP|vaFaRS;Gaf7Q?jWs_n0VAeF=f_L&!^(LQ$+ z&%&EW28fMQOJA{%7tlmkR_TY~IIpdkmwmPbnHk>H-Mnm~unIoC+;VN-V9hq zk#R6QVL_SoCtyX{t2ib41C1u>TS~{c(|Hr#MTDBDJlDu{ENvq&(EigT(`>AR((5{6-_ab#TbD?lpoSQz zerHVTZdL_x=RiE;?f`{J7egsl)AQLMIOfE)#LNM!6AaCs>Z;5!p3E=H_4@xc!jlp_ zY-lKP2w@l|>llA^%Xid{3s0ovgI=;YFhSTkO2j2%KG>*TLi#A}d~MUmNQk?>s-Z zh_Uy63M4)57{mTe4(Orz!p3q(-jDxG%ncUuf^2;RexC19^u4hJDqOnbRve}d1JGp88LYoPegN5LPyT(6y zApEDgC&dJW*(@ow_EHT7gX23YAB2=5V2^4X5x z;de2ouAgQUK3=>8en@$0vHeLt#x5y^naB=C^Q^^njO8gQvEve2^U|`HTJ}u|ZI5@s z!YZDD@*9Qn}PZd_AZ`YF)}Az86A2&g$!c| zVl%d5un(MaL#<@Yl~@$QS@5<4JRQH!x)jeOYXqf*)iX5`UrJ)1jHa+ga|*<<%5k!e267 z+ei8jI#RgC75X!QNOZ0iT!5y}KHR1g4%Z@F)&GGUGpa6#PIu*N!MvO2aa?p zG?HM}p?(qjM!RC{`1$vMa%?MWfwyg&4S(NZ^<0Pkwd?k@{t%^?ev3|_7YcZ#;wqiZ z)X6h%OJ(Dg^mzF9`BPqfq_D_Gna?XUgmQF){kT}v#Pa^D>X1yXpiG*(L}3aNO)Y)@ zCxA-^()YCdE$#{XAufLSUR&ZL6Tf_DwJ6kwsVdC>^wcoDkW+S!xNL zMz0{dn)sp>kH$&RT=&6fBIVx zHQKqUJThk~_3Dw)t~cjK85A#GzjJ*Xf%PO8XN2S9PfplFLROwE?dP5viO$nsrehCR zcnxM$dITAH_k*mXVuMU2U&tFb+322y>#>qBK?Q9kg?%i>ux#bZdN#TB^#~!f{M?^U zXxqBtx`KJ!EF)66&{VJTx&mv!)WK2T++?rIQhS8Cqy*Q-xDQbxH z@XT__SOQv~=_k(fj4*HU%2fY04}bbD87JA=*J77Nn|3KzuJHwdvelQ^1p!W95?tRx z4<8lRMTsd5ZNJ>@R9pTcy$juDSMMeE-iB8l!5^s)sf${YA$$?xIP;;_y#95}BPV zbtX)yZflQ^WBsiVU#({QLXyWN9k*Y$I`EvXoFYvOeW=0d)L30viKGLji(w`~?ThgWHueHT51=pg z+hZ51P8*3-R%fe8l}fh->O7}Tl}VuBwO*Vu`ngGKT~s_B5=dLR+Fw;5YE$jL=UL2R z>zhi^C@7i`ONVo^-Scr&*DwL(}K7)&k8=Vj8QXY-0DAs{Nr(SAw1OZIZJ1iuXMs03*t&P#!t}4 zAJg%L8~2(9XXDu`qx&{%mz6_wT$J7oQSTBJzwkfEAOY#ubLQ!0R{5xB%NFHZp^Z3mz3X0jinmdCFSiu zOuR0R=5x&8cwsa7V~Tt4s~FW6(^mZiJWp=e893NuR@w-oG zqCWGrj1#+(yC-B#qo=OO{>yKze&#ZDG-x&o;eY?uFT~18@?tc}+M_*ncg^F3JvvU< zvyF->wl6~z@MIPxAND34wRZ1rT3G9pW>&7Dlm&C(*NBw zTx8Fa$_U3m+M?(iT!SI>Hv2<6-I?CIlgiwy$+fZKor3ro>7U1i{Qbm6W@F5wup!23 z#CWQr{QcuFNs*-y#P71gcHU*sq^>39g62K zY78PZThsZ^T|)=Bv9wrjXS?R0E^ikm&7K|ZYDV&`xYTr@#yj;*zK)-fmRP>Sxryyr zy@$F&s09aKiY!IJ$jY9N>w34BE?ZHQW(=Jf-GAOJZumCeUIVYxlM8s%%?r6ulhk>> zL@t-UUTZHJeD{NLxpr$SKssOR!BG#-61q-03HP&LAcJjrJz4eBq-_YEtClBaqP%$ zUniViS3!R65BFJ64#nklq)1GS*r*WMq28E$77FfMqcBOM<2JKVa=(aBi~b>R9=H|z zn-)jyxwkjPS)H$er2o&?-Oq}W;dme6ZFkt=WH+ui$=WVcdd z%}g)P)ltT{UoI?a40Trh`&_kbMASAVbq8)-wQ(tVMG>i#itx4D9{$uvwC~)~Q zvGWvL?;-y6dY+dR0Rhpks4uD>WA5H?uCbH%(;=20cLE0IKkn<1_S-kjZBQmm1lY%6 zHCq(wGjJ_ZKH5!u&#@uxToEC0RgEy*>?IW&qsEHhPS=>LWHm(94Qos!jL52d>fN%5 zm&B&twIdu_-5XvpTpS|?cYcCX_G?W#<_vswECRLK8w9W|&` z8T*}0+b5eqZk7ucDv@W$|6FT+XY=+=CXn0ZiUp^3uagGDsx@!B0Q(*O7!N(=))CgV z+jtm`s&`mEL~+`f^@u;g12gRWa}9dMSL^%-G;(TavmuYvU2d9%8I1fRxL#GvBK2T? zBi>zmknJmDH+W@B0G)A18UKb=@_FmXt>MLe)oMZV`SOVObQT?DZ|brSmO2`cL1% zQSfNhw$+w<-nF>~sau^dUa6z}zn2PxApcn>kDP|2Cco_kVF8 z4t@NsppEzPUYqbYsP({=Z^IsEHS<{{QIz%a-2`9ifr`$4Qp=blCys`v-SU z-tOMfltQ>S5W<0hqGZU*%8muHiqHlEX-D^Vcn3TlB2I_)&`Uu0aNpVGQQ`{ZMA@_k z*b4EjZ6X(Ns@h2-|FOHFIkUozVY{u;L7)FkO>!(f-mpf5s^%7cHstPD*yln zF$BB%cF)C+!mXQLC|XFy_<6eC=b_FLM<6!N7TD3Qd>bO+?>uL|;9cFj`lld1x3sX{3JDy++^#h_ z-NoKRAWc_>=jT;-Vh~BlkfG|teHm(C0*q)v9>LK!9$+^&uRxPAfZf3f`q7f`$;{)% z+7lqOJ{X)-)Gp%kcJCGm0GJ@oJQ~x-{Wlu+eqVxYAV%ZK z0bc9#nMxlHB>DYZZy$!%DjGe@x}z5+xvo_F7lHFex> zM}^KrkO`8Vsz)G8BAbHH*g&Y85%p`RH;E7 z16bNw8koi*tm?7>f~);KYN7BexNjve@P5mg+1O1Sa|+wOym?p@?wnhK?VtLB?=qc1 z*AMZkQ+*MT5!Bq;jH&Nq*X@~;&FdsB%Ig(n$cUOhd<4Z^1(hB|ab!XriBJA2-W?+% zn^v-yUauNwXWN$m&ep{){oMMou_g(1ysarM0O-@8Nz-~YznweEm%js^SJSdEx{5qhT z2)b+<=;L}DQQ5hZjygNLh9%417E|XbJy&>+=xUEhd2tS7Hr|kF6j{V{kzWOa0&aJ& zo?&lf8)Nn#YQj|Xyy+k#520GGoVNc1K<*NA)NG6cxt=~8_egZWmqZ2=AtdM?^x2=~ z(1RKq;C!IFMrIBnA^6J2+siuHCnhI(LSa(NLE0I(D4q019ORaJ*6P8vJL( zHjw14l;Mr@YmILZnb;n8WSad03zG zMFUa*1ao%v2L*cjf$D(h1MqL_HR%r2e;ezV{mZ?+k~3q68qkq-l^7^qT$Oy~z;#&N zN&W5mS?mPA69BT|eIa<4c)hmAx8U!FJU(BA!eCdIgpy#}8>oInnFX6J*j5a4n~1tZ zixnXUESv{uOxpN&)Z7pPqs7n-hQ^+J*rlvl zzddy^GCDIo3Il1IB8_MUk8W8>#2qEi*sJFWt?8EnOly|am~SBWrU)Yd)u+wpKvuGd zhj{+?DWu!<2!+wjJX}`0ft$O#Z4{5Yn+BoLs_`kg2G=WC@ggkdP@Pg)RZHn$ zUt$@)Y@GRR?asLDew6D() z-@r7oX}G}%J#Fn+yo<+G(KkmJ`4H5<$7{31XXFfEd}#A>C|jTkp4T*^Fvo_d3al~& ztxs^?Jwbnb{33H=Y{&Nog9aLyAeG-5%Qte;ynDR9LpYbJW`b)^uEFfzvQf%V3TC%G ztdof$gCoqtT(i0Fe&SK8*6~dCB6xfvpjPU72yw%Cm#xtx`IrwxF`?f|BR>V+e1RPapts_7fzw_ghRnP_;xl|6# zzPL}U2f1||Ctd?CW809*=&7sZTXGP*`~@f(oRtMdt>c0j+fP~oL4G%JVA!;C$KTkQ z`gT-_j2#-DdxG2;f$MD#11Aga5O<|TYkM1qf3;%Qm)DN(ix567GgiZ1TU2YM*-R&C ztvfB1;6go#?AV*w!tC?M{uO-Wkbq~lUp^%J98`kV*K-12uCICJ8P9#W{k10L7`yst z7g@FGjKapK(p@QE%)OgHvq7AXBm(`Vb+47Ama*(D8lnhq{Xqn-(y`SSOF6l zh?=Vk)YzCz&$hzUm5#|s&upNO2+M=}cN>*1rA@<#`+(zxnJUDeYLqWJX=)mi zy#AFTrhl!x2JflD!0i9=yQfTIZW3%}poiL~;fN~!H_FO^(wMN3;rgL5eF-=CWK(O@ zpxABf-bA+F64E(y0=-r{6ujXM_PUADwM0n*RafZ7jcSuwfmi;<+xajwswlm)UwYs1 ztKU`K@r0G~Lyv~+J>=}M5CMDl*BgDjeT#&Zh21iwAkxttx?!0KfFM z{(;JL^gu%rfcnvPd&Q5L!oQsK9`0q)AWMov5{n{tNT`(BiynbXON!5Jh0Ny-b+p&L6> zcGpYfutzWkIq{a7PvEIivW@(d(%-`(8DFZ%4im29_4(LG02I{bYj9u#b9+RsltE+w zEsTfp-TOtS-oVv>F5{(;4kA<`?u0E~5p1u+TZHL-D{Kipw@=ujkUWy@=;XC}^@wau zg)Cj~KNpK_nGS}Sg0H+c+&O*yOa5`x?pM*PJ%=ONt!ki~Fn}o&*cq~V)OGC}=e8}k z0_#*BPcJ)iZH_3uTPXvQIG^wGfxV#1=c`o^R9W5Fz`FnFUY9{Sa5A*wz!ZIP=;3~J zPe}Q#x8rsOyT7Eb2RTM_OO$&?jw5hNZhK_oz2faSwu;NP=6xeBvmxNU4kr=} zuL2;bjFeyObzlUk5TG0BK#n?2$$`c?{4hdiPV=%j8(R^$?D>H|#HzTn*BO(Uy zaNgm4oe4nnAeXCFwlkPdo0JVZ7#qV82_v=$U9Q444tX2GSKf_UEBbCXwqlNM^mOjB z+-G9H=2p%GUQs|0lVIcss<8deHEJVpsizIx$hG1cTbGxf=X|Jz8vmz^xtr;Dj~Mry z-e``-ScX1mj)V7+clUwn$8^%R|5Avo>}pxN?cKFjGARjJud6O})yjBl4AT@87EtSO z>?93+Obctq+>L2S80WmW`*|U8;_7w{kvL8aytGhuejJAC&BE;n;oQzBU*;PU8OE%u zcB-ov0`DrcS{k)XFYTJHeY3g8+wKLj$uKf!NNG%u0_VAJ%*+h}AOq|OxYv^CxbY6C z_kZ-E&^-qn+dJ3B{=Y2D!qPxa=ZV$^C=n;%|6x61yooOa2a(H{Yqyt0@*V0mjlNdo zZjIo-p<4WR0>Y}rpDtV8SGeZkt)Z9BvR|96851CN-;;$39`=y7*sUv2$#A_hZrw(x%M$`x@n z^QYQ`EKtoq?voPoG0lJm4s$-|XO?dI8~_^pQC?7?Udf|oAal@9tR+1jwIazIU63c| zW}?ecjB%El%S&7vyKMM*y*JK-a6S`-FiKDM3et!3(f#5}Jv3~93SuK&yy2FZ3>;Sp zhqY6gK;7cXEGfUoOOovSQ<#PGwMU?DJH)WGRZQ`uDEpus>v9Kjur>({-1p+t;^#l- zuQZuA8ALxg3Xg{NJRDhp#fsRN*c`@3k}Y>X@A|Hmb+|%&&bZE2w(v1Vv==w+>E3Hg zkK|1!%IQ-wl9;-crwg%A$jFY&Pu$facyc-qY~!12YAxvgan~7qG=W;ls|wsX8)G^; zFh#iN3MXji`JyE9^$;ft4e?4kquwV%bEyh%mpldAuavghSB3bSdEOR z7HF#fa7?S;N`Ag&oCQ5|x|wQ{E=G-vX{?4N16=#Pp_^GwyV&DWJ+7_aPdvS`3hBIW z0}qGN^Pn3ETCm)fNJ5qFxU$=Jvkv-RLjhQMyWr$DJ-7R1A#YH#xOw;$CBn#nw2X$wnOVVJAKTid7)cMVR1 zJ^Y~UPd!s&th$iayA#L>5PqB?eI;_kRJC!Nv&(f=4sf767{V^CS@E5A&Xx%{@6WaY zyH)Q88cmEh3a;UnF6xn2Zu5>Cj%KjN`8M|<+S?Gl9kSad?QXyoA3W3}cpZXx_jzt_ zDv8i&0{pP|fB-wX&9)W|-aGqm2G)n&A8mfoEDXiJ{PA@NvI+OV0o>Wa5E4J^9OxQ^49`(F z>a?4cJIdR8wJe-sk-cUZK_GiyVYF~h6La)M3 za?qz>k2QI3AbLzF zUO>J6u$BA+R-A94voM}`39y3fyls|S!2)k!}E9{ zP^Pk^7WV6>s)+t3=WJ|Mj3VB_>C3$1)6d?T%ZQ$%OehiPauZ1YIYDwlL1Je0%P^X^ z85jI#9X`kxEg5$7cy=?c)#R|UbQCKZwK{(@zB6gV>~nVuo|ZgXxwd8vDKkW69UMVJ zs{?jS@})AH)01X-)fgqC|w!A(M4usxt14zaX1r0$Eahen;ta zO)DUO649~r$HxNa#ZKU-Eo5cr>kel?OVwgVc-%gCc~l|C{JJV6S5WCFiqIaGsBzbm z9+4dYMh=Qz1<7xI=jC+t$zpA0U;0Vl)M>QPGU5fJq2hE8Jc!_LL|yjp+lfHkJroYitZ|4nLN z3OW?d$gGH(z_x8DBkZ&7@YZ}yf}?eR$dU|2I78>F$UO9W$C1u!kEfxNW*hMB!W=(j zh#FG6`q($pN9f}NMBRoHKX}eukz)jo>b0K!X$CBAX5P#w0B}e1FINwaQhlFm zfi?)y(mzZdqjBIt9Ot7~t#vD=8Ld(%yZy8gdw4hax?$`)vpI+*^S0P&eQW6^3>Wlb zOZHt1|7#?4Ipl;ggs}OxS4;T=x!4YHlREa{4hUZYJLxRP(=P!mV=`lpzjw^kt>ir| zS{c)NT+vIXckT+2MkhBaB?q6)YM?_oT4Kfgt3eApupXoBA$XB}KjO%%U=skKZqLlX z*l|>2y88Aw#V{@S&uxMqGB69+C$8%`_ye2crFt2AiB--)9x~NiMc7Fh4&h6oeJJ#1 zcOeeu@hF0bAv6lbe* zQY$Gg@jIc&S&9o^U?oP6|Hhtgt88z(q4!Ee_gve}*N@0YioGX-QT!?_BG7DIC^k!$ z$opn@aHb1oA<$E9`YuRmE!*P^b)$=)az8B~b>1CZ0d*rWY57?tGc7>TwqOXM+t_$D za?t+YBkbm;T_c;R)uF114*a9m?RTn z&0~_g@hV(>x&@%>KPE;rd|kM_z|?x|_18T2J98gkR-YeAsnC99=_lGDDS5*?wjk?< ziRD|lLyx?<)EyfqwzV^fc?TzzNJ*v(4>Zv_jK{Nkg!iDV{CK3i-~L;p4f&p0 z!Nu)7EKGUZff>H{2I1iC3(WXTDeOLcN2fOcM6Aa`<$j~0kaysF0Lm!w?RAgnSbJrA zVMD&BNs+*tY_AeEHG6}Fw%_Z;edYA)4%& zdOKrKn6__KbC9yU3s?%R5N~Q6)h;dD5T5IIU(C+Wx^Z|!)(ZcrFZi<1q;~(=;O)th zp}~!QllM3WMO}po-xzo~c~0hteZ@bYJF8;l?U-7VS|hVo?c!P*$f_oy%ya&}kb8xv z(l@hECoRj#so-LtV+||CV7sJh4DB=4SgL<}-Y6Nxw_`3aT{w&CJ2h|6F}#?y_TN$x zu#a7u9`#pm^lr@n_1(GDGSxLXH5|T0o_}_L6iFSJY=3e{cZ4^GRHT|X8ZVen)TGo3 z($8_;_1;xARH*4YirskJopq!{|889*9z~~~a>L(lr`! zy~O=ZnO&xw9m9~b?mTw^?myOy-u3|-wRpIa?g&{_uQS}TtueOao|zwtlE3n|*z(Ma zYOR@!=95OM@Xkn$CAJeyZ^^ffdqG-UyVe!VG{}nnG}THjYaSQbr=G=)dNwewD8M!I z(`+DU^MnemVd05JtHg>zrf10uzNu%8Z>`kB&XFBSPDtA_OXqY@aJqDR1Y3gm@d#?iy^jzgTC z6S^u~aaZ0L#39xWFVl?j8Wvu5$Sq73>}s5mT-m>tEjte#a%qSd8@uB=aa$7(uDExo zwS3LCn=P;m&>83kah_%@7jQr#q&rJDmYdYOpREObc5TU;&pI{M@G4BAD7=}}`65DN z#%Srb_1STy(S_Z=h6EZR6&$9r+8> z5n;ktDi^?QwyE4crXuEu4K*JTyOYkbu4nd6=xW-KGPA-jvQ zj_yOGDWVGONfAHYovNHZaE_tbP8BrzZW7w{wK^o-oyv<*@tL^bs3E7H64lam%Fg^+ z9IYJQT&pdkm7eO!Y0Cw7^BrO*dGkJT)|m9qjj^aAjvM#7HSQ(LiT@8XK+L}tHVU{e zc6GbPGWi*v>@+OR7N*%zX*%onnixhCwQlRuLjX*=Z2fG*u{6Hf3qXFh_gy&d-8ab8 z`ihQD{LeSTwCmDm!mwsVUP8u6Ru`nME-9xAR$N7x)#WVXBfxT3Vb7)2H^Hc7*JZR{yN=X|@H)#kbo| ziV0_@%Gu<__tExFR@P3?ilEqLC)+anb2ErjFY#8U2P@hM9q9VRbeIXDoeR5GTm4M1 z52gdU**sH8b9bSb?J<_hhjx(lH4`yAns$%E>CEvpTMn~Fw&`<=H9|X^JeF(%=K91G zDC!z{uvH0dYVTra#N@&j$feGf%^u!VY{w4A^3I*}Y{%csTda}oe4^^RXQ@i9h}p7Y zQoY*wvHQm57M|Jkn0;sUU$&%jOBHW_N_He-DJwk4i^T+c3&cF#?jP_ ztwKf$TGm#D@X6DTFn0X`i&)Ltq-N2NM zsc&1%?l(1sZb#Ad$!d}w%fvF8+@i;AMbu5_A-do8-DJnESo^R#s5?8@#muB@v5J8( znK0FXAt-)cP4C&QO?B82jq9!Q#LVcTrhKbSvg}~li8r_2T)?V0_>WZ_M7 zG*eg0683e%sAcq)JLMqBDoFf$HF#?P~@SXOr?OdU+NEckY72&V~P!KE_| zz50Q$6K(d#4!X&mITOCZ_6hdT-Xx(H-62|dP$b7z%VoHjgqWhs=00KdT{um36?uB5 zk^Q`ylC;+_lf~&Ot~hNEY~3a*gWcv#CNar=V@Id-&}g%Cg*1lg7vF`%UfMpGo7lp; zFwfKkm&GuZGUt$B^GR(7Y|HQ)eIM9LQ`M&IOs@TV_M#?h<^iH};^Ay>s~&yyeGYEHE6pqU;! z1IbUUy_eY#Oro8;5)P}01f)*F30o{bcip@`6pzVakJqprwT-t`!3VH!yY!fQz_0Ls z!#ly&PB{DK^q3WQhm`;Uhp(VfTmzWmw0&)Orgnh*zUB(sOW1+Ol=~`kicDwmZf1&V zI6GA#f%(t2KXbh8OgJ#Dx}Xb&**}xSVqWGDUo4xKY@wK`ui?Z`=4YAe!m*k{ zkKxqK1V9SgT+7%2S~xt{n{Ztg_o?him2Msu%6B;E9e;RPIas~L!msip?ChEXG9Sr*Yil7F zen}>VOGI)6d=p2Fz{Q3sFqqXZCK2XEA@!j;T4Nwp6u8)Pi20*u3grL+z|IuOMoTl`^$% z2gNR$2z}ibvA_mT5hvZ8B^L~nE!$iRz=%+4I1t6mvuSSgbxdv|46svR(!x=j!d*6< zf>_ww5=eN6Cau*>MJF_-yM`9H;EB@bK+QE=bDLE~-D1A%V7Y^9TVb}-R>sb>>#0lx zXr{x=96qTplr2T$5EvgBz$$i8Iv$-+}-D5|oM;Jsunog`~C*03~$+p=)izfj#SBC3p;;V^>0Tb^pW^a^mC2hTW8{r4Gv=AbWI<&F zLFPBFJdODuIqbHrkST97w4K<<_ur%q6K~sS61#-|Y>Q%hZzkU&lM<&an`*MH5;jr> z3SdqZI%USa^&?<=Zem;@qRa!!SOHKv>efzG+?H^6<_(BP^{zUX9AtMP zOWOeamPZ6Dv~=D~)!#M~Nn<_8tTiCQsE*AbVy( zh@Ch)fYmpJaB;=pbi0$H$@EY`En%sdjzrrj?PxtO+nItMQR*EGfk6;si_i^Wp}-7TRGwaC7Nq%?r8dNW+7%b z*ah^0rpY*dB`{0-CH}L;JQmXUPcba!XLFE<-rQ>=^0O~jI}m}-%w}Hk-#gyh;tpEH zoknrnMiKf*sMrYf&5EDahA(1gJabOuf%`@h#A7#Yom(h+JJGmBW~qRN9kl}Lv1I|a z#GFTHZ%#)iP$r`Eu*Jl&;zXN&g6a;x!_L;sUYM+`;fvUkWL!Iwg4vx)5G7ooinBK*Ju3BY{7MyShZU zz~KeBCo+s30s~^7R+<&{02<2#5cUzf zC+HpAG*>_h3qvCeBEBkU0W%Uc99~m?XmE>mF*9?-DWLYYKz7l|cfwSFAk+m%b_81H zxtJa^&6tLPA!4&*Rt0a@5gM3v5I@0#`J{iUP@NRV0$| zbpk3f8=45pEl#zycPnA*WZMN;ggfpC^}_oQpPF6q&m`3r%>sd&T(z)kl_UTdS43pU z+DaJJO=0wiAMI!Msl~f%xb|jJOad*COXj03q1qPWUoap%2bV9C=|!lv8Q7M?`ttxQ z5zriP{{$d_ufw}XDAI(KfgqV(@)$#E*bYPjSDgGa$A!VKY-e)~Aj2G5!aWmLlTkll zGn?1}0aw!}fkv6LVfvl0$nN651%|gPu2Ope*=2nAr8VVtW_Mvr@?|vtbPAZENKs@E z#lXOF6C#AGR!f{YJ8KxWLcC(j;>I5c%+v-L>Ksc z-xh}V@H;-(T^$QQOQYfp0??tvMr37k9u zGr8)kK-UGt6>PuWpDla~TNEH=w=cGP7N-%yFH;)){ zr$!`3_>kNdgEp;z+SFo{7TW;QVH(Zs`aA-YNEUn8(ZFPXtE=z<#JPcqE5Is>Yi*FFqeMJ^j2);(x%zNFa}^C>`XB3{Fb zF?WaPB!XfyHh9~%dZ?zzV%Z+3U48khw_YpY>?$2_eOg@Hnn*|KFPG3n~6lE$)S zCM!CiCIAalGy=WdT5&Kj607vCp9`Ye&M{169^+tSKyh1?gs%`zCEhfCJTazcBS5jb zhr@M1Z?{=nKX&od4P1oyIJa2xYF+^00HV%x$3D`ODZ z!xiVW;;Jvz9WOg1cJiRe%T@5*Cg$sSN!=yP*329vloqS(W_Uuo%%l{7*5^1Bgp$8KTexZ)CC9HLuDjo7)a5lZvliIf*GRbWGd0Lp&pjlVD{sVF0lWljF;~cYsZPil$}rYe~ndVdF)7- z1X~#75d=03NC@w)`J>pGK+ynif%Zh)m%nLV$QGGob4j;Zs4d&O7n zyYUKS-oYHC;)a!5v%jjHEc^z1V$d0OZ+;O+tB+BTSCw+FU;4A+OiAGofQdhqj*<>Ymu+{tmS0x-a5*iRE(7m^kK z%_I)39tcqZkTSh)huZXpyO&8IN+U0VQE&%?jJtiS6Kw`EZc>wlrC3larw$ozoWVry zxtHP97eZiwLLpE3UL`?1{oXZi1L#%SVhdPOKdX zv)1;mCXXX|xRS{BWIBSfw4Qko~4iOE9t zDiD-ep8|U4Cb|M_X2NkZAT1IVw0E5Wnn6b02}V%ZbddG{)h!Og(s?v}}Tl-}IR_u0T9|rfv)}$yu-w7=v5W@&0!K}j@smPJJ(ggTDuyV4xc2|I! z5!Kn*o>3M8V*LaZFctGO2>WaEIMj}StwbiDn^K_8*bdY0EONLkRn4tek^-PUf$e9I zJM({MfFpMT3mR}`>!&*0B2YSW)`2&%mj0O;&k56GHYB(VB=*b%f=CQ|O74@4jJd() zh5DnIIM7^~EZdTXlql)k$Q-2Mj3}=Jo6CGxg6zmh0u$NC3BO3JGLuzg8+~jl-xUq^ z9@-ZAgCl(pr{U(Z8`;#r!lt^xutx*qS;>|4V2D)lIy#AxKw1lEryVZHNpN$x9Iv}H zJ*!X`J_Cp42DV@py4K>MFfGEtIe_lspqijDq6*j26tBB9I20stVS&Nfh@=jh)oyLe zpBzpDg{c})wZ!!V;}NV^qUL+up8+E#V7vqYN8htY!@`?Dn(SBcUlzp{W=!F2h!X^Y zW2JNz4u(*Vi zWID#h%ii3=l3DIej%SfOVD>)YrY+e|iJcoeIOf(tsmYO3q^&}SMwMo#57`T2cunit zSq=_>5JI4M)AuQ&f1&GeXlz~cbf58^fzHwG$sZXl1V&EUAH@^tZADEBKu_^#AE(Kp z6*D^k5$<@D1$#H6)8LRez=9xlTl$q8#q8irB`wz3NOV$o@g}$C-Alt%eXsFsjo5G@ zHR7ub3Ri)9r$D+aBAmp3gA^0qnRtHCQD#&^u(rr`K)nP|6`GPrH8&V=gn0JMt`Xr~ z+ID9_$sV>&z}vVmrlwMasD*%t93Z@~oFXM+8Xn65X(nRyf(M(b6M@nc_psy6K{a_V z3eh-2gl71AqJ9SF#SGd+^21`<#a9Zl&?4RfY5jm+I0#~S0-mSIR~b%d$Mn`E;2uz2 z#S&(a%!6<_TXA@1dR=1<7u% z%Q!#~w#*aH@Nv;<{$fA^=1mZbBo9%b(m`TE+YS1ilqHPJtgKr0r(+xmsR4j^e`32o zYg|C7QVk^nanwE_*}6o2hVZruqbH6EzNm!+Ft!Rfb6&$!Fp)K9j3Z_Khewd6+B4#B zfn`~9;EW68F@^pq&?j%Z+`X29E2LvTs6&0+|8FhEpf+ek9Ikmz%{K;Odg;3E%mEJn9O z(PSdch6X;+f%aiY3S0FkFJW|{Z~*;=iksn#*cCdg#^3|5jDRo=IfYY>kxb^rk#J~a z`j$AQ7Wx&rCP9eHM7d_W?R4B@Rgk~z6q}G&@z(v-Nb!(*CDIGb1WMF22_p=oY84u} z!enamK*Hru+S%?+gcGbt6>&0&g%D@d4iY~D>1KOf@;LDoG~8q~91)}oqygPi25x9( zc3H)SbAzk%Xr~3e1gH}dzrrM*fk^HAxIaf`G=aeqh_)ZWKCZ;TyEm+;5hyJ)q z4YrZH1yRmG4eZn8af*2g1yY9@JCID*48STR9c-xwfTv>vq|-27e!Sth;)E~ox%@bq zR?Ha0jMWLhzlejfYBA^7|BClE!!Ep+Y@lj$3>Lhs$X_m55Rw_^6p5f2AOOUbKq#W{ zM3PjbXi}whoLMl*0<}l9?199)Lg`!w$m551Upm2O*f~q2J+Vwc|n_;OJt6fX5pIv`HmXH{FLeH z?h4swX7tSPx^Etb-5e&|Yupfw`4ZL_rW&MW82f&pNLSlB*{)&J^)-Ungck*u)XO!{ z*tm>#S|D9S25Ls(SPVyC)~8To2%^b3=zI(N2LBi&A0F-!c^QyalWVl-;!=l)mN3Fq z)+kl*m!(V2`x6q$CNaK5`hi zt!!{YWTWVW%q*227F0NSpaX@YVf!K+(jyjZ>Vk@y!9@KOleWqLLW5wLIL^?1I$=x{ zCnU%i@;{V0^k^)Jf<7z$b1}xbA(2e}aa~)S6j)Um;19lI4+kFJx#o)SD@HTP1*U-jfu*^eNK z5ewHya@p+LztlEMY&TV-LcD)=!m z3pY~5Ht^ZdJjLT}^9!2F5lp(n$D2X9DbJaRAO}QXVkiRW9K}wFLIENH(=Y?8%AVe) z7G=2Gq)fnhGDvoCg3a*3(z3%>ND{l1CEG%x5CALSA$$G_M1mM`N|CHIm-D(~H@20ISs4uOeNyBEkVs+BQuDlPz|EfJ^+ zsMCF8l@NkTIN{@$KvR+ShA*&!TE{(Lo_A$!lW!wa#&~tx8mO^;J-f0Qy-Hn40HD-! zm}sG-&Aaj_8$TZIALiC%qeN&%`r8~+tbZH}ThUVFj@|2HN;60)+RguSP{oESY$gWuq4Y;hG%X2WyhzIJqX9r45=Apl7hC})5fPEA#UFed!LBl zOpl9D8K^#05^Ozy!5r*5B3D=}(EI10R{%^?7Dy0piXh^MB)o#R?Y_cLB*ce+As9s= zGxQhuKhT!CuQ~+^6A3bSC?zyB|AWY4s~>p{!ADTeiwx*IJV+9vq^#!>oS>x(ps-+N ziGaqAt0_T!s8nVE_TU7Q%HG25CzSwo1{tReGO`mz!7kT}olVFBSZEc)*#R~!C?t9l z`ZDS#?CJ2?a3Uu6159hku`P?RV=u>?$q7Tc=wPlMAi-iXrA+zxD}#&kARh;oA5({K-f++B0SWfRb*1A_D(hU`-N&N z0!4h};^wfDKqJKo8dL^1WENS({20*sIx;~FYvWrb@F9~N(G9@1%{@!ZhS&uD&e^R{fu`2)oeA41i0D0v z3qjT>elghqu$w}Th{docBA^D0M9c-9t}|iLbzc|R-EOh2ao~WSklY?~%dnTauLfXw8afn5@Pj+}(RBKhL z1Sm|iWgci46dC|fY&07kF?t6ghI0e?z-`k)o(QTI=)K?90+zlC>Rw}2G9HLBcFTyf zsV_7eJbQHH=e&`RB)dwEGgLnzg1IG4obia-?J@{JLmGn|RN<*)p^#-1ItrQvJ_Sjr z_cVuv*u3M%w%~#nGRR5kR<~dzA`&Fu3|{Hk#ZLO!o-`=~5ZPezKE*lCJ%GO%`)lqG z*IlF5(;i}y&VVesL;#GJ;GCSm1vJJGa<_?X3(7bq1Ly(}+?MpdN`~YMH4HV_07?qL znGv@tDkpILJBcOUItQl_SB%5c8!ou$I56QOiA5lU#>6Bj4Z>dW zVL@g_X|~G9%ts`l@0lC&poHCoY}I!bJ2g|9W{*tH%D2as4HOB4G0!6^h8GPwGR1Do z--K-pn{FCeW1K6h!m{Yt8b^<1N6}z{O_W-UZ&(3CyVrg0#kjjI77u&V~OOuaErOFsC6NORsH2#@+U6kyaTv19qYW|?ztY8Sm1Llc(VkIMN`GR^# zECu(Dq}Cb$9Ap7X6${k;7J1AH`485G(g=MNPrw4I;I}oXoz%ZmeynHxK2OVo}v6m-I|ZouaIDGa38|co&Fk8if}`1NbVd*CzlBfSk+} z+7yqelX8K0Wh>N}!CI{Z4SI=awn43U?ke#$cgq!EA&5f1(sIRvw;4wWEl!FqI>^l4 zJ*R>D;DPx9%n)4ts0XPKfO6s8pwWf+HxLXn*jO#|HU@7MfPnyTs!=jdHJMV|q!`0z zw%~Kd(9qE+qPIm?>>< zg~Yyk4XQagP)carck?o`0wih5ERHKxmECF4HJx;8|&RLH5J6ZwP#b z3hVh1KC zmNA_R&@DlETh&8ghQ%LI7~NsAN}RzVqYM<}g*~H+G#H38Oj(^|iUm{fJ}F+NAi_>o z_H+fM>(qQV&TQmdr~oGbGvA=CA>ZNy=yB0s!AhM~2LOKxE^HK$K z!~djvS`+_yI;aFCy=x{e5!^weilP!qH>pGz{Zn8ff#CtRk1tUHsSa2q)}r_u^9!;= ztz%@%pv|E6@P?uQg7R9@-*)!tE)`|iS*>pBX3_9K!ZPk6)u_aR0X2w)iyEusXhPP^VW&9|jUU#L zqJh1lk`?c6pl%+v>t;160S)+gQjr1eQLx*Cwg&XXW02wVgI`Ga(LRa-sU=v@^DJ5c zoU#acadZ|B+(_a^g%)W+nj@h4g1|w7YbHEfh>84>L32!rlGa?LBl(P84jDrdXN__~ z__?;(1Y#}HnE;n$qg!YoyEx7cm^_`Jg4havOy+=IWx?#k4x?+S@F*pc(tvcCOt4s_ zQGQe@f=2Rf32OAMFzr7{>P_!10rAmQ#Uvb5)Om`HWO;)LLfWzPC_jQ#4;B_h7Ge%m zm=)q~jlee9T2B&v&Ar+$f*0gA{-n?N3N=!JaGLR0DDV{A`$? z^bHI~l!~^fAWn6l<`#=XGAH0!qA82|pa6VR<-ClpKgqERwKa(rda(UCR8#1EBTO>!^p+*WsVx%o zD*ZY|ECqqoi_(y@Xk|qK^sF+1Q0F*c1ZqeG5RN;itEeZLiufr|C1ENF&!Mj26N35? z5|fKtqe%-e@T7(Xhg^0WjMxr>*DgaAoL6&!`V&_fnl(nXbe zr&)p7{X9A`P+sTF!$=53-8yylO$EhMfC+^vuBvL;;vMvonum~I!{wNR5>Kv}Pz6%l zXwQ*R2Tr(GC#66~IhoxFF+ookDo))AR}H9xQIHMrc$dKMFe&d>?_jK}np?qXiI}?*gep@Abs;>96_fx>kv>#&uo-Ai2TujE zN8kqbJ4z8V!M?+wxDIG}6FrJ7qV)_Ey@iUA67pY_0LH6=o90J{=K?&%4_#X@M>FLR zs^*~uM3fYmcrDJrUPt#;HV=3LerkvI(t+*>hc8ww4^Vl9gCFI z26T{3!~kBE%MYxFP)BVVy9#2X9Z$mjpv%Q*0syIi&>vVkNUV)ABs5#5xUofHAOt^7 z)J$u(LEWI~2+p1gLBoTh4T3Gma!;g!13|`r5us;|7=~JLSkYkk(Y=iF%N1OMa`<4l z<8u+RcKTttMkN3QKZL*#6(m%Zp`;-`4YD|n7e%GKYl*5D8Yn1h=BQQ6VSQc$uSO@*-YFkH-~4zNHBTh1ihq`(C*{gdKJ z>M&vf$lnlql1GZVAhJ|Nt%2!)6+LA-^jDLm`&^Z{5i|X!`BjdNzuKDQqacH$|s2?PIas4?Py0caa?KVtHxDJ}CJ@d$`WdqGqV^Npv$nAQe6Wl0-2J*jW*Ojo!JV;Zaf@AP~k} zqpU2!sih32VKZE#64>A_k~%Su*1Y6`^j zrob}!M|iQ~Akq?dx=*YDbg9c!iR7;w?JQb@cMDV554t%~95My9zp8Ky*B0BeM58(! z5~59V_>sS*=>v@mY(b+{Io=&?JDP!(JUWd5eF$c(b?c-{CU~W3Pzx?K63la89r`$S zW+14RrR`+k(WGSI#;sXYvOJRdG+%*$RfAyQ^mAKKjONJO0K3vN*|wKT>S$C)e~7Af zk~L_ubO%WCg-C_0_xT+N+!a;f*#fl&qL&-EY?L?x;hHHDWxJQbhRRMQE-If3Py|*P zbss3bz@h-?Y14!26B-&8Z=qUNKTX04%iNsu&twl{MmOl`QYy4Iwug@T32Zq%9SPX_$Rbfw zK_0f!t#ArDX`z-p2~~^sp@ha%@BuOew&2~hVF}n0WB|~gB6`na&2oU~Md_d6 ziBXRW+>n!3fi$Z8aY9L6LtQu0TMIZ0jqvOMLlg4ob|HWO-l*Q>IjOa(Vu!{Af;IMzh}s{rNBuadV~Dv;ys@Zx z&uSJ)Fe=2zSDEq~b%LWiNg2ftbk3`kmqfcY`&I=B2Ek0hZ=eY}fW@M;RSDHpl#4Vk zL?Bw^PsP81;yO*idB}P<979#TM~@SXE4ViL!&5X(nau@CbqZ{R!b-~Yyv;=3~uI_ji29H#$iPLd+P z>mXc7%?#*Q7Aan=n(&<|*zc4n0dRJDU#0u9$JNuQ6siY^itGWx(&j0GJ~OB^GDPF& z9QC{4B9J4h7`H=V<8*<9v~^5QfR>bD1TACu1$8AVxAcZG(*SRv2of*UQFukJkYZ5E z^pwX*4;4&K9H7;!GZOv(8{14*(d5wOtX8@O9t?F~Q8Y{ucOzd+n?=v>PogcT*^5)U zX_ZBJ>m-{nh~(-+P9K@({a5)Oy{VJRCW>fNDs-SXyBY_SK{%(@948z9J9+gk&33bz zf{@|MQOzn+BqfPp`y~Q=vPRk4rTgnkjOY&mcPQ#RZEc9WfGU?I>ey(XwkjX3G~Ip0 z3MpcmtIdI;lUIi)T$DBUN9J88ax9{?`|ILkGkAkO{;J~2bo6Abf&6p^!}hJ8%`i``Jki1!<*F8(c5@< zDLx%SnkwCU4A1HM1@xps6dnys35C~aaUL#!33OLu2N6yqwanZZctJgy*knD{xh0nfg-GG$mO@7)(oDDg8gU;L+b<)^lFc)1<3KJ)R^z$ zpQ$%h5}~5Cs6WFsTopDAnzoiP!BC~p(}lDN2V-=NG9pvJ$&HVch>vPJQtN6v7j0$9sHiMHx_d$+ z6KF`E_<1a!N=0zedUL6QWL!ijgDM|AIOv8+8VcVo>TRhRpOl!!_4R^FNdES^>>e?2 zbVeZHLB#@K_>u~8R8d}63(e7|m1!#q@PmL#eu2Vl9P1LAxuf`IwFZZhkOS|eeJ%ai zDAFDwmnOfU`l+ZI?a>*TL0{pu%n{Zx1jjB#ePs<)od|?>|FF;lWuUH*2nR3TJFqIz z*wpvc#E4=0 z+{*cgK}TI!%A%}y4sr^)LFi~uHlmzZqi&#uEp-2iNQ>AJ>TZEW5)*<{V2jtuOs+t@ zS23y>6tzTzLaYwX1CKOCsbg+F&Hx5*a^BE_NsQIOiCfKiz6G3Wsy z=gr|_cn?swATo{i%CO7P;OaLY$$L&09Y5rf#j(hkAJJYX4?U84YmwfY=>FRLz665g zy~v3Z7&uDTJZVCzthn+4AVFLzqBM_5X-Szywjks&`UAnSW(}fQ0-g!;gK+ug`$>7Y zN^~b|kMrq|xQe=|Fu|qf~CBgknQD>)GTp;}T8kBT8o3+`E$|BMPamH3ju&8B2(|nR> z*F-y#iK@wV-vJ)PA?auU&qN6nzT31alwV_tOI8I1tS-%)1?S? zX!krATELZ)yxMsQP-dYy3Gg)(sr%7cRF~1uesLL_MAJN((U>w* zC97CfnA)RHD4hqY51Gm%{~}wflC05|W`!aR&m*aMYvc^53R}A-qvS|8BPnLIF|-Qj7c^uN$~kyRi4KTKG5vZ#TZ5LuNEGm|6qS03+7qe| z7Fwl6uSq1Js9pLL>|rv2&<9p0CVFf@4@a{4%=KC#-Ud4Z_aKe41|tm)597O<2pLhQ zOFw7YD9-pQIiL!;5>SOd-f<{WP zKo8$2mxZ529#-B(kH%-%M3~*Q@hoA(yLji&VZxfKX$shacUGK7K{K6rDtyS8&45WK zjOT2Y==x4_mEQ5{jkrvsT>~xX8LCm2uwj+9TTGNP*9+FLKQqa9gn8-Rmx}3&T|LSf zauE&yPC8i^GX9YTr{Ql_4rldVz=;H{TAC6L%{}Obi%9DL`4CH^`wQsbs)BHwnQ%pz zLYHZoPRW)75maWPOvxV%Z~~^lqP{q3b-^IT(M1l-!a<`Ip^blZJoT_iaXP-5; zhPDQ4jF^KRNxqMBm7~2CbAz*v7=>AZCy`Tw9}6Qn2qTb7)teSFq#G;^tOKc^!fvYe zgywS^Ar}WK%QGP!rejbIE0g8-Fc9X~RlqC}b5;o7_ zpUyrDgSsc&&WQ1GfH5Tik5K{bMJnR7#qb(d77&|MWF38T(YcBsqyZK<=*0>ewFrg4~=AqW@gM}gy!x}hPv)ADng(NxyOJ4^$)cpMTtm@w&;yH zPP1Axu0b(`OvSo0Y%j!R)_5Cr!7MR8q0$f<5DJw%X3FR-MD?iAVNiWXiA)#NNR0w? znoQHshd3`%vg!*)5{8bRF)x9}O$?M`r&@G*BgUX-0{zS;k2LsCyeTNQw0{Z&49?~( z3}y<38HIF3Di=ywj*b%!NisIKjQ&V;9EM{+Kpl`3!GdbdvDzd3$P68lN1XBKjRKsE z-B2Tir~`&hrj!+mK8}Lsk;1fR_-EvPASlC40Mm@N5s(YJ0KHz-OptBlHE@SX{Cod0D5yL@5x}O&rgBE^Q zS8J_Rog%uJ;l6w%F}+dLG!mIj6;WO7B}d!;MPm@ui(qv10z=2iUl=JKRb^yK#mc2R zb`HuTNley7pOoj>N4f}hRkak$=7fT!M)Uh zm##=|)CfxG)6BHvs3G(|&d{!A%|ndFFDm17$gYhVI$Abh7u251|ApiBXLF4?jnN*TtU^!A_nQ)2mf05q#Zj*?!f{StYfDg6e9dia8Am?%;G^& zYTk~9S>L43$wrO&A{Bs$h#GJ;%JewhjP^0$?Omyf=rTw!oxLe{1*#u(1~At`Wp1$Cp`U=*z>sWl z9AU51bw};U)-iHnRoNcW(nea;3%qG!PcfhyA%F=qF0RJnhP`R*Wu^|8$UP=|bAFh# zOtx^$NueBsjYNmUS~bn`gI~%=ACq|58z>~_x8L|`LyXs?j80L5e9b7b{;*D$O-~!v3R!g0! zM|&Ga_7Q-Zb|AeONHgbxS=y9CL_Hdsf-DxymS`$NZ!NiEV!sTWfH4G#0)Qoss4W8s z3H*?`t)s+@8or|Dt-XbJtCU4n6QF6dnWI@B#jz9x)ksM+76>K@rL8f-5saP`O40Y!e?5}&Gzb`C zp&~g=w_#4Nn|^>dB0KV+(HamJ7M!dy^(z8bs^kF_Nk9jL484U`6|@|lS~OFjTgs&F zeN!E?GmG#pnq}mgM?ZU!^$9mQeeSSLj6h~IrUrkzS^-B8ESZ;FV+r7m&UoZ&8%_8H z3ox{bDX`9JRS8?6<`LZ*j5N3n5Sy4867tB584cjVb_V4fiXxs)0}Aw$0VO3V8idg^ zMIRIL+FTPs9|O2%4X@;qF?dvcTCpLte~x)%M5&8{E2PEiAK;QRdu&OAw}6qQ6GlLOOGp!K3cT%=`h$R#imGGcTIm>jwt5 z4bU`YE>WGV5l2l!RM(gg%>n3(X1n$%&&G+0DQG^d8Y{Lkf2!Em1ejeDAK2(W4b$RsxD}X`Ul}OwPpOaUy{r zHp67e-ugF3B`tajyahTbJiXDmpVCR1sH0B<(g2mOg4XMBhhSkPxNFO6Uje1$On%amyhM}8Yq-R~B)+Xp`_DEf+_%eF1EizDi zSL2|^pbd(L*wES`oW5@-j=zh+B4V7T(Kwp+QF28!ODYDq z;?sjTij5wqfhHcB*(zlWy`g+_BA;G|F96~}6X`__SI40(s=`3y2W6d-c9K_MAvw}v zlwE1OrK(JW0MA@m+JS)(_jGvm=(MKyRRg%%kk1h5=5kx%P1A z>*zg1Rv+h_KF?8#!hk6db$%W3)TF`PfWqW-qSDx87#NnIzC_Pq`tShQiINzEZISY* zAQRE8juJ&Rl!31_hlbL)B`8c~%pXUy@$M7{1^)0kb$WL= zxu_m;G*@MgB>vhoNNpfDC_9DZgEmKMns5u15apuK-e5Yy0S|}>kzht1?FE-;=ER6n zl73ahyix8$xul75QX{*VQ?zL0-9(?m+&B%K zWO!iXQM&WZ?MZ0eH#(N9T!P3moB`6KStaDk1&lej5KFo$(?iKaq}SLSeB~P8KHZs; z3R6)U(NdD^5XlUnqm4tnC@)B}^T<{)w~v~3hAYI(a1JqSe5MyIqqI~5((JMGI=VO} zG0sh%)hyPvHm*g3$4EdD!QQgQ_%lYBMgawEKNR3HKB7`^nH5d+9$n|MCY9jnY5YqJ z2x2(1SjIIHQy@%`PM~DX;~i)~mC2LibJd)k!?x^z=iS8wO+|URH z1dfVMTg1?+^-Gj^?J++-Sxb@nGou zMIHT$ruydM`i)Lr%r}SPHnL!}xTPc)=(I$cqPdx9C-W7E74L_Ebrl&gc_0cLfzU9Z zu#97*b0(36AtjUu>h;vcm!U;cOi5L3B#t%jGLL4g_;?*MU~U0WsqNXUG4f*o8I#E| zNQ^%SzY=OFGkg-`W5=kBSuA!&W%TG1FPkRkNF&x`m^c#yRSS&o8-k5DLgN-Z_3*5x zq+I`8)E*%oMZM!G-BdHy(OHaAGv_-_VWX(;OQFDc?N#M@n4=WNxG<%aLP1D7;S|FE zX)0EpLmmjb=nO&T1#m0UFp~^EsgfF4E)92RFkDCoM46?^^C%%8{Tdw9q8`!eq!_^w zJ|eUSlu(V_GXNu60*E2UafQwb@si$sO*pHDE5ak0RX-8K#&D>z2Jn?SMzAG_VyZeo zZ{%ocZg%IL#Ok+Sq-NE4!37qv8?N;i6$aS&lM^vK~8*8rxGTIyra6cNGVY|^NR zg+>SMajkIxHPxJXyqa|sB~CO*LEAEww6{TGAg+WWjVB!BGEGLGG%Y^doE6sWB&uhN zc7JNC4vz%fbrg2f`(zeT|9&7Bd5KxwEU*UAp|Pn&iAJQZ%jla#TNC(-ZL~ustVHa| zD#WxvyE*m7NkCfE&QmW8C7J9|#4EE5KGPS3k_PgCcSPSAdLi`D1jsZL!}y>MjKcsg zN$7Y=qa!OLnJHqV_i<=%qGL>f(=KC5anpd(#kIj2dKp_q1BNzyE43R-n)J{{$IpqR zu_@E6)^nlxaX7nbtTp#h>V>i=rdgooy(3t~JTfpt9)O!~NRXY5-mup~eN$9N28Z^e zElBx9PB89hhXnh_wHmgDAWL9S3}#>eBO_8ESgzh)7&S=xE32VewC1K!j^>0fu>y4R zqlyV0PldIs2Z<&n&=nb(pr$#a zy$y^Fih$N>^fjZuD0yhwkw!HdTzpOPVpME|yEG)WU(rI3u1w+RQB71u42iCAlaFQx zDjKJZ5p_%}STxEljs?>)G`wUUo)1VV8U`pQ2ZRO*Y!T>C)95Pb17b3C-CW1?+6K-6 z%(JF3eQwpYltH|23xIsd5Rp|3ua>C?jKm;y881e2ScvRdU4Oz$2BU-L%FM}VZ#rux zAw%nN`tztSZ5oGIJzTP?B3vLHJH(#~Q(F}H_ek5K*~Ju29O?H7JM z{n0wKxiybyg5LC5AQvXY&$4DE@pSrV<6NKAUMP=Cby;-E)FA&d|$1i zNe;NRQjExy1>3lTg3u;QhARgNH-2O%O-B1j0^*D@f`iSUSW>Nj(!#WWHkfiJIC=AA=e_HW_xx0Btara1RnS{Z#c8 zz_VZ_(;zC-Zk8gkP=4?X;hWO8IGS24s_KBiBN}SZ8{yVxANU<{+LCn$Vbe{~`+=x~ zbOt_0O7|ER-5YUB`xq*&#+MBglS3YyX~?b>zj2D@kBk-r9-$La8sQ89w0Gj6$yl`K zq^FI1NHc0F3QU2s=nqbTP;^5ibq+S^6%k|TN!OgK6>4mBe<-2`gZoog6ol5SdP)4b zlBZw?tN%C@jpd${yEJ7L$eQ&QbqS!R-r;Q?t(PapOEPtk=qT7mVy@E4bkSt5mD?Gh!` zMO~HP-K}c;<{B2S(x-h56}D)k`b4!qZbpnLW*F|IELsi!5}Z%7NXSVB-b=?#s5+ex zZW{B}!PcZ+mod4zN@#Hnu_|>;2+EWdVK}3xT!Y!+VACkYX#x}sV23n@HCh-jG#A!z zf&qQcR)nXCbzhITBfuALJyqX?O{mwZxt^s0VGZ%_@XWz}*s7K$$pxPaFIZ6R26us{WQ^ zg{eGCfS-fDS2gyG@Wd0m>K{)xlSC$h9t)#-4jMQ;SC^=~n}k$T;Y{0|nD|a)SiRU*dMZ+GB{F4{gEvOdp;DP(AHjd}G6IFBk^R9}1#O~D)Let@O%p@< z@visjEs|+C%5ITj_9HkINhTt?XwpgEkRS|CWQw{wh#CN_xTf?yj__nvYNOCHX^tlA za;4Q~FtV>n^aWCNbX0NdoTQH$U>70CF$x0y5*nK69QV_;)oOw=$|YE%g^DmDdTO0R zo8V#N90+?Jd~&#tUs^zyjoN0E%Xr!Utm)N=_PD+vxp%KhQ zwLY9hvgi=YO9jNI&HQ+|I3?b1)kFDS&5V-=E%-Zf?gwb}7G1gS1MyV(vQbMFD zLdX#mYKR5wKBOteEK~8F=_xpPN!3T~&`yoH=j?P}I z`WLHz?>Y@AspC>0+kjTVrUTKVu7M=?I1@PQ2r_|oblt)&BHm;yLqNJ&9gD<+(8%n_ zY{Bu+gpL+1r6m2}jR3f_L-R1{&@@dYC@GS{MDTL-MU@&cD|IVX5YDnS!lfw#o_HVg zw2%rM)QMEqZ~=e{5=rS4qoyk`743;Yrj{|rRx_O_p)6x~7OJjNysZZck~|zCAO1e5 zI;%JzN;c@H4^NdNiGKkn{nIs}j z)Pz#tMYPg2tuDY9`T>dj0%{+_(uhSR-FXz1O!5e61BPHkn-)c0G#i63=qCff-p-Ye z7mSUhJ3E91^)I}qX5qgi+_+#W&`tucm`q$W6e;RX0cZg-7&G!9|C1J^=Svv8iSYm~ z8D;pl8WSY$01cQTwgPUYLM4-A3v9?FVxd8@D<#3s=`1UuH%+p4EQ{U$_Luc$nY4n$e?Nu*ZHgV)#3Dm^@!aSSU#=?&Kbyk%-Ka*z>#F7du$J(GQbsi zoY7u2)7T0|A}CbA!c`>-%%;!k1Qp%F$S{)|qRUqhO`#o6YRXcg^CjxJYa)=URie`| znJJpc%naq5 zPatR&7DAMGO=?a)sd;}N4VOsI0l?7dVDP5Es5HX^uVxvj7c(R7$)b{4A@=Yt(-f0$ z7-XvoQo4Ehl7MV_%`Rys&jEJH(n1f1who7m3K|D*Tg(!oyp|$Fz^gf0IL{)^Qa#UL zu`yMfuFM)+8Q3@tb5)4~L>%cN(ZF%&q0^+%n7dMep(fO!q9!TyXzZnXFVG@!Wz z^tMo5Cu-P$QRe%BFPb)i+Zh!L)E@KSbmD1IEi*%? zB4#I_wvKjr^q1A4BNH$Hn&u6INL`KvST&%)d3A2T7&7HedZ%<>hQDgcqdfHz^%RW8 zqI-P??}?d2{3~t;mFZDvGh@>sTcZ<$Z%lz34x50y9ytOM;L-=KC%aRAVG2(mJ*!KozA4e*bLGS#Rb63B&{hYpBFh){CX>#VAAqBBPT zlP0y_o?Le5ovjwo8Z8h^;GzMM=5)9lt4G0DX*h3&;o9$~T7$+=o*SvMMJ|$Rf>R8( zryi2z9GF3wf^pBtno3zT$|X85fpw^%vKi;#&)FWTcTpw(P@-%AuKwg@%gM)*NbYDr zKs{R>oqN?YhT^yXkE^@cnWbs2GJGnQMYjT*ME@0uoXACbaFrj&SYbm)U^!@oL)P8k3xe$i%V!+Zc7wiT~A1P>Gk^Uw=0grCUHAvll;L3KY*AMceeE3sjFv zzYKtvFUE<4TMWVV{5-d{BS};Dq4MUv?=daLNfD3#v+^X#vu)>I^s1wPhWDp@e7;BG z2s{ENpP#EYL`d5_Z@Ix#i#&UXG;CD#*k}+ zJ-heW=YoGexW6kAdoQGCzY;Y(LeY6W+o$d}nf`E1%v zq+9&PZU``m)cAZAHd0s8HlnRKpDNGp6r8tv#Quv8ytmUydP4S>XJ=lrP`e8Ch= zL`GlMlg^qJ`=k0}i?f?p5LZl0?~Oq#Ppz=L?qK()u>bYS-n&qYQ;D%PWqw%%2YD*h zwDNWP@HJZGdJNb3`hKP;Z#FpGcWzE%Olr6#)9%$b+ZG2ui?+b|@)JV$$IU;c*Z8o6 z0iTfVX&Eo?C{z@(0LQ}d%eJPNTBoG{zMKF@r`32RZ0?stYBv#2x&Fc$9rm7hXoVsi#%s8 znqEXAnx@qOJ!kN-i4Z+_4+J6!U?-T6 z%inRm00|@4k+5V9oL7`N67a6+c)mxJ*1I_<)Xn;);t)7a&*gP|aS}@;janJY`J))< z3(slx!)(4ITm{1#9P9Cx8blGR!BaRd+gmk9uclZD6Z-A?xrj~i2)q@J>EscsMtoqu zqzqbk>LlQ-w@*ck2d>DD_(q$sa!)jow32VTByjZ!!9RE;RiuNneQcCWhD>r;>ndxQ|^IJsd9}U5E_XhJmX)OJh_kH~I zp4z$L93wXT#K}Z+#y8=7yyyS`sCWg>@zaTI!E4Q7X^yukH6kw`I)L~DKf@C-35CgP z-gTxXTg#cetUnR#fP+lEIbIwq6=E`0#*g*@d$vB*+DI>+MFxK_y345X71O5pjU20{JU!?Y41%* zL694PM5AZO8G)R_C)q4-&(?6yD0O=1paJwG9{QRiF#a*$;}g#aWcwwxfPKc+k0S9R zpbeNft(aq$>oeI|Mz*@V6;HQa@ut34)_m+`+mQas4Gccz_;+AKd?$vb&KBeZMKw7mgv5zPcG zqV{+T3ZMnkz-``MtNh{TQPAp0oxe$~j>#;|#WeVB_aeybOkPgtU)4!%<>9>cUofn( zx$HrKy)V%<3RSCM{2h&hth!u~TlZanb07(J_Y8M$lj0hYWB zb*tFHl#}Op!wUpl?Tik`%Nch?Onfy_>#HQ85rS(5X!=o9)&$m15edJXHnYA>A519f ztGWb4_wbXuyko09U;PuYIZfNoGIt2O2p{zq0~-8FeCKegTyx_3RNg?-!4%l|zHJ#c*>yUlguQEb<7$`sbX_jX3Dz>7A%JRMrI49YOoO zJpjqixF9a~^Bd|kv71B9F2|2mGy!+WZUSZdk!qBVva- z3>*~Z;x~UGDTfx1Z)al8f9>?#hO2}+4XyKMyO^PTABp%oek9$Wf>C|}10K!0D!I^! z$18PIqqvx2B0YGEuuEjxJRtJ7O9w1#e!ei+zE@@PWKJz4PtM;d6bnAcm02RIp6_xAP+ z5maXS&j-fv5?+9HK?2q^dtcupv(y^hzz^E5n<+3u#MAZb?&RP|!TJuSJaBY@1L)=* ztBP!0xM4UVuU{TI@}6km^T(Sc9|!srWbWwv`jFi&Ln-~$GA?zi! z#d{zphFZHrwB_6S-$TiWhWvQk{6dY{ zwWIY(Lgg!_(T6dqclUZ#hFeC};JB*2cOXC|vXSI+w!BQ1GoSOr82pjpoZ70tu*l!? z2oR1cenppk*R2b^;U?AFD<)z?o#m3n2J~)kYbGVLCe#~x%7J;n^0?!CJ$fU zz1Q+Wl35Y=#QHvy)YpR%%#m{bdVPoRkXHtK)J@k97z}QE31B;3M!}x%F|qc#`PU1G zVRp^JeTUDv+C4D9^Oxrus}yI2G=uTWij#2bMr&h#x3l%x-M56@zwSoqy^>=9^It97 znQXVG_#bbG;`X9-4C?0h>xhPrr8LH>9}791WV(G_u{VO2*ukaMH}&&2C);Lt;(n}Q zOKMBl?6tjeiZ#E{c^N6ruPcFEf;@YJuz1r9bG=!K>M*0;LoVE&S;)m}HsD!$YW;|S ze(}m%_DF)UcLlJ+P7hc5Cv_1&zh}q2epVIRWHOA>Ao=;g+%&8MKjn>DNP{i$tszIe zLc@eljY#9>Um$c=l#%-WBT+dp?!q84&Hc4x*|>>GbRb?8`8tI@XBc`vUa#24pb>G0 zROFYQv&McG+136|y*C?Aq>PDud}Sg_QUnpk-!QZV#nxmZCe}ArgTnv56|9j=yz^+$ z4OjeA%lrexkWAeFquTflc$t`7b+$RoK2D;RQ z|K47NNx~$y{oOJ{>4P|=1p3`s=XhW2zvN)Y56SS7O7@?H$8(r=#-s9ox7!?iV7@&3 zPi?*?LrJlkG}<@CJmd>|;r-5E#n-cM?eXg`KMw}Q%@xS~%Z^G)Gv7<@HtY49@!#8_ zf5XM{wS&<5(8SOfzhNDz34EFN2l;Y{G@dtVOPP@K57c}=?Wn)gPa$$7W>bq=m+hrk zrAwUt%DTNUyZehkV;4=0&hyI-%$#;NDSCO+Vvhq=BV~KPT+Q_*K_v7J@8l_c2C&@g zH+^&(BEPHw82?I45Vt4}lgae5h390QdmP>m#V{>kmV4=+uVVq`Mzh99cl_d%V^&3w zh_L8UrZ}8!nB$Fpi@}0~SF+mts>y9b!c<34+AsTDGYlG8&vAY8C*&?rd92BIe*RTg zu01jTX*X3oy5hMofrp>)a3vI)u@Wu@&Cr6|lNH^CwU^ju-YbVx`lm zALFIg+OUXjl4bcS^yT+Z70hPtul0J1Q%UgEplf+=ntnILBlO*8{b)%}u~lPs{%D#Y zt|N+t<$=fYqGO3KP$ch29~c;xT{GbRKL7sj|J~pG?f>|v|NdY9`M>?c|GWR!KmMP; z{ky;UFHg^H)8gL1n__7{)_?r>s^;~tSkzz)WEUUf`QuEnl4*>%?gIJyb0$e-lCV(j z|NZ>=p+96q&KY|@M%(?zmw$AlgEU4&*|{I0-;cR6Ct~prNoaZgeEW+T&<<~!fl&4Q z`I5LIG#$`xyXx=HU1fb4K=Aju{`+HV?*3DjL=4FB`(>Z;H&|Z?H}&@oY&gYY!`si_ z+qr9xq%efDaJ=tw|0^epL>+CO-$%J5dvFz6m0`g7``hR#u1#~esmJ#F>sf$08I&g1 z`u>>fuZLb>7^By=KOf~{5QOz3>>v^MzxPPfk`x+Oy7Tt|Z%Oi5qB0eIQXl+oj1?e_ zhv)an=+l#En-<;$<@fpS;bpAuK=<FCTtDaVFt*<>n#Sl%e|rely|=sH-{%=~{Df(FFVo*AVDvfkcIb!B z_4g5s^kMgKMAqNR_1;+n587dq!rwnix1#SNvDA9{{W+u5A`lGBY<%w}RQp7|-do=H zG;W$^BqBnWa6Es1yeDjnmW2`bTU!Zp)-$p@+_l#4uX`e{*cmtxzGu`CYcw8kgkP9 zS%DG+wBOc6&app~`gQ+KD)EcS&!F%<_Bwj*!|uK?E^LbkHQ#ciL|*8IX~w_b|FV-f za`wPyzFmEJ0G*l^_rLV57OL|r7j4$o@Apr{xZi)#Kl*(Wtf_NSmu<53k&`DlF{CqW zOT9rBX!W;t`}8ez*u9ZR8u7|o+hE|b*Fc%ps~rGR^mrB|VAW5D39=jwuJ3*G+rH)m z81F}f#^2)-(wlSL!FbN!cci2>#k+F!EZ^oBMUj{a7LEB^yLT0#tVzRrJ?47Wk1+gQ zUtFNSpB@O*8l&CR&OigA6+M}n_gc5A1Fn!|ESY=%_*SX67M=HUep~4yNPr3^iN4Rw z=Z^<&%buhYAc^hWVN(arY1f!7-;3e**JlPR4P!TsUnR8qU`!vCcv=wO$L=F>S`K67 zU!ApLX>S3$i!MB_Z*Mr#kXrz8z;V8ZK!b9U-7r&{*5^)ZpEU6^VKucm+T)V9Mi~43 zImXJAnKNlu+e*xV6^hwx_TNK5={&~?am6k5RYLy$mbn|l#P@jUN0{{(psefnOGR`a zze-#q--ocqW1~%yDbw%U=96>vjq3OOKFWnz1<~b*ng07#VJV!1d%7CmKWgqqSfXWf z7iiy_vA0|fPQLHyd+X6_<%n3~SN|TfyKHa+K>(=@e+`Uc5Wa3Uma2RK6Twqq^321& zzfb-TiD<&Q)RF3R1M-B49ksjpn$69|7P8UzeMinbrFNM2@2K_82?PaZ7+QZT-Y__* zChoCsM>)(i=ZKh%zrG)Cln&463TDCD#f499Bqlwsp-)gUuyH5{$>*9s)WV9J)% z`u(dxYLfac;H33##N^~u0o$(AV961F5n+sb5!W_}47|qTW%}`LlL&Shv1gC}whqpN zU^SWL##T>)UOpLb=sH!M=vzN-75QFuvmijA-P5uDzEwRN<52q(bkDba8Vpis@`&8~ z%l8QncZFFy9I5sAPMhKG!@wUN{`cURF;5wKc#6Mo#1cpg937zQvn3n^rHpYxtMVd> zGYh!r<-YHg(ldH&Ma1&`t7pP&bOj(azC%8?B|^&*TL>p|NSJF?^B1}2x;j^ATa1hBmin6O}(TmbyxJ_{o{r*X}K_#KwnSRH*bf=@O zIoLm|ikJwcmn7hwqZY8VghVh>*KB)R?<>oqSd8zKTa9{`#3&z!)A9R!2PEa3V?`VF z`&9vrNe_%(rmDwCw2hys*@!!;lv-mZ*0}kqy2sIFfPt2Km{rk`l3D#5@ zLig&?O9IUD=z*uN(y?z)fN?F5c~>R50Qcy8@&K;2!1m|*2Z&vpjJ%*VcWj?}J*bO^ z{j~W8Ui^Kf8jEyX9=)k9aHrVCJu0MmZUyEE+nIbM)48uqHMfgOJ81d39gt&U`|^Ho z-+pS`60|wp)A_AB;G1R2JQ0HKdoLIvuD0$y>pm~7!qhYa36(nh{hTEulXw~TuPoZ& z%QA~XbUNx(mBcZghUWRbbw>e+w!8w*Q8g9CMOYX-{Z)n>a58LXR!8lbnsM~aaMWj2 zu1FdwZ>BNT(MBC1xIE+2db-$K)td}weyi_&b00cT6GL8Q+x=XGsDkW6{M7E)B2AEA z!ae`~9BuyeCOuMh;ltkorI?eJP*t8W)E?0LUF}U@^XIf1_CdP)&4WGRyC?>L#`z`LSOt8dAeQ+;Z4 zycgL~iyCz_1O2sa`KD8tBP76EJ?@mbqG)wmw)VKY*lh_*11qW~5nuzuv>^sw__uA4 zcUYw04clDx8LBw9k>lWb3O<3Uoj1UY=GGBnB0?X#ryBPsRn4WqV?u*_kPXzQvmRh zZoF#0e$A*1mHI;n5HJm@=5lh(9x4%= z>-Uca#U=Y#VCS!YqPZOHp!LSL|9#Qh@q`eOdL8xF7sD2f7@~sdw_hKcLL^DC_Hh1%*89}1xdqPm+#-?kRgO3IBQRf9;Q~s9MkakBx)(5 z3vmQ2H35^JX;ZsmOsp-$XnG3wl$Gc^^Nm<9SfD8;`0wB}<~1_|ww_zla!CX_g~9U8 z7Azvxf^&}F^tiSvKmwx=gQJ~>tgBl@G4g`f@5f+|D!?>`r%}y>BNlH2x+Ic*b1rds z&Ios}T16{UCAhyU`LWbLDxw7)^*$8)C4|Y?&3LNA9ioc~n0spfejh{1DNG)_vs$($ zOTkT&-s<;%W1*N}Y?leFs*lm%9h#)#jL&M49U)<5M!cOz?P#VmF#%s|b*l)Ljc+rO zI*%%~&xlWrtX-Ds1Y6UYq^UA)uExU>uB?l+G^y{CK@8#akbPYDWnfpg5^aE^o$lup zewX>xG9PU_CZ9VZNq1YQyX77b_+W~ydvT1Do(^V!>lpPMF|;#eGcV3{UQU-K;}o3g znwxYG<*FOax_+%vwlY?Y#B67=XZudz+6c}A?eF{CwahSqXxqQyY1ggFSZt0(seMga zmJG3Uu=Qs4DDe5(BR{CN#))W5A_=EaoqK8Q6TGom^;LgvDr>t_*F<@03|0q{$2)PW-pE>H2ONh0>mkI!);ICV_p^@91%CPRVU>cUzA- z?+hYSn<3Ip-?wLSAFcADHPU()y6xu{b?LmY|B|^~HxY>9 zL`O@tTIX6WSA@NY1IMa4stuv9bcP?9Mz@Qdp)m0{{CBi`ux zj;fTQ9A!8i+gD8mq8#w0P_^avI1;THUxwxVHUC#yuAOa#LQs<3_dLpaz?uxn;Of^F z!{tkp`BDA0ZvWcgRrs7PONTVT3np zs^y|DXiy)N**f-`ny<+|l6hWz9<4YIC3^Aa7(-%@bttD7vF(}`r_RJNY zi9ai5>xL(3DsN`2?E&y;p^X@E>{Y61vFb@Hwzsv0<4n((i>_d;$`mx{8k&c?ZbRf| z4^%ntvig_FZPx(tM++^f;Bj=qwaFmXPr!MaJ)MEXhty$U4A z(>3>NE zXrD_`bw-eJ*m}QC_4Y{dx2U7HHhCr5WTLiM_OF+0$V>;~x-})QqRM!F|Fm!n<*HK~ zzrdQfCF;9x8{{21r-$49eW}wkGMzW?{`(mO+X*u@8U4ML;|XDm$U@`)`#sIV^eu3t z&YGyKiQ#iiq)C|Tah%Ye*xON&zRUWaTq3gHt8{5;XmJ)%2GWVwmPr!AA8~0t*Wyh| z8FE9Aj9qKz>2*R@rKU!4)u9bt=CAlBgW^1~qfftGcZ=!~I%n{&)GdRnd`?H#r2hK^ z>2zc7OtH7NZ&S|^mkb5qJJQWa8JDDNwLUt}xlWOfvg-XPkF*T*Xkd~ zR;~f5^1seDV$rw0KSB1nF*Tjeq!+~CVO=cCx8lwWpao;+?UwS)K;Sq&y04wJ7OF+(|y(BSd3 zs=FAY#qqdktITFFpOTe!I@+t{rqjn5ML$PELFl9ATRz=P~cCJU~CUaeo>l$r*u9_AIUiZuk`YE1ZCOq4y?W-@2XXo!Dz}l3 z)*N6y(P^Cyt_WLT3#P^;l0-^1u4EUj8|#$zzc!!Ab9GgKx)fflWk+57LxaW@I)P5A zgg*lXMPbm%|0+Aq{%9NttwuXcSqEt$d0}cRJ*N<^__o(?jOSlH%&UUq-sX~EZb{uv zhV}l*6IZ$QQbxtGAS_os83w^f={VO$dW9pcc``3c2}-cF%1+c?41JGbA$%QW7U8K6*@ellNQ+x8f zwHkeI?IAi-yx%_x(tZ1*pY8SlBIzRz-JwcwErMKHkaJ3KdvZ|{{w{&Dl@2YQ)GLX! zb3K!SOIP;kXdAZHT98X{GP+GQQ`s}xozq2_zhWx_9MXPP81-0f1k~7>zYX5|@5@>v zL^kvgL|3QH$_0+2bz=I~UZ~p$6XN@-1A9&LMt1}`qE6KmO^In4{CM@;Kq<^*9LM+y z-@^nNC7;TsJ@Lc1Zlh3y0jTCRA+afXbk1EXH?nkA*w4C!%nt!fMR5OONA1>(^h8@* z4HfS%190cy;^0ZIF9~`JMq3%T)*oxnqv2j~8&&y|`7-@n4>aXy>YL!Iazvl4NgEIQ z{cRJHmVU+AD@ zB}_We3`xGZp}!r)Wk~x^<#Se7QzD9HBnj106Bfp-jDzK-EH!Wk(S=?%)~_kRF_L01 z$I>@fQ#(B|(2ymf#FDAo()6daXwEj|PVE%U{9LlEprDpyFkkzpf4B>U0Eb4PW%qkM z>R^)V7OIwji|)QzB*glpY;YCt}%OJ;T9c2SNRVH{~=XI=X zS6KOcjNgS>2UE75o~L56TEW5=Y-(Hh4x=0b_WXsF&<;^Ws#ZiwF$G!mp9fVhETl)L zK9^-m9P64qxcbz$gaxP;j`oOglCNQopwBmH|CMwA^o5cT8WA|e@kSQUPZzO9UHQmQ+v&{iw}7 zlnNvtE|;S@3NGnat|Z~*R1s;|6J9{l;J)iVcYl}>^ue{l5~nOW&i1^vMZoYY9Cfd! z&jdoM8}~_k3*WJKFF3Ftan9qcrzWgnq}mMsYIs0+FNzo7d99xJy{A8X0NtmxM)z0Z z7t}w~QswLv=Og$ru~;ufwkhCkiFWOCyyDiNc!(yWa^fXy{_0QUsmRu$7TXr?8g#6I&X#IWD1I`x| z5XpjQ_+Q;6IxO8@p_p^iK+GtRrs_)*69o>xD;D4it2tsMCU8a0%+^FP_1Qng4X|6h z33Ew?QE`-ue>b&Pe=8uCdLZwqZT)z?W{&-@Ej4IGX*W zvyHRv9cjAW&FU;Kmia7grJCcI+qnlJAx@X8x`wpIBYPaQ$+GeJapuwm!MDXV`xG+H~pkb0K|d&X#=8 z`M{*HggjL(2!NGTh7cC*%9p$Tsx^LpYOPZQEwLvXnmwkpLrNtvBLvbViHxb-W%}Np z+mt6JcS5Tvu&f#{I{49+qtN?&L66~C=Il7OPX!X(V=7=+{ifNw_}eqXpR3adTxTcn z%#Lp-Bx;!4vOo+4${gcp2NS0jG>5oA8tZF$uA z8>rzVzt=M(Om7cOk71!b8(?C{YP8Kwj;$+uP4Q5*G1Ove6LQbl?#=x6q7@;g_R<|} zpd5#ehst1wIQxQ)+{aW77j16}hoP4yBt9#QlyN#(ps4xNaT9fb0_ z+U#tL;UFmGQp&Trq%ai>*V?-$7GoG=VBAYOYTw-Ch)rqE3Ozn08WX2$94W&xmfvaY z;YJGFd=IVL87aUNi=N`6ju1psjWEEtx+&B1>7;HUsg>#*aX)0OxeQhM%q||Tr9R28 z?$859_qKx`XY+GBmc0dLMTy_crd$H}TQs3%T}}|teRi{$?DcGkg?&OPqJ#X(9b%5O z^Yt(4cTyHz38b1uvz0_kAxswKhF0xltW(oX*wZSp=zNwuL_w$Q)doEy{bPO}OKVY4 zzu-ujx;IA)1umVmUO|m4q-65tvi6kdOa-@??2tO4H0k16{(aQ%lGj1h6NBryDGv=zcZz3>>ZAwpG*` zkz1UU3lQYFwNpOQl#BbaT!07f^N4>uDcmK8k!26u1vUH0d@tXefTK0h;JI%3>4)mCaq^xQON+U0PS2F$Yiq~TPXU*B#p}~u$NCVaF(CQ; zJqPzqP=$JC`e{&9+oS1OmXrOx8G7$MR1=wU;d^XkmuN)0tdKHR{Q@PC*fJ=$)@J8| zQ_c!JIZL`R#WZ;TXz16b#$iO&q7-HGtulRW5CrXgm)z=S?FrZ-N|n;o&mZ3sBgHkh zQ6!;hm=Sv@o6Scw65nakaWwB)5U`vcAXjBc57&}->;5+iRzU#|UB5*V*efnvHO*@36|Yss{&cedCT-fHf~izzFk>? zE=W_W68OU4?u8F7OSOu4KccsoUFGZYg-`)o8V&0Ax1};BPXW#4Y$h;mcEtSS(-(rz z+@Htvtka^BL>s3PJh$XymW%|!#U=KUHddE_NbQ)iL4HGtOLDDle%)S|=?h=*;jny<-epq0IMVBb52M@OI8*Y$ z!h9d_DFMXHW)aO=*ARM!P#J0Sn+hhJVOH#OR3lYmEj|A&{M(Xes|S!Bh<{6(O#6~x z1m3PsU~ni~l|o7AZK#Pu1fUv}2dick#_c*#+~mvEE;BpxOJLNawxr#Fj8oN&uy)f- zG|qVO?+1SQ!5Rd?WZkW))7NIy;~jBFynlL~5Q!C@!*{1|u1k(_O5}9RiCZyfu3*;4 zieXaP6TAd09b7{>tk~f_jPS6n(McYP;L4hGmb8U2jtre&^_;5f3Vtm$GFg`66FXqp z(NK^~Ow(70>4mKE3fRZy->rz#(XNhgcGMPjK5(l9OwLn1@OROy5$-ukHd(cs^wtG_ zXdQ3OtA=k+OY=ls*4TjbTKO8#S#1#a7osDHbG4mqv`R6NfIcekr8ivU?;aFY*V5XT zOCno!pE-oOYT1?O$_V?awiu5B3F)34aWsL~IDGTTdmi7KQVm2*4oF!-q7bKOQi~nguCdR`xn=yLd=!i zt>fs^Xo6!=bqcfY?+Y<4A*P1LOY;yo<)ETST|- zwHYt$>tj#JUrZyN?jrzNK&8J~O>J6{&fqgLzc>}%u_P%9GbFu|!zinOVn@bjvNjMz!v5T2T35)=;SM^CV z3<|UqeRfanI}Hgfw58Byi>ob<-yFBpr_)a6nlnNps+PecT)*@z_%$0*E#<#M#4l0z zSYArTenzeu$LiscZHAOlm?^#k2wlIz))e_JLrFT20#rA7!fVAfa`9;Gur=Uc3Y@@` zzc-07w`SYVlTz0$5*&XR@o7nIkf)9*&6BU`9c2o`2x@{X-wxGy2G2{#{mmyfJ+!G= zwrqPdNUBk?Y+K0=6P;m*ol=V~c{Gm#SuDxsBW;y|Q3Hxd zu8x3XS#oQ!lA#HRl}K=wlYjDTfbWnRu2sMg&%0R@Z>`lzr34U|lM|$N#39VootKa1 zYDkwgDffy;$K26mtd_@gB~GF|pk~In#8;dM<(R~QI*u>{mm)$iIkDlpKz+D0owqzW z5w|yYhD<kT6=V1O9Y8`jb9I?l6g(nF!Sa*>C@+TM_jLZY`ACm&@v#Ony%9Q+cn>ZgFSa@}m9>DG{ew{{J(U?cym6MESa zyXz6DlAn;;Z2VqL3e38+vwg_M#p5W&HUmF7NQ{nKV`i>F$&14>Q{UPtbcI9?q|+Xy zD_Kwz-sN}M48IeP=h|ZuuNEdK9I`W)=D9UNxb*n2I7pAS-@N^ z5vWO(h9<=EuF-rY2S|o$;hQ#8%0J}U-S>3MoHhVC;ezU zLVYbOil_g6JkobL*1Z&*N@?8!IgHdf{Z}6jz&bo1glZ;?ivf6uBp>(ny%3Gp$Z0cC3#}Df@A`}fT(ZIUk&N5Ti+AYA&!E=sxt84D z{qtVh=?7NVcZp?ePfTWMA!QK6ZNG*N(Dd0{f_GhbHPzAncnQoQ(_fj(mV+B9MhNJo zYVsw~##8P@<7@63DqCMzv1cc0nGrtQRkbswglq03H&bn>Om}8#*-ysN%o}S$Y%=I< zjL9i;T#>`0MgfeLQCg@d_O%}21EOx|3Ti4z- zt$BOi_-6gaO?Gc6juF^#O#nrd+y||nvY%~EWGA>clMxtBZTj#j=#6Pi_dc^=KOmr` z6VV1yL<-dTn*Y2x`c>V*N88$1``OJ(x}YhT9J3(QilH)cp-TaA|A(d-^WR_FxxfGG$~?L51m+s+o`kkh zK3<|CRpOkM@AefY3=Rs&Cp)SJQ;UwN;lwsq$I&Q=`fv46mo0b87e}Tt9VHUqa3A=f zQs>w661vJBZ-Ik$EfMp5k*3AC!ho#R;tqV@M1!x|8`bpQ7y_ebA8Wtqi9n+JCsw}I zzVu_s5cEWzooOVG;>z4>EfXIup)C!Nvv+T`ugM&OIx0az?s?LrA*bi6D)dp_eA;Vk zsDR7Ef}&26QHy}R6NOT=(N+AO)084vdPlQBM%5}$9n8yUD)+Rm&WwG`is%I-x zgr@7miqgBT(jLq8aIa_D9fkV>sMzRT)LSh`Ae8?T0EX6}4h`e^IjwXotfO7tmo+ia zQ@rsFOziVkdz?*s98Q2AV`5RTD=;JKf<5^T`|moFA*p$whUwIh8_b>>7v4!@AsY`- zJ6kx_`}>Nl4l2E~>mywAo$1cw z$y{5*LISoZ2PS6qW31u{t-dYIDaAgkblqs_MKP>7X7;0j&{*M}Xztj1Q#KI`J4yK{ z>hdnTA!0ZrsODrK>kp+|^Qbu@@ODlmX$JHq$dbkpl<0icZfe58`!7`G{MISQg8Q)V z0`I5kl-&cI(Y%fA8lA6MiH!r%eBRMx=@}s%)P6V79T{LvZo!THecyCfrQo?=y~w`b zhHZ!a@+`3cVA}l@ubF6f36I979QS#A&2Kyyb7Q;mT25xOX)!7iGss{X&cMCU(wnAo zEZpA=Cb~9FH%D#{b^pqFRgaylFS>$gwf4DT1kOl+4NaD{BeGr0*2pSLOy!5$qem}| zChu=(^aT#LG^1a_Pr+X9&AU)UQ6KOhJICredw=|H@@pjH73#3$B1^)#*bEykXpb-5 zn5W^yNNYZ9Mi%?5Tvg)sJaw<%T3MuIX4O}j=<4AD*A^Z4%dxgfsB zWQZ@&iohpZWRXu?hN$x|Pi)xJM2ST`pM%;L9dt!1T6T?1P9P{U$$lLUKaG+X2m47ZDepo*R>FwF_`I_D-^@o?c=Qq?VGK^km{w1ccXO#mjH z0dqO1>$AM1rgpL7t-?L)H$A9l$XzFLuO=8{9G_@S@8fDTR~3kwaW^eljrBBoIp{;=U8_ji1LeCf0Pb?_ zb|UW0K_EN#tj4^zvnE1q4Gy{_{z?J)e}ezMoj|_gj?k+txt6b+c>nc@v<>X@+kaSj zOurr=LDfjolfpP1^K=xbS#QEGh33E2WXba-dqgPssJ^BHU>ENvd$?sSZBbZBIX|wo z8P=&V1GUwsJh#$v;_Qd|Jz7|a(+fz=4YD=loKzmA%XqO0R6*kvEY-Bx;&)mS;4z&A1W3U17HDPM)kOj) zZ`byZ7{64RT4dB=aURqrgZCo_(hQ)A&Ng+v!kNP*SWC>U;k}2TjFfbiq|LqbL6=scp#nE97*oQ^>!LiA{Te@9NK=7W zF>aqg6B5fB6VmDW7q6vYO<7$+^%zn0LEGZG%L`&_%d0*`GnQ+}!sNG+C<6PsjhsB+WVcKbC% zCez(ykFNi!uRVj;2&lHzUKN<(F~irL&(py(Deuts8pb_wWd zQILI4rG}$j-?Y7c1?2;xUpkmAl!Zfl3BSZP(}dC?G`OLjEl=iJgKnDc77a znmcTbhIi60Q&;rAk|K$U!y)k$Zdap|4Kcgk4*px6Z4$$%N!QjInjGsGh32^pk!}?n zdu~h9(jYMM^;XyseVY0)suInQ`-5D|z!4E+x^n4+_1-oShpS0gOnal|X7hkXIH!_p zpty;zby?m%690i?^r(?o)~}Z-gyyyhr7LohwbgkC)cV!iGiHv+V9IxIcd0QVn>F#oL|VKd9#?SB^Swp zX77Q@tqIv}9XWJ1zhk0T8jA7WMfPVFy=+&TSq%h~Eee{Z`!$?BJ;C(mjbLvRl)AH; zfBq2}W1}|Q7YflNA6il`Fk~;eg9|6Bsl&DBLy}diD^TDXXMBm+D97z+J^WNtEN`By?TKJI&b3ux zI=hfbCr(MD%Q^AGeM;PN9YQ`V8^&RP(4I(BBOQLY*cS}gaWu(i}rH&I{Vv`x*xaerswL^yzs zsTxNTxkFxEl+DdqW)3HT8hmE0NkP!Q{8r1?Jtio{ggCbpmdF^eb7(qL8dB zpv(tM9Y@o0RviiNKtr#axOVQGPY`9`PO!Vb`)=m1nwS#OsacK!Bm3 zd-}&snd=_48Y3)Y)M$80Up;ZIHt4Fi)adM28xL!>rztWFN;32vEoB8`&(oMT(3B>ip@g8dnJ(w=oDBP#J1-%HC#?Kxl@fWy3AH+<$=`l@vck9#)~lvG z5Jspr7c^Q4r7x3JUnsW6-jH@n*c<_*jL~$ONn0NYRBX`w1A)QRXDxZr z0S8%eubAPG9A$jt(j6`%$(*?w5S4LvVNAQ0u20i`H0)g}cZ3CWq&u=Dll!Rlksm*w z(X6ZumZ6rzIllDDzoW_Y%-~BR!Fjcg@HJ3IZByH{6)^=3gbZB^qh(X=ks2vP?sMX3 zC;>dqEqaNj@D>M4DCg6?_n&%z(Gt==EW1n4MrTkI_`jtUTs4^??w;(C7WKE~qY>ZG zBIM|f!$&=Und%02dE)$0I?U}deI@3ORMnfU<0|Pjhi=6+RROgp=N(tqGqRSImeMoK zt+j;?tLBgGnXZ{n^rUA6+W8a`4U#Gr7BtBe_iDgOUh2iZL{!w|e^J~WbdzgURyiE# z*e^qu76DA|ME-YgZY?xsO*B$mqK!m8G5o+B(|5E%7tHM9*)#vBr&M#qP#3^pFZuP9 zqHPZl`)j}9DhKm(uRJ%br8)#u_)pWTHD&alH%d8H-x|gy5?BVAWmkoQ_eBk z)FRGQ1czzeMhBCJ_rQ!c@fbT?frh_$FA3C+`Wdzc15E^{uDq zzwos7ZD|5cH$Ny5z2Am{^6v`1b2NWiEhrr<#nmwD9LL_s)%VB1qq@Wh{M(WCHBZdU zB0tVAUy{XVOI+TS9jktd_;Lj7qi(fCEQ-M?#;csz@;D{A!>$}0EIQ7&bf9O4?h?qoAS~IQa*HkPM!4Td6QlV z;3^{MXflhWDb45n2(;$f#Az1SL;@8tou-rGUn6QOdvWep?h3l1=hcF(^)*nFPE5lp z5%hW{F~MxB*V>RzW)UW1*Z7QZAsFowG|hTdk3U>V$$eyuTl;inOKOP)#8uBtI1Wmo z{Vclcs%yKZ8jy8-HJ^dRl_OfCt$%k%a!M&8MwQ$8Q)k3oWWy~hc}<4tA@cfITDnih zWfXmc88y_I5;2J-Yz*+ny7SM@kqMQdCk>zgOBUd^+QnwgC(*X|t9~~Rmb1~SOMsro}mW1937R|CYb(U?~F#(wPl^`0Yd-Rs;YyZ8T9)o|&gF%F}{1K<1 zbXV!F%UsM;GzHrB%T>LuKcGxI%hVYdaqFE4=5RFc-$5>aMt6lmQ;6%_h|zK#nTEH$ zgehi7i!B+Yaj6awjqUxt5xA~jg(5^MK>jnI;-cg@2o%7@6M)h6<)C}NLot0&J#F4S>N>$;h}sPq$jJerPW z3NOOd641BZH&ECC=`G8)`vR6|=#NOy+s##~r(1ocUoaGaHpN)^r+=qS_muj0(r zmTi`#^12H4$ACA96P>c?8vxn$OkpsKBdz7(YH^CQK+CAYm_6c&k+>=?Ir=BNP!PXI zLl-M8NI|J(nxk+EN0}XR>of!{wLQOm0B6;26Xy*Pi^o#l|TDa1cpvSv_ zyaH7G%1H7LhOZ->gZj{o_l^)Kj~1Kff|e+n5tdnv9T<1QMZ9;FwbAg6HKtebUNT_Q zrwK9ePopT*vfLI~m1_-8U0%Vbw4&$Ymp{o;AQISVwd8S0%##@Y&Ms=q(CB{fspIC# z?oMBIP?@dMXhsabjHb+{-OyZJ5@ZsU+pzyj80fT)h(`s`Fu8F+UWSXbQ8oQBI@+F8S|D*?#|M{%2$0dB6I(KlD}SBt5S!xh=k( zr@}s36KiC6V>|C!12$nu;FTV|9*ccRdAH)k&R2_shJ{R7ugvLC_vyiu+UJ|MD)PB8 zr)^o^K2NJljXUFBAu1bKFlc zDW=i+UaeE!E}M=;(ZY{*qX`W06xRi#A8bEndea|x0mCqt^yG*iWp_2*&TL-0tKDjK zu&rmK)7Bw8l2MpMtF;Gth(<64qJHYM(|dJuP9W~qMcNJzf_A1V+S`dCw@bNkO>I(; z(Ff8>ilmmKCc7Awa#^YY>td+0qoD@Qy|=zjEW(ja>}$)a+%o?&M4~y^T8jbL!#JZ< z6*1tPk%j7eHuau-XF|_8@cOmXIhW4vwC3cfRw&(`!x)IVd7c&|5d(3Z{BCxX3OHxn zHFa>{;40t4{iyn!AKXPLDQm@AvDqpS%?;19-T%ah4be9YIE^Hh02;{3wTGN+V133D zsc@=dW12E?G9j%qAb%4ePD`m3#D?xV^CwPDeOz0H;J!i2wKcL=&9T$(H6Lvn2Wi4a zXUICW?6UpIG@FkeRzWYTiWl#>kM?m%RvQrAKA?}x>0Wijm0khp9^U&e;oV*_-xf}G z!_6DI^5o1y2tU5c*APFy^>*eqf&U> zH4~5%%=_Mg!R`fU4mC&eKR4{nefKBlTr_{H@Q|#)?5npu*Vb?*KK84a4YTDPg-UbH@iLHGw2CjW{G9 zF5r5T+cVNL~=a{nLPBr|s?eFm8`7gxc68eq5=S^W ziQEnM*jo0vWS95`iyan*aEy&fD zOfDuQn8T^FDy{@%3(A_0tHt2pI13cN9_(Ya9R+kHG;c4}Dm9b&E7iz+mB*JL2Nq8x z@l^{TVZuUPCgpmm7TJIpxL3@J+IE%XTSmm&(syG?(n@Cf-IkW(tt9bQbx^j3D+x&V zo8MZ52;J5k~@!6#NoDE)&@x@?)HevtUoF;HZ2paZl3MxpGmL9ZGui|1okz- z+eqZ9{i6rGb2eBi^Iil&6TE?%c2;vL6oHVw_QxQ$HwOEb+peo&s{kE4i2!tUJeut{ z+`>fQTvJmu9l~{~s&}LL`l9DlGkSJwizx+AHO9W*p<}6j4CTt2Yx?~){r>(XjL=i- z0~kl?$>jF%nTgBUjy5sa6S80roeCwo<^JdQrL$`*I(L-&_O;Bd?4l%?pUj=_i$3@d z8S)m>fo(Ke&w=j9eSPx2RgaUrXrWD6=C%M>M(m71@Ek36c25+szIK^l9%<4NkN4c} z5jsqZ@T{po7qVpy!hr+U={WvSbWKb^O6evaPs@ck|2kU6mZDJpW%Br%GYobh@g~c> zmQIL5irj?md#hOiFxtK6VhPWUh_zpVw7O-@S8bI;|HM-VHBgKEeA=Uim`7D&_9uo; z%7JzM%hvvy9u%BWonQGmeSdeBJq0H@uDm?5oW9ETsCp@E=ERHxp zTk}=dMqh2rO_yyZytzpyb?@Y zR8LG}$JEV*Azfw2+z=6yCWi$|QL-G3NF{h8X#?8}L26CxE5T#(m2?g25=b;)vSfZ+ zMo^E;h-E=+OPxlLMQvP-9;S^75_NwGbCcrHVh39qG)kR}d624dKUJGcaPm-s)g-lIO9g04y1-j@KhwOmo>TV4Vv{=xYqkwT|-QjC9pf))yH76ES@rS7Kri8~OEzCrgSBXa-@7}C8vupuvad*=#10S}qrVqVELjOt^n+o68i5*8W>{@e&VblEhAroGiUytG&l1R`TSTd<7XjzUYz zsvapPcsTc?NBJ;J7;|BBuA@-kXfTXQ>X^9^PL+EzqMKalI!g`K^f9$-uI9!eTS|j7 zB$)l%t$9&mK-Mk(?bd;eb604qwN!vvG(n7g*R)h%ciL}(Fu17{w17DS`730IhFbu) z!UT?XblY5OW)2Y+xz(_qCuKtJvjIX>0Cr{bywoXG$m5v7zDn((N3i+uDHoM)H ztc|606!)N=No%gA*YKNCF;5ZUfB)>}x{E+(2C+0AUam9}1Y*s~Yi~l; zJ*+4ZBg&{oAe9wGMu?Z+(_79s0jHGJjnE4U<#{>gb}uJAmM#`^MRfyRB=}OvVZKfI zt||taoU0l!-l%5-)k_)GSmR&`9RrNouZv7z%<>vV)UxiL(S z)|;C`0i_1oVp6K{r==TN^R?IWKGJ3n0sHLY;FWxmnp5AW7Ty$OA>YbUv#ceZOdhZ; z;W>M07Y(!gxk1-dZADEF(^zxxwX#R92*&p(=(i^U#?9>$R=hUxp2?5ZWqeV|Cf+;k zg|c<6lX;}yx)NHHyx%=hi~)5ml=8Q4(xO8&Zi(8d#v&I7h}RVd>efhDfDtE1-PtxJ zZ+MCK)LpU8_cfUKjm*M_R*vN@p|EH4Ol?~FSS{1fYhn<-T#>a@hf_U}jBYe*&i}Re zA_fXeV7>PZ^6L53Cd=~Y`Q|Gpfcy9|wNQjqNTm6R#_bujd=JxRKn+m0A+WK$dFWKT zTdSj2*&SvuX73Gw4dvkZP!~5@V}K3>eFz?v`hwT14>$d>_bRQoDW+(`xE}0AM#!2l z)s^r}0+8qy=V?cph&7ncG_?N^>nDd@8)3aFdx=A2eLB~bazeFvmwd@4{q9?b>>Xb; zU+&c|xEghHjLdV(PmyVMmniq<05;bQSD}!)QC~9eiVg)%Z6Gv7x7bL)#}lCv#8kNs z=Rha}<-gC3>NZL-g)tM_J4)1Q540VzJ6?U>U<|$5!1gcfn$&6|jNhV!skzcMBJ#aY zBk!?vLtDfp`xfiY)~jJ6kPuDxUiBMW*ud3yTf5Ln8o3NF_dg;aV}sYw9yN$e6EGI} zy-7urd@hd@duuzJqD-2L+p_$Lsx!6qc9qSFn%|OE#R3QmnSDt|uh1%VUv^7yMQ)G(E-DHg>PN4X)7u7l?E@UX9JoJaqnoO+g1Mdc*y2Qsy^D5 zc7K_P5m(){u`ryTLf($t?l(X|uI?z6Eh5&{J;a@**=UJ^E6CbimgSy zE+}0rNRdP|b#V+V)8lt2k@CIQUM^4k=B@+xlV5KXyVqLF`)*ighgvG z{tmS2{{)7^z&34Fq`7$^jLNTTwY&0B|6tpWfEJO{r4bdjFrQ7Q!L z&@<2oQ?8{MDFjHlc!OxvhUQUVLmm;NwKlB19t)!HEy7k-J>pGgdTpLyCY4P2MgyI} zVkSb4aCk3U7c9>)=W9|h+>%NmJx!+6XOW)e2x8`uW!5$IA>3rho#NCpzfO4hI) z4OpTNLM3ndQIC;OB_iV_^|r!ehsjC)_=d}NRNZZpFw#>VRqhYfu(f3jeN^n}<-W5M z+9Js8UoM&hYJNul`!oE!UdB4&-6ayCHx(|qL`Js(qO^V_X(g7i`~Ol$rka)aJn+nh zfrlCn;Z+37Xvpws&y7uW-$EG3wc*7#qoJZ0&$2cI>CH>40)}b0lFyCgDE%5dv#=AX zG9M6#??{C?G+&qH3y(cH!;I0zGL|_x&CQ7@Kp19?mKGitBmQSjTZ8Mne$YWVL#peX zc83TUwDw<%y&O)KkRz%#=EvMUF)N;F>17}sQ5lJ~S{FR=j&TE?-S~=aa+-b%Dcl-+ zLMe*5W=PRy>-gtKTyINzPPq{;PQnc5U|%G6r_1OckLg$%xE60?Q5jy4^4>tROT z&dw7!!dlFv{k*EsNMjLO*xji)sxV=AK1>Z8&U-cN5FS3Hl(*5zVfuQ_kWAH`Sh<0T zr~K*G=sj@5^n{CfVhP;|9o(RY(!Cx8J(($KsY!xq4bUmRii+&v;=jr6ed}{~^7J$vvl#MmWiorVb5fqhNbuzfI$0a) zu{DQ@j$-axQ|k>MczygBX3_;PvMn+2-22tqDAg!@aGAr)@d(s5&YpWw$JqI6kigHM zIBqPgy0Tr_9J$}Oxp)N8H8_PWy1f$fpjwQWSV^Z+RML&(r=G0kTWREi^E#dohtX^s z;$b6Ti)UnS1NedNBQ2vzyrpg1iQFq&Bj?Pc=}L?gx)wA`mv{%Ghpca`mBSxhAQR^& zoH46IO>?kIAijPz#Sn&L-R+>+)m&!UJ{ersu zuz;mhn$PYXRre3xlF^*le3lScRC{|f(5%XaLx*}?P4qNx$@{t}4+@LrUsNhz+3iar zDZ6u`(03I%+a-^B6zhY?mT{4{R@JycgqDU26GCu8;V*2P<@psuND) zgA9I?9yA&W>;6FJgl@PFRP7p&C^}o~@FoF5=PVMusr0qiq;EXhxA zE*Z*L!_R5Uobo%P6jECl_hHBtcIQgC++lkvg+sxe4pnS6-Elt|7=Q$!bBmAj( z3CEgeM&j*MROI{69pRGAfPCf7SO4rI{c;KM6(}5)eMpZYA6k3ygtGD>Y~L;MLUlBF zxED{*Je4@^cK1wVrY5^ecU^kLAaJQ!SBm3wwWqzv3S{CYWn?d3blJr$!qsY8>Kgj| zn^TJJ1prVoh3BE78>YE zjv|8LUZ``39$g1u@2S}<4N>;+N7b@5)6~p!HJ>UpFd06CwrnH{=;lUMiK)y9NE&W+ zJdr$cO*M{Ja90Y31kO|qb6jyk2PwJqcIO(Yn2qqN-$`pUUH$u{URuLKsu4G#D8|dC zdPNqS=|D|1^qidl(q`7C^|Ud}$+JI?YR22XBe$o8n#Y_pCzyKXa9uFlZN6YN^h#>AZKK6h8i46Pxy!H3hJplh z#bWz?wlTjf84F7#n3j@$>mXA2p9O>z1;)<3y&cz{qObs3YSUjuFIsZvnj(W;>NC-e zG5nm;?LS*(c$ZvK!`JIDw#)&NKy7$vADV?09YfB%~t=_XY-8Y^x zTO7LH_mY^vXRA{sm(u)q4$!uc(c~%rsmRI2UjTK&%9{MvCZ{dL!3S1Lim%`Y8t$?`!02Ia#bjt1EY#@{I zSY1{9)n-$;o)1^XDO-9c*2yVE_|eP{R0q36pJQ$mtS4NCPSzcM`kV!ekHu{==~>mT zd?KX{7sfTUiEe;alK_xLCw1d`0Hnq)dw=|WPnocfcG*2md)5Ht&!u!n0DxO=u8}R4 zDU=*9M)xrlp9pQqDECZJb2Xe#7{b^x+g$v%OBkb+Ue=|xLS0Di#;cI;D-%E}AYFY1Zk&oeg@zg_EQW&e^3!=i0faAhXsY`EAzCIwdh- z^g=dA3&2~nKTUD#4VueDbX>Edv2)?FKI|}5|NjU<4 z-`dI>{gdS+HlPp|ddHUtzAdQFj=girFs2}xl%c|1dd)-)ud24rkfZNo%ssKLI6h#% zsL{YOx>MCh5UyOivNF@SfZQj%snzkj58tPE;II#O_PY-91ptfUvs^9C@C=#-A=pfG zYWbhaD^Fki97mtC2{ql4X<5B8m!v%e=4)#MC45To1+1GwP~89d5s{v;PPTN6csRi< zueCT$PAeBhw5CtBdcT!u*v=<^>8hYC`9)hmhNczeGDUgPo1?n?AfkF%rZguJ+tJ-7 z;DlGu*Ro%upvu3NF>hROG3XIGO6y zk$ttxBBjDZNP}*zT0^Yd2`q4|M@-b>0kJ`IVXByG_ zat~PDn&nrFz)UqM2%E_w)6hMLzfbkDgh=2sJkO~L>RhTt^RV`BWu3p66lXsuV2t*a zUfDNnCQY_*|1PcxGELVet3abU5+xcyM^hm!+4YgYwszbmEGG8R%f>OPhei&FO1)4W zRp`117d?3wg0=Nf4QL30_MB1mj2Y5UA|RF4_u+z0TXIZKI(mzKjycIlbu>oPoafgB zm4xqXZ4?_S$l=vmdTG%hISfwh(I7GrkmuLhm!;jI?e4G#V7nDu6k4QoV?v!Oqwbx3 zdk#Q~{hFoixjZg7vga|CUM9m!O!F~g=v5E!uD8~t;e-v?+}yOj>J<8j)-gzknkc0x zk1I;-QZJ5#5{wGD5q_nH&}Kc>qN<(b(MuvJ_hTQ;W;S}q(9v$?zB@fnDS>FhODiSK&a7hg99L$lXij50t zmmJ>2<1;LYts%|%uvdTmQAR8pL%Y8>Syf#+qchiiRJltFgav*aF|=M&v8FFOT$q-U zJe$hU9GA?7t7~n3I+3?_i^)M%cjL?gcKRFS(1x+S1b_u0JFN|_r+KV#aQP%kXZcv1 z@EAczt7D6(eMNkC5nR>GMp@_F#-gi9QKoI0r#vuw`Q!JsH^OtzfN60k4{bBq?{yB8(7BGlWx37EOW!}!b zwc2KxL2oI$_w7S1ABbOEV(Wtx>zU1RW{&-+>3Og#Ca1R3MwT}or=@g5ZgJjQ%&K=? zsXwrnfHRfM5%r~cRsEC9KvzPOAh;WxomOUwdUmwI*L{4>p-iJV)L>5fE5F8o8_<+?d*^#gXAQ@;)AVwH5}MEgi;#Qp{Ggcq-8mtf~M zML8pU@NkdVf~u_WD@t=Bm8;W`we%!jSrbcArzHNI#`S6pQMRZxslr-9+i?^boPZ$R zY@g^>q@;UsXmmvncUC(GfrK*J74i6kp_dv&X)N8nBojpOt0g%aoLF3l?Ox}7*-JEH zz`-YcpZZW1MHNccBIU>ikykjcH4+a0_dujXaOs5;dl^KdV?(&D{ z^F9cHi49K+Efd0<&~!Y3xpnUEg?6t}d&1V|=1Jf~R&oug-I?wla!SFsw?kVJ^tQ!! z-O)Y6;iCs(cG5GcSkkW7^suePto42Z-iw~Jgr}Cwy+dcUt<7k7in>(K6#{#UFMa~q z%A-~`?D@*A?@4j0*0VeeqGq``by@LTrz~{0Y@JHo?{Kl15?VhVnK!yAG}nf%I;~SO zP{D7NOGc29sQEQF&NA`nKtb=U2!U%$xAuPu30X4=sDbrr?CYX$EYTFKm;erLP06F0 zQ7*01kxAasB&pjou)kMunHBimE~HWQOao@gmYqhj6zP~-aBOyjb3jLc@I>vx1ib_=0Pqd6+zEoc-CsUTwNk}n7hk&iFq>=LRd zbI!1ds*kU^w3^njbhMl;14RM%uJ2ou^HJ~Gx`{4|?IxWPebNThWVF}vc!GC~wYHdW zmuT>$H!bxPBP4r@NAfsomrq_44@LsI+9gf{msQ8(Hgz}&vBl}YxLPD0t0D28R@J8! zVsd~LmDQE&Y1NF7JZ@n|(bc+0?icnRGQib=1n%D^Ek>7(zF|@w@8dStB;M{zNqz3I z?Uw2QUrB>a6ZyAL$Nk4H!Wjm?ztZ*83r8YyxBPe2JvaL0Pteh7eDsLNlrMgRxu|FK zoo;H;gx5rb4k4JZkxY}~`0ts&->;w-Z=ubW(zPb-;!U~!8sE>wlDb@BW<#r|ZQVjo zfS2fC4Nsxb`skfp7UxjBnN-!Q{CY`)<3$U6@ zBMNRtfN?auC@Cpr{gQagQXkE6bjp#_TPrdsndT5Ae|Fn6MKW;jrhe|(p7`SD6ynNK zMwOT)*b96CRm%HYgc114SDgxTFe$XaQ-wiAmx&XS4q<2PI)j%pUe&AJ@sL4Xe5j$C zWsVCh$K@rSsG%FJ7_$jI!Mge|O@`Mmp?9xtGx~yfN6U-jsu~pO6)~m}Bez&w?!9MV zvNxuHder?!NYwQWuLf$GeyUdL3oR8Gr35;qLoG`UszW zXUq=)JN&Y`n~)z_0Q6*A53(`_J(AUVlxD(>fs94trNy`KBr72#2Nh~=1 zLgw1*@d;bps_(itDZ*1_R{+Eppsk)-KX zw-yASxoEKJs9al(5Yo6(Vy>fZb1u-#ZOn4>RTnFP9)uw@-3^0kf-qu|6dTxr?(W}c z6*mx5QFSz!SG4dnX{wBH*4)3zhJiV4XM28)`-SuL9NVCs_bEjE>1f-a!)euPS5c4V zIk|6gyzQo}Yc(**S70px5n4YLSPsb{2`6>nN@!5iY8tk#6T3R8wF>B*TQgX)5wn(# z(c0%|LK^k-08B?+(Gf?9Rqo^R`qIpHk0WnNET=8;Qj}F3LB8M3AD_P2`gr(J=-AL6nytMri1rgdlI#}u5U;| z=_(fPTkG9uda1ts6t189DWyn@Wm%{vF?`l`5giGYF~$vSscgmCXmm0N1Ecu?6RE?P zwX91G2a#IC0n_g&pQl{rD^?(qpr!d}7C|ZzP1U+b#s#|B zdfhKf@ndIhcLCDJ%@8HZmaSS|b25vTJU(?Hbm7+ZBbOR2j^mk)?(;*usL`&RCHniR zVI6ao-BpQF182Y zdA1(B?IZxQsTPa~hSZh&mL0beA4!QuI``XIe}72^5i6JDpvq_i_&J5k+;TMH4hw`& zYDV3Vj1qN}qt&Vw-kBS|nWt=oeJw8|))s4M>&bo*G)7BJ97IzN z{&Q`}4Bq%*X%YI%3 zaJs3^B{-=1+!u;`J@$~X&TSuDpZS?M&85o(q&iMlM!hX1^m)qym+!r5{3$XNY&2q> zs>+p?08Ctqs_xDlEfh;3TCU?eBoXUCt0CgIY5vNPUGTh^$Snbn^|&WcblaXq(>~vO z_YtL+aqQkYSGw@dcTnb*5YK8vCVDTZ*8NEw8ht>he&6$snJ#f^-W-I*JhKHADqOq)V@%BQ?}eLQ_Ey1f)p^0qMP$ zPz9tD>5$M%XbHWi@VxiE`^wxuZ)VS$@7rhgo;hd#*>k>ctwo+sdL$=NTT);Ab6@?w z`aRF12cGHfZ{f|2tbVMg-Hg-|Z!ZK)UQSK_E(tkdKh+SI`u%5sNxMPJFYSc*GCcaF zeVL+pDzBLW<*X%8Xzv+fg*YiJcK3b*%Q6_<;8*Zh^I0IiuQ_a3!C$)xp%?P8;^WXf zVqCg@`PCW+L6iJy@n0!6Op%J8Kc?+8@FGahq&3T=RwIYh)B)mU)pWCY8R`G-MWuKf zYd}99%YElX4tP(0??5L(`S(7?jww&&mRi*w(wR!f@t^viWU_Q|+{24$>RPLxd3WdG z<3_KdUw9ivJsvtegL2=FEG+a%{o9&%@6i|CG@a~bq2Y|}y|EKL6RI{3vq5#__qBgL z98;#&rXiL-!cy(5K74#Hqn&fnQ63GauHtN7m6vN|N_&b}6v<9GQFyMD4-)K&|q~+~SSrER^$8R;fcwn1`Ys zq9Pxx?F!^`Iv zd-3-bmtuvifO8MKu3D)Z2~0}=YL}?+OK?1`c#{)p+t7U72VVV9QGH|!bkDEGFCsDq z`NVI~!|H7SMC8HrP2LtWkrKS8<-|lNP5#xpNYB{ z+3GdwlpLOSgopWI<7b5aU#N(+0alu2OQz3M_+iujFL0$vkgHrcLSiq~kCB4^5&ah& zBY~9*{|VW#Pybi+A1Uw%$@RZs?eg34|4xsQqLsk^1K9xn|CzG-Vf&qw&1H{}aWgTk z(DnMYOX%^x*xV1B^xpt6?mP3R_Md;Ew;JLoCF@ieNM+-%-+%HvZrg}!qV{dUW+DE^ z?6J;2Mpi|QPiosP#_YrXJ6JNZb{cJ8Ju(D;pPhYF)_7^;c3iuPwjUTlq@`yoNrFmU zgMKXF%iw;2%=lxStb9q36C{Xf318NJX%uu^OGMj~j3Vx2Wb;Tt_C1gBiS~@62$76z zZz&L!dyxJr{(0}EQPgqmgH3z!Q5^092--0Eeq76W4TA0eW}nlx!38l+bg$jDpI=Os z^+9XcJ$Pt+_wE|W-R=pN%y?Z7s?z*7?;k&|xWq^j1vvGl_L7}ncmg`?9$+dekg>!4 zz7>XXrrZfbHXb=@LP-O?BF(0}%)`2C3^+Ygi3BQpk8L%ftbq;D<`CZGzw4Z7rf+np zY?lq(h1G=$2gt>nfAKO;t=FWP8YZYZdAO@PZ83C2sychbsSDK(gh@0{@-m~=J#Dxa?)N)HhXx?jR?W#Y$f3syW|B-O}ZJhcE}YvH}X_B1Kj)Hr*rrew5ip%4JgeX z4vN&-77hJP&Ux`)YdrZ)J0kyc^?%<|D5sI|?EjgLTUZLbDo)MbO&^&W;ds-3_iy$- zD;rxjq@t3B2CI3lAt7l(hz{I8I9S;jSUbi}1Rs~z?sl*B%}6HGbl3)ad!G6_v1gfq z|A_C;p_66`FHfHm0(O?3?KWc6Z?JDbV<~3MGw?tNB6uHWr`HIQ!*{QZT=t?ZO|SUu z@&T~`#5HaegKoo~t{tZrLaw{kCa?tM!`@v~Fw7Y=Mju$$)*=sYUG^Iu)56$4gOa53#+y{8GpJ2yX*O?Wp zzPUQRRbu;6em>|3QyAQ)uFQmWmYDE}AG)>t+Ep35b+eU9{w}DaJs`llm6h2K0&`6! ze!zX!jHwva2nxE~I>H}));t3cWXQ+!rzi5fWyCj$7yZ9KCNwN)>_TOPm%^j6%;fw7 zJj-2J1%81{ArRM5A^D~t|88)=T6Xjju%10Rwt-m_bDLc*z}MH7-Szt50+SIe+q#G& zh=!q0#+g=5oMAy87kGPowb|8zPw?5m>B!m2c_6b;<)Yu_B@LhD!x=avxE2^pqN(MEJb#PsaYC7i+o*QZEQaInfYf#5DapB z@Bw!EXWQW!2)0Y?qd_!h1VcT~J_LDvc(lA~@ZRfe3>^S-KVCOr z12(lTI;-^_J+%rVxOoP-0)pFt=u;mW54cKUW%}>oT^1ui=hla$%wIPIx+v zS}R+&+l^l?Q&xq(T z^m>ob;oVmV@Ry(LbN=R_S-E&&>7amL*cRX6F<`44a2{C6KC`=EelQf<&{r@kf4p)! zm)(GqwXXP#omx5kJTgUrO2%}{?&Etph-QH})rBa8-0a>@2wOqknstzUA>jahbc9zh z`I9$t7>@pO!R+jJfxjA<66tl`!K^@om4qbNGnQP>4HG=!zX#CdPKVi7zmr{uSEV)B z!7N-?B7A(P33Vy4!#)-vqKsbR2Aq8b#^+(OJ0m(liN=ko7&~tq}yAO zY{bprx3#WpX2H1-2q?%a2*M`t{1;nH%o#@N8@r)z?p=0qK z62;naq3z*!q%xR%#7%<`8fD8OJU!dB!$pi37dS*>R&oX=SLW1j-qcJ_ZVUJKg5o9p zi%QG4Z!ZLTR!d*6BZX{1^X7qd(!t1BqXNLj!-n+IV@+yoLBs2I8Ohs(k(INZoueJ2 z+}eHB%uI0F9eAtEF?kPl5M@;E2n_GWyt)S(6A6^2@@(_F*vjx}5f8Y~U|%}!Xpxtz zp9rwjSYA0~^zdxZZuPtDf|#=hd*Y^OBxu-O3ru|Qq<~?uVLN+Kj9Z48Gk7N~P!o)M z&)9#sp+F_=B8%mznugguoWsAYMpChzgg1h(-m}wdFFyeBP~&#iyl@9-2=~3s^eCDJ zCdm|GDG(^(v^sgdoqPFRW!oWgNrN+1!XSBepnF0<=NJ{WrkRz)bG8=*e-nKAHNf|h z*6v^>E3VGkKQQpDD@?k%fNa!tv;oJ~)z_YgV zmktHpP8%M3AQ|bF^X*!ZPk_;Pf!Z@OddH^|m#+~6i_RT+Bd_z#%$)(8appmF(~O0$aiqa=wqRQZST zSSNc}Gw^*DzK7T(cd8P&lW?}qch;xZh`Rm-x@09v0SVgvS9u6|iQug4LnFUG_Bfhr zS;^!qQSC=pguWN-#F^j@TmtyGQfK^HgZ&aj#78G!PY#3yPZe`20w1wEe;9{aLW#MEr?y3?we?Z;j9B|t~T{% z&aX!bmm&qO6_b%WD0NM^3j1zOER8wJboadPs%ddc#Ls7iYoR2YWhbp=t+*mM*FeUM z`Xp8lv~&^e!;Dexm>dd_*h;6|@)jf#xnGPxcFwug@S$W0EIm zzJcqC54EwHCmqDVz?wr?#$JV6S>wB_b1%%yNg<(&xYoJ}@y4bfEl>qk@0%4|(wSxM zOTc%>3lGa1q5He%^K1l52-nV69u zi<5OEc3v&HhY1k&`X#q~u87rC0Rd~tkl?09H5vBD;52BJNzU3CVF8_a@kU72=vaB- zqPr1{OS>XBtK0o(eq=)g1nvxJ5m7wo<<82@4mYDz9}iCv)~0|hlLe)9*p)J&kk9<= z%%m_XSaN^8bH5oV?W-d&PMp#l-=pFanmg@`#p@?aJxd2)d+=4c$J#c&2t2CYLD(u^ zOz#qEO*e;>~v!X%QepcfCgpU5tqW>rqvaDJ}3FZsA*wF_+07qLVEN5-9 z*NAK4&|RftDuuBtU4CQ6$RKorLC)5~Ywh!f#p7W7_Sooz4A4D!4OJ=W6G+^0{i%FM z7Gw**+#Y3~UpM>9oU_!r`7nBUvTgnxfF``10ir7R@lSlV-V+B$M`BE}h=!7$lCU}H zKc#)96IpO@5V3NhCEZlQtE7#bBt*m9$229F%nRK8v zt30a&k#m#C;R_s(Cg#u|0M+V)nCsdO^oUGf+vxC&x;L*gGOLF+Gz}nv~y61sTnmh^zzT8s%zl))%t{_K`1< z^`6l{bXarj0oofAG?@7{af-f~CsGclaQ|uhs|AD{Y|Vi9T6@!;b)B#E)ZXte;?mi7 zuu-F5v~{@{xPp}T>*ZS*wGS+iI-c$b5W(Zh_Rldi=b5ec(i+DLpa5vVs5CJNW-&To;zcl6;(5YCkQxdGY@;{$98dPHQJ`L*ni_u|{v6sknX z69>@ft_i?(zdR5m>rWyb-Ne1u=6-xYd)*;7yuHE>^22|*j5k0fba#*tkXxqm8H>I( z^wX%W${dP^W_5_YV!ed@vnw=aMO3zTH}DIW!7X+%-p_S_RKu08eSY$7WNQ?P8Y3r{ z?b987IzTz3DXV#!R5qkF?;7b^+dNK|O8wDA%4z*x_sCCGDNz#K2T!6e?%OT*2MrYx z*>Zxq%(3k9SL@9Pn8)$g3AxNHu&b*hElfcRM3CSikI0^v+x20O-gT&JA1dwSgVRjb^kEEtd#1U)D_aUb z9sIo62u$p!pz$%+1qG>V1RX_=(T$=i7m=5Fs2WfC-$ovieDBCnW(_l$-x97iKLdji ztaC>RnF>{Gkojy`!k$p?9@Ap>noSTQvu1n2)+Zl3(Ae%%MPPhFJMMfu zja4(v48)()@p7I541UINa_jYaZboI6bwt*zf2M#hxC6X(z>nPrlK`18=e@LPALV96 z%2Ub34ueJaz7f#7T}Z2;_i-Cd8;jpTlv%z9DwvoK+5Z}?EXJ1(x#TSec_*J+@P+R= zRw#p|JvUEnG&Ll!>9U1Wz;6EZ=HuYiQy_rWwL!KzrpO;STRwVtg));K&HKm?PhzeZ zn~_IS>P*T=z&B*%r4P1x7A$?xz~SRTuje+;fr}8!BPL)y3CXCukLOb|xl_Voxkt)0Cbi5t|*n(l_K(dAc0i+ui2C zT&Kwf!4>qt{3=$r#$wxdG!LzWTPBV!7AuakV~OaW!XLX0=O_(S`g@iZ46Z=o>{k#Xhi#GdaXGQdERA9 zRx5-Tp6179tHTb9D1w>v%mXDVFhh%sTp{vEo^R6$>@c3AW{MLBZP=Vh*4~Cp06D%{ zGZuOoxz?0pl6}-=Zj9-%g~}YzEsrTP1+Zbq<&;tPNexEZuDt+{V)8m)+7>|(} z(AdGQ{lM@m12YNvueQOv3d|ix#F0mwZ9~Tw34X5yzghb&Q(egko@E^S@(IIMN_t#x z%`e|ua#s$x+Y>@HIR=Bjo$g+!i(a+;&<4TxhMnSIvhguQ>;=xj9%|j8!0HOk-MJLd zR2KsW21`ZB`#7i=h-J~9K*F9aWnfZW0e?}%#i;z?n0rld3D}BCV1>nk%PGD%sC82^ zpOv9g@?1);IPXZHPty7NV)B;KZ%eqUnbd3#^V!yx1p9GzfhdK&A*0*R}Z^H3LPjNwu*TX9q8u=**Oo81RE; zUEbQH1$vrY`ld;fH*EMq)PJ;1T5(4n+t&_S2D~<&&lQC1bN8@@9@OJh0~3Y3HjH`it_*AWlgWh`%}+RATy#;~YMyAh94vHg4JJ(HxB-_% zb}54-Q{oaKJ}3ArsHuE7c~Fe-WyYgp*KoXR4eqx%VX<++`YJNLApw}Zy>4@E=zwGl zkb5Urd^i>CL$NASWqE#91s-C}+)BO-^EvpCWnH#!=VxB>9MpFh922tWE*#j_zHMdN z(IINp$43G0uVoMPX8zZTw5Jzl&KA}BcdQ)__T>X?_5P!)*=@PXoW{Ic&L_oajlq)Q*WVy4B00EC# z-C1~Y0%5mMr2P6u z&2fDu{QmTu_r*Zt56O<2V}XVt>a3tgA6uC>^=a)xA9e`uB1N^Aq3#$!kp2(plC%?s}~wJ9360mR*qj&q8k2wW?$%$cyr* z19eiW+<{ur)WldXEdoRZ8K5FeMxFmNn_e zb-m{u9gBgXbV$I|RA(^j)kB{b-G^f+_`O9;7a*pgqHA8_1Q>VQ{=>1pzi0BoRzjY( zMgyckvIEQ9bY|LjrFy-(jrNf>OKAZ4UGvMI|CIfkchKI!a3#mGu>23enOyA8L{ye)xE)e|!_L3FfreX_;ZaMj*5;LF%>X5~vKwh}^sCP@L| z*TthspMBX1r6uBZJQHrX^DD}d+G;NQ*>P&JZgGRA=+-o1c+hthC;#cl{NO~Ccb(+s z!SH2EH7c7G9zal-&e`8uT@1Dc$h*kGcZE<;%DAhGQkc92%Bk>ZJPOt1lyx*DA4(JU+x9CU7`Jvl7=O==<4jDv4%=t=9cuOs8-pNu8A9K4dG_Ed3xq}1 zUUB}$vUKoTiwM5mYk9AUFHZrk4jX$jH{=2P!_fet8Q(3p&1v>0?$DfHOCPPnE{U6F zd?ITVpW2R=A4Vl?8^9dGM^v+#aiXxne)7YI%>Sy~O~WIsm2QnZ#OCa^(sKY25!|Z` z#;qhc)i+LSf>-$ousHxg2!Z;Njpca@u8E9u4;*y`uB|awUK*Ppge7$NgRK0_>l^_= z3u_}?lAxfgs~n;{uEo;+nwKiD&TpW?b*Jl<-x#UV)RholQDgF*(@m*CKw=N`^G$77 z6NP#uELf_-M_MHBZ(w0q;2*P98oQ3cC~^P`oOx)WU|w4Qs2cAdG$ssB4*@F3>^DUC zfOp`xm2}-T#}`dxmmOdy3vB@3R37tJ#*oUCizCVQ38RbgXtkogj{I8}EKc~}WM1K$ zHJ%r{_RjGzZ$1DB zd~mt4wZ?>M{X%BUD(lqb5X%sPqa0zf!2Gmgcjek&N9(1?RhK;o8ZmhFS`%$Mie}Bs zhd(Xr7~x&OhIE+9XT!e%#LvcVqJLa8$LI=_bM|=PN z)ponRSEdb6^F?JWjV|kEg}6JTYNNW(7YTEyb-Ey*ArD{N7)1PuS4&4hG1>~DJBS<_ zKQ&4XY!d_7WWHv3vFHN1yx=0|GETq%w$Z=7s^e9#-9Lfw{aPp#;~j{^$u`> z@oozeQYc@TzvIMx>9`$ej?w=rafS2TDbG*D)a3Kpkrd*F;(s^{3MM6DEAM{OHnmi*IX0Ih1pyX|vA zKQ^tPMboWb1gK+ig?PRMBU|fMtyQV^j32gs*qGF${YQ&Ei_i$LB?eLn5vLZ{(%uWSv+?A$8H=ul5ETfEH?&YtYMrc z*?ar4Q~EBVKO7uJ3rEB!IlBIICJm{phL(y&>sT1yD&c{5WR1<2=+6BIO4eQ`P^Z+?|%Y{VBF zZQ9a?(=~way~j63QLdU|uj46E<8``c#$f zg+%kr70glt+Lg}!o^6`C019j)+6al|w(O*ca&efCs=I~(^5c(XM-+@%{=vyt*s4mjEa7XG(lx; zQ@4I=vI30h(vFlKDtM(J!2qIY0Z+!1Xofz z5||ZP?r!T@SmyKc>64!gZoK$X(EPkQqRzX3bz=0Be67K1hF!vzjqD~Jgvu`d!(;nv zLoXUx&k^eO1&5m8D~D8U@3h}&!@~lUz8QkrvawbK(O2wWz;nK=89DYto_Om@f{VQ#Fn2u_| zMiRCsZT^(t;%e@RZC5KSx@=&JP0eO2W}`+QvZ73G^CSQ9f41BAs4NQqn$IQ{6$^kh z5~$~5y~Q`C@V552%;g+*XTxS1$BJ-~H2*#?@;wT~evsqno ziZJhriWY4|gVR}%oz;)~m*!PCGfTrZSvCY&P@J$!3W=vY2f)2{mtTMEh+f?wzDMWWvZmrXgHNta-nrrZ)DhEQsq5^pB9E#@p-4^2gv43 z&q3a_Lr1$+b@e$qR8+H_N|JrU24s#Uq>9T$yl=8$Z1S`8XJYC=WUBV!Q~=P&CPC0T z)k`>mIhdy5He}J65%RAAjl=52!+gHQYy=UhkqK-re;J3JDQ=vw36{{Vr_aOo-a+!F zNq}uQUtg^=0E&3Qq;QfST^VGel7~hscPHsd$BDi=~96iN53`=uNfs6j>XHrKHeaMymEu=~E6la^`0+4qQ&N z{z68{V%>Y*8)$C;I=g;qUIiHGXJBV48WhvkgYlnx9xr`Eu9;QyoX*)`r2Ey=a@C@s z3_X-r0I#gM(+3LAQ0I+d{kiMV*q;`+K4(ftR2aUvhc=&MR$uZBskOj!uyk#ERnFq+ z|ny1x* zVMJPlYYn#MrlZkfG|ToSw&hz4h?Rdiz_R=OUR6o#nM(o^v@;BK|PJ3^^upcp|vH;pG6i(Be7{XRV?yp z#@=Eq_Rfkk1UI`saadII6gQ)W#@=eOKm+ggEs8y&&ICSY5}dxxF(?(%EiAh6deMHT zmNID<&e$xnKVnM9nIoP1I-brhSfBJr9N#*)KqanmV;ORVzd8}{W3F_@u zl}xSCLll!|;O~K<*>_^?&kJNDLRp#2XhW3$x;FH%^*~8P4U*@kz7obv32I0yY;|#y zF`8nesJLKF^I4y{3$NN{#GmGdDQpivFLm4R(W2++VjLoN02?b=#{6n6f4f5W7xnu= zqr)PSJZ@^>FMw$d9hHkE6^94kO52pXD$_Fwo%dXIm^wfEYkJS6r^{3)02RveCu0SX-C#5(U9YdbpANEDEY7h61Rktd*9mhiQ(c~Ug8UQsSPjzdGj)d~98 z00U}m+?J{{>NkcW9-@-N!!1@{UQrx62x;-IeQ-4qfV=v3_RK*86Q~kiYA%&ET)xAu zboIQdpHN4~ekj=qSe{eUv5a$Lcc@q&J-Vy~+a?Z54qzdxIpl(TQLB=5e*H&;`4 zJMZxE*ydUN4#Y{siBxITjaTkoWfRAq-EXz0nyM_|V<$>J{ebU}((N3lgPfxvSHlL0 zF9x{3&6dZ7c$+kR2XmH*N9_sI6wYu;=*06*&?6DYzDQW+ug_Dm^*{c?nJje!LqfE^ zNMLx(emZ~s$(k{ncmTHB!aeU-lEQAmU!=Jes6C@U-mgdm8owdYx?8OM`bD?psDb{s z)e3Ij@KqYeD*otCM`lm&&PJtqyS9TGvKmmKGWQl%g$lp&{8^gLNs{q<+2uqJo}<@M zahrOP@I_oUMX%?Ouc4oDBLdna{+vqwA)84W7muNEq@h#-gLVz5HA5cWpj-JjtFSX6tTQ}&rR{`(mM+dsZ$YeR6@nx3Dzhb=U7)UkY zIc-6@Id@bz@!_3NyuIFS+xP~>r{(GSb&TlJTAUdax-OS?f&4-pzQ8mozhXAtFFmJZ z_xyFC;{8MC%T$FQjBAzfcew|-Of?gWGM6tA-cg4JI z(YMcY5l>?=VP+$DAK4?ErXR+zxwrB;-g zDh<;nSrPllV@4g8q1x&Yc>Fef=LT)T+Kf*L%^8+ogK7SaO;~^x!>OCIpoM5#gzO%t zmhiKw^~AP*_jYy9Wed7PzW}>RNXg@eoE&Jr?yQ;T1?6^Z6}R++;meL}1^wp*I4R=u zt6#cxa0^2Tl(2`rFLe;&6O{yQPNk<@DtRe7XW2$0QIarb=T{YW{Y6KPi>qW_t3CbK zOLu$id!@QbxLLayY!B*V8YQWdFT6Is^cQ&x}lLr!*GHzCp8M#E4E{i&X5uF=;| z^Gm)9H=+VQ1r}DmKkW4(9W#j>6fajgQO#lSv`y=oH$KM|@OrBMyTpB(X{b8in*X*W z64v;FmN%W+xhN$Wy+ot~ z*4@E}pajG6gt>c_3sIVKuqXO+X=NnhXQ zvpv1S5UFxlJDYWh*psBA)tt$9K#>H-Ctfj;uq64e-#H`!Id2K~6>Z1CaZNi7Q|M3P z86!1sL`v_or4od@Vii-!AEK8~{)P-SZ|+sEx*CVRdb04kan33w$mpMgQq=`qyKA7S zpum`u+)6_4@+SGVYRn|3IN$NzeqHsffSM7B0j^hO{YAJz(Nyw2iUspF-RPs@L0V*u z9cWaE(GceL%row?Zq_MxFl-+MY|jmNw(vGu2v?o$W;@Kv!6zD4c3YhyOz2})XKb^bvPeaf{D0Ma>EB^-u*xz{b z#_zjw9O>jv=G&aNL3Z_iR*sg5xF0o_k|q;+;uB&u>&BY* zfxL60`k3-9t3SN-v$QQ&Mh$HgBynmTb@!EzWq9#VJmL8W!Z4X*oZ+dVHx+;i;mjwL z)IW?u|3t@`-BAom9KfW^plAYvoPho~NS^64i~zw;j%S)1HF7SC@M{Oe&7;#cQslEmxb*7fZJ;29(&E8>o`!YGx> zuT{U70iv}W=xh5(Lc7LSRz)|#jBGP`mySd>W4{M8whWJD(tZa`JKnG;x*IQF*BKST z8E`e71inF1C4gVe8?<~BMz-@aL(4H(RX3lW?)3n*(-x?=!%6PvnOx7rCp%g4(B!jc zAH8!NZJ<-nANYNVkXAuH5xN-;%&?T56^%2z^@6_elSpkjuVuVI+7~AUEb_Tlw4!kz zSnR2Us!hQRyC%7wvuk?m4kyg%*_TiWVGN5%)IcT!M+%AQ>txzQB+M-aQXWCsu;9(< zt^fO{rGkqBzegnr;YCi>MdV$hP`d}uPiVa<;D+t?{a@Iq|?6If`o`rhxkJ2Vy^=sV%onF8VPpY6OU{;4va~CgS1nM0#Ysqo_vZY#`?E#&|p$UvZiz=?Z zenH96{VjI5#e=6&yI7RpiOxz_MI-Oy8$AQJd?oQD8>qA{3yO3U84#u(y7Q`=&WN(MyP-vjPrzi)Jb%tltch|pgY?}@XNcO(exTQV z6_)qG4M{fs9zVUNzupiWsD{pFU|rYBO1zj~laHM~d-;TvzFsN!oh8q~2Y)F!#{IBO_Frq>Q+~~UY9ZE!XL-Ol z+wsbeqtF|g?>ZfhETL>ZF`JCVP|BQgN+;*IlaN}DkMuo$JN(Xg#*1S%jV{~vTT5W2 zzzDrO>U}7{ws`!8BI(g{hKMWvy11g(HG<0(wDH`S+j>(;FQjA{K01FHrOe9biy25M z#f!vQoY?GeX9X&~=325D?0D9|=f~7hE|Vpd;P9&>y)&B|&pf|eU5#c>y;=9`LEky) zlTWO?^h~JzgB$89Dby~Xgx^p{(v>USr(*j8Gn7(G82)>6i-$&#r`P%{{|){t7wIF& zpVS>~n9iz>cWKcv*=Q%cZtHfq{6PTP>N)o&^U5QFZc4gpIjfl6FTG)61^4T&ZVppo zYFh!T^7uL$*rJ=lDWSq!S|Jik#U}2O)uTT~!w~aW$mdmIsJ5s=EU)CD8bcW;`{1~^ zhpQ81Mj7y907@-{eonw6%?3*gUys3m+AvgUYrLE`)bpr_B=cn7jt>*<64}mFE>7xw zkx!A>+igTm*C}qAdl1iyf9TlGKuN}M*MgFtDWxZoQ{q=fSR_w3<9%)X?UKUIpInyU z(D?7x4b(F|>$PqWSw51Fq!x602lL9$fMm9!lHYngR(f9YMHX?N{N~90Gd;jQ8^jn} zFHONq-|+ah?k^uH%#FAKT_x=)Zkz2$nUeRG71)anH4+!LMQ(DUq7a!tf*fU(ma6$v z(Mp$xDJriZQbV@f0K0cLQ_fLMjNM;W(he^dL~|bfdT`^K4?{avl1%&KWqHEWTh9E9 zj~*e09&rPpa%5LFHpzlGZUL5lbo>Of|SX({^2Sp3$fcC;xf z6Cu@Wf&t_rJ2zQ)J5_!7ZUonn>{efUeEHqMv3-Ni)+mp2y~wPlH}h^i0A74>H`JTt zCNpV>pt(xfti!j?3Z}oe=~sA?{=76vlTjppaa&6eWWUr`;nVhaKUT@~?&7>30@rfWmC?%d3pJ$r zCk1h#)s=NZzGqSKndKGOir!sOzLHrh+m&&N*w>c6YY%$1Y- zcY=wsfq>VCA(b%ud>ZBzu%%&8JzwxzuL@1$z zLFGOJRzRH$t+A?;vgg%pa`UH%U$^+}n-Z+n(K}j@U)`iNofa%DqRwBPT7lZ7N;cfy z%8yEOMZ@k?i$0?0z5AT*R?H)X2Llg8`3273j+v2|C)CLasKq>sPZYiI*3VBeS&qdE zp9FewQPxi-HjO&jR7Yf&p!JeJ##-|vwpG5H3>$5LuL`Q`8gXGuD`=M;hbG^|M9&Ll zzo&g9@x}GAF)ZTAe0Hvtn!FD=DT@&#cJb>IkM*yZeVGk9xe~E3RG11Ghqz^No@wIN zlWz4+t6RK3zRhyd8vWk?-5=+xUYk3`{}6eL+16S=;bZ4&$&1^gwI?Qo6Qe;Uij5psJ62O*&jdc*X$%ouGl`?T>M>EO<0lGUGguu3$)#CB@Y?GBm35 zzulLa;Cq>-D<18Uu2s9{yx0HQ_8WAtK9rV(H~bF=Ic=t$#p&(I5ZiO@YNH}}`dffU z_&k}z{1W%*db4jg6_mYBsU`jc;-){3J4-v;y*IoEWTc_ExU&uVH7yR>xS&O^Ew111Q$8%0?IXKtz-zYf2ZU7PHfD7I=}k04pSM5%C>Y+h5Jk$bk|VH`e5k=Z2%r7#Lr}xr5r0<`9?^nFhZ@_X@tB+F#7dEJ%NZfxN<9bQS# z7S#x@w*@R6ic1 z;MZsUWc-x}{nLkVD?rJrc#?>_{%=8k^W~ujUzpCaT7ZVHOoC{%K8RRu0K@dT#$F~W zzIjB`$7OUwfAHI!^j!)UCZwBQt(DQ6A<32@{HuT=I-X}Q(;o&%jjVs1qQHf6^wpuY zW8OE9S}pS2PUb#{GBhWWSV2>-?Al-L6RVWNk)&c@qB{ zvMV?k?)B8wC_C>qSEsD7X1j|8&qMOR4V#btie2%jtvFDj=<;!XQ(>X6)5-31dg&zI ziwpk6=1f)OrJh*9xkBx>NhT#yFRdybzjB9nB)%#+Bn1!Y{8P=@r78c!bIrT^F}DXdNwYi74G zs4h=d@cn7^U(iT#HfO>sD3u$z1@@l>#Jh3GHv!{h7X6!$&@|!S`a2&cL^tGXz4JDI z%<~bR@s3=4svy%sq0_X_@7xYH0Q!5+OGjA_Oar5$nO&(2JXv8;z zI=}DtOhb|RB`=~K=^dWfIAG&$i!G*bCF zXV2o~*R`eo2LL%h#=me8X_@A**Hf-KwhW6(E4f{7ghmu)=tL*)StFf4lP4g%X%1j? zur&2u;GFSqB{Vb%=&I0lfS=R|M3{iT7KH%OUXVf63~N7SD@#cs95;4}%q5v&a%+vR zt&YMd(ncESUojh~tLOM9Qly4l%RWq|-$Wlcl<|N;9}E z9{r89xCEGz_B>o1@~^HdNhShYAiqxa+LgeJ>k0JIo-UdSP3~|(EYP2(Hn6=Gi&fM; zAfFblD*ZpOmk8?>V$q0!aY9pIkHHBjor0!{hTDl~0wkh>Iah-Abu#yqm6oWq15lxq zLmYftu&!SEwo^q-$M=WYKAsx>&Ui2AC4>>{0V)UjPC(gd!kuayTFU=aA<1xf9*l%7 zjitIo)Pg8*F0^E_6GKJOxVQ*@LL*;5c5U+#h0PfP1(!S_y37saGza&D0Qkjf}$u;9l=!l(koCI>*SCKiRvejO*lJQMXxfHAcX)Flz9RWSGczY5P}0n-67EYneqmOQ*hwXO>Qn| zt?nJsv>X^ajH<09}sh46&UqB4DT`aFM_R(eIw^ ziDG4?oKBRV^2o}X5zk;RDV7IFeO0PVMc`;gqy>9XZ#7~(n*DKG%>|f1%+67m1cC;z zNJeB1G8l7MOp}4ILK_-nQ9kAVEp3xQ794U|T7=*#->; z2|l7v2f>P8f*EXdZ(!yFpy80>f+z;~R4zq9Cjqt@O*ota+c8_1=%uQ3C*Zl!Kp5Mo z)>$>`CP*y;$*Th7M59cO)W23K2vI(OUx+y^_)5=;Rt@3F)4sDV``$;E|{yxk;lXX~>8~3!Ttw zv_32WGVP#?R^UV<1xwBcEERd!Na&MKvHt@SMbH{8QpD$onr1UK6jLBd0T#wKR9dLR=Yb%A#D=;m0s@?AT?1a3)ac@C z4v>j5X94l+TRjsW;T))oB<|YO@(&ngjT#3Y0T@asmm(a56O86OQi3Il#u><0q2{sF zzzHF3d6lvU((LL4aC)N`oMni*d{xc^utQ&iDFo6XkW59#B#lnoxhVWg1FzF5KrnHh z0**^Hy+o9x1(6(Zdcir#mJxbKZzpbYRla2kr1b1QZvth~^=#@~Y(==dK-ZeFCs2S) zWE}v+S-3OQ$c)Un*PBjh}QDrJ)5%{wg(CVzhwoc z6B!?xw~0w;oSq{f5>1%xIR(?T1c0bRwxCrdj?tswj!@d(7h}#jBSB@D(4t>BD zk|!8U8<3Y<2hO0bYMj@(2^XwzJ4!cmcywg7z)4eOn8Qlht7q{IaJiA37CRR#5w!#$ zi~a+82WjHCjn1+8NPY4`xVvg}WCH~uzZ1M+B)(087D07t^gSRs1@?#~n8UKg9I$KH zXUh_&APX!`_b|5)VnLQ?1p`5Q(mXIPltXa-2noUq@A3%gILc|1HihEB`NI(l$A=>2 zEY6`dN~&ry?qVCt1ENb25=Jo5QAE@Q;y(#AnWuop;Hgp8WX2M>G4<#5KWas&Mjb5y_=Y z5JClk18UH`=B5tU=B^bjR+Ot@yBa`)8B_r!T69hLFQ&;8DiCKpphn%s+ z$P_p5;DC6=&LZWek*S2WJlHx!^oCnA56&Mle~Z|10RQ`Wr+0CNj?}GPZg?2^1|?&MCnMBqr%&#)ms#ikhzL# zh*Vk&<|m*Pczo`PmET;(Oh65 zEM+rldBLEQKz1v7Mv=^sX`zjrGfx6C+FQ{m%PO4RI-s7SU4xVkDUVhklMIS3xWj$G zwLm%GHP~{GzRH4Us!E^zfAkRB9RwKzaSI@W?=V z%p&$i_oe6}nn^*T+tXkGfx)36+;nmT>Ow4#v-r4zrv_+2Q}+SEMH<;G`DzrQi{lAF zM$HPgI+QMfupoH{i~|K(q6dA!D*-(O;YEqC3LHBqCeV_BLjlm1hY4!9`S{z3SYrgG`ljj#A)7Z40vg6t!j;mO%0Z zEzzPV7=(IM-HPxQG+uGZ2!e!(qM-`LQBMSrz81gS3?6SZnSm{_Xk;1^FgKYBxHKT+ z8I)c?HSwqD%bBAGo7$8LE+smX7wIB0Wb)Iat5S@LbnxJ{`&)>BcTowQJW_O)&XBf& z&>1XG*w6yu3DH}eWEeW}CK}v40qjWX=$@6R3F$$O1QmpeBit$6cR&@5iq9yiu8hM|l-V0F?tET1|D@1Z8tT`3SxA* z_2WCum2?}nFvE>@48qSc@Gi8H$%|)e1GA`z%@h@#oB@{?8#VdXG$264gsazKmL;kQ zM0rj;D2);6`dLi01TiVleZZQCAZf0gN*DrSm?A1VT;S2e52gkXCEjKfN=Zgld{Z5L z??D3HPEn-*7>&k?E-+6Qmq`mBa}n(gEeS1RPppJRin#1hr1)(UeCALpq;)X+s%D~v zqPQGHXQp3pxhKjS0fK5uEllj%JVD&2Z9x7^;SdzBLhI+~O-o|5g_mp!j^QBB zrbMj>IdE2F%{l}T14B>=L`{c62-F5^%Pf2@{%(uD@#I+_@N$5W2I@PLLnOxPi#I;e z=$G9QrEete3-=Jv;!204rwAcmf+E7H*`oWqVZ5jpL4X_CeYIe-e5O?>IQgR7TZT2a^d;bN&KWea$+<`&WlJhqszyPyLzU? z+YxyWl({glbai_U0i+`iqMk&?dxbVvJdLBbK&*6? zP$){C9CnCVXX)D%j6@=pT!1dX7UmYCtH`Eg`W%T(O537A22v8im+J4R7AWf4sIe4r zp22uiM%qF3EgMoOgaX*Z_zf+A*^aex!5)biL-{>FPDOOya^ERl8si&c3LSStcS zwQ5!Zb;Kw+bKoanOT^(ptpZ^q1bOVfr|c>&H}7DwkYZ{9VBm@Fv;qJ$2ydZIQb#6? zw96nLfRHy82TD4cgp9P5i#pt+!eM(rQf!5q!G#xc29yS~D}wSjsdxn@-fYzxvFc1E zTwPE^TLHQN{5kp8#?S(2)LCG*E;72H=;6z)qE>S?(!^Z>LT+%!7M<>fyHD{W2ijl2 z1Q9dF8OLc=#e+O@WRpmT(bx@J0L`D#-2^0BBpKd)HGxJJ28puKGDsh2SM2KK%1gk4 zVF9SHvBG2+39Ck^D#AP{!#vD|!VsLPSlX)R7=fchg?&LBsdGkBRB+`YZvQ~4&Qc2WypVedRe|TjL$HVg_6Ujq zNI0?4C~11FcawQ<|)4v5HMUV6*}ngi%u4CCnqrqq}E33YI1ULIaGnQ1OZJ#1rT8* z%Bb3*-Bu<0d@~^ErjfOa11nH5w5KsEgpB7DU5N>@P5eNbz@tt93J(f;d!i)|#t@1C z(9tl@WKe9816H^4TqF7_^n}pMN5L_44{5oq3bV2#V^ji{Cyy{kAr6{_Ch#5378x({YgB@O#2YW zQ6Bs^V(qtcGjMQp7n68~cvDBir<8ymUsV$vQ8|ebhFpL(ihjE+vTH~afsKYbCkF-1#=XeE2kSXJwLiZ*0iV^k>Rlo(nd zMgR;I4bX$ty{KqLLZHMgpvt174+vDAM_X1`O_-y%j+7_Jagd~dp4Y*HOLq`)1RL4L zj3KH^*j6!TMtMM;>Oc}lstX(`LyMpanbM%6Anyn>P#r#76JHfH8B^qMX4Sk*MnotR zI2L9pLHUe>8__$K2pAlB3j51wLsfdS!r+sF`GZsv9e8w;mptfIG**H_JP@}On5Pb- z@0 zn3*h+(rVGIN<%+dpUDpE8r*yKIxv2qMN*hTSUDTs0h}NKUY+U3pzKo};^0X#!r%l$ z6`6-Lf*xS!z^~>(mQ~ed0A){|gCrGSP3;JHY;6$Zk>5#RkcWq;CM4!JiOwuCo2HB5 z_+iKFDdRX++`c@ki7SG4av4pEdl~J|0EFQK#T3T~8afJ^(#kY*yYZs@z#Gsh6wFRp z8EWDpI_KBh;IN5d2>#eH+ycHyfcs>XTZB+#h?Ks^kBnRZV>76=!H^ex4;VMdDhMfJ z)2M<0j3d258OJq-D*M32H9 zf;2iLA}2blfdL$H@D_H`&>&kda3k0zUFZ$Pb&gDh#&xV>J;`mbcY+6^7z)J0^+K0R z4jUekbr5f0|EQsm;0;OyaXqdCn&BUj@C0ZIv1$)uO0b2hcPtPzw&rvjH9us&enuCM5ob+hL32AMB89|ich0+ui z7$C-yh$^0ii$W#$f~AkHo#<9n*4?|y23cOE90!Kmv-*)8qp22y--rIAAD5N}mG7fW zN2d^x57c`RT2T|3M2AZ(5MI7W7^=FrQyju263DKV0Og0xTowm)n3GFYYWztgcm z&ulpKxO`BmB`ER(U@-yNuSzAAXqyCUJFEF+7N?!t9W|2BnkpiUfJ}Fh6O&?#{7Vz) zsG;vUWI-%RAKb!VF|!O&@KHw|sYsy;qxX&K1?+{5gSHOX9v@K7tG7g;v+y(YlrCYn z!I+7a3VDxqJrpO;$P5SijAIrZQzZya#mgA95M&O-((1$1cwcZl^pF8girf;+mhSzx z060#Q$RD!f&Bx>iCLe%3g>7{7jst&zc5U)uClp?0rI99@E*wS-il=IkDExV~39E+z+6f?w)kJsHoW zykU-BYFrRSoC}?Lo@@k23RyMr>NHFi;9LCJit8JJ6kM$2$8h>$@XjnQ3DG!%XTGh| ziZP1@sHh*(mm0KwY8v41(A#VqnI5^GRuNxS?+c_8%or75ZXjxRHJF=&v*FV7{f=Inxf|v zZIwO=rwZgb2VB%sL9XwX034`wa+0Zl2rloBFMz<3pfam}B~Xp1C8nZ)m5DAo0%tW* z0u7ID@l^R3uQ=HH+MpIMs=+uMInQ}2C@zVfzBn<%o&6cZs2Z{;FiD?GL_>8*x zq^eAbd^ZjClu#-EtVh+Q5iP0`VxrnlD2W@63B_bZvapApu5iE_ykZ8ZQ6)2mSj@( zAacFxge-WbgwatQgsBy2&SZL`83C3>;)b3E%_U6cK=_IH2M>A?$`Lc#gZv91aZv&U zsB5(Rkd?<`L#D7q#M+@;<`GG?2b^{mcQ8{OD81BZ7gF|(vf?^>76iIUd_+BWcmyO$ z)e}&=qftm7ki46OE*RFZFrx$vB91}CYYH~lYW%D|*yJX%T@1GQKU(+D1;G0xZdHnifj___?`p?Pk< zw!l2wI;KTXm_ZuUnJK|xnk;KUi~dNdYfhh6lw53R`i7ieCt!dI#9=8ApQcLrRNZOj z(R{Y;a0FN{&1zl;H8n$u0mL=lWR5@(R}8BisZ;m^=#pI~wLAIcNg5ACVD*Yv4o$2@xIsYNMnx0N$IClz>*9cGptF7%v3Iqz5a>)7xAF ztu<8pEli0;qN$hC9NjfN2wxWU)_@|JYY)D#tZ|NuT048RQHU1>Mqm2LqGpjP$|guv zgzLI3no*&m&XnBE@F^$+C8Ya;)_wIfA~@NpM65}DtZFD8ymFzKXjRD~fhjQ`2p4_I z0@(p)3J$5lJe>|JgLg*f`%KxO5(U_D4&|?e32mYD8^_Yqfl6H%dNBL@7|lsyh}fWr zt#!cF=o)88%o5$#g!h!4nwq1d_mUZ*WT~0K7#--U_;kWFO5V|SHH+ro{ z3PjTr1$w0^Rx;P9%5Vf(VD)^`4ekb({Hq53soSoPUgk<~^Q-~4LFPepsPPP-&`Usm zX^doUv6_pc-yYT;dbPzJ=Mgd#(4zDb?^Y203lTa4Dvh&3o(D`C)Im#+#)C;HDAT1# zdeBC~4UXuDQ#^>x2_Pqpc+S!OR7F3!XnP|GMZe+7%q5Lki;iq>yItrYiR3~h3jmV~ zJtou0wupO?fPNYcbp$;mw_)#XX}RrK!J<`>WkyqoMtwQ6;^-;|1zj~1Z%on@@7bzB z;EWjxW-YoKNHrDB<&9w>qS1g5ZywX2R(!{$E830pZxaGy1|ZwVXIggL{O$vZV}d8e zewsLi@^%LRFamTN&qY(a;+XMT_9a-eV$al>l{Hr= zwcGSGIWl~iLDdB92#s^p1FJGPnU;B`0Aev%>^itENbK8Mr@<#nNsWd&LgW$?TUj=R z))Pj0jqbThrx2N+7gWmtKLpU8@nR9TNc$@P0OQ}7MXNQ)biLQ0NFswmbqvt+8qS?rMljQ)vQeXv z0Rk|zD01vk-=TkVu7XA0_M1cTHHrUPiP{(&ibpQuNYDl0YN1cOlP#c*aP|^u6^G#k zKR*~65=d#x?gQ{&vZic_s>k09Zki_W7h?!xe$gxj!?M&KmTDz@qR?#$B8c;v2xe$e zf>JO*C6mTz5;?Y*W01i^vpIlQqCNS$*Pe-K?;jF5uVT=;I@W| z)FKKbeyPzhZMQ(KNy#sRv7NYy?N;il1r9-k5fo_BJfOYs7ImtMfK(&fZ%Tk5ms(Yn zJIM3#U7W3TP&KfQWB?Q-Mu#(vlBf$p$)Gx++#x4>UXq_ix`60g=B{SWbX&nF0b7!` z5GNwED-}j;s#~Q5tQ_l(7f3I28$=2kdCBZ>Ly<_|TKrv_&&b z@0btOi-FYCErn;y!qdj2vl)&>`d*@?#k`&_K0Ny{#T*WCD* zCI=INK*lh#4G=O-&Ndsdkmz*K5_G${USKQ?PY)YHu(2|t5gLM|xnt3<16U3a0|=8r z8^OT8B5-e+gAhYQgkc>7@G%XJNf3^!#_I+W4Dv&iGhJJ@cz%#)@%W-cr$N~RP5(kL zD0rs72FFt~04uZ`0TfoA{ z({!vhI3qkSlElaf#A6;KQb_GEF@vnB*Bg~HDhyf6W4Z&1kO967j*+TXD+#D;K;pMU zn_viW03M^o$K+w~8tTKb76QRI#w*(CP(L35g?-PAV~ySydcFFi*vRJE++yx-=yY28oJ##snC! z*N8b-WOL|xCzT#=#*TGmng-7V2~jG*5GK^9FjGpWlThHnkOC5DfB`iTY&6f1eO+;6 zn~6;xMRsJAp%A1}BCNvaVp;IJ0ys#HTQi+W^abk&rfve`OXjEd!J*-mM{4cSBp|qX z6AxPe0?BWSn~mc2Z7(*dY_p{>M@B+Rb38zy^EZ(YDa>L*eUq5MaE0VjN`yBoq6%QUEUD4uDe5d!F^t2$Wn!B}iU`s%&|$!mi)IsQn>;!eORTa@8d4lJJ1W0wE*xxC zxB{AUPdz#6Q~+OFw(q@+Noa}O z2tER>@Zs;|tRCa(E}$en>1R5eWDeXzHzN(e%J0Z?*G zL{h_?vM4bp7WbD_fGD+t){1GSkQp?!H zMrlP+3){_LZT#E_q7vP1^fdvd34|p9E0|qISEU>lr~t+$54^nFSW%=A&)%Xm=y~ zSd}FSP%nXeW6<6_hU8XZgF&Cu<}k&oG#L;3QW`AG9zd+3fgCKYiisBCh$E7HPbbbEDtu^8EPpJ9u8tRC4d3FFg3)_ zXICcz$yjvIdW*5vfMVd`u|xQU%z2rB)Wr&*PQ@^|Mr#P%i7-JDqn6pzx}8QU4!0N> zBKm|Bzu^bU_{G?)EXsG1e4aN3)J-f;8(fc^*`gX7l|AG=DrM9}2$;YO)*!%B;R&ub z=AC-|j54A64hs0UAo+oX6IvOD>_mor@!1n*fG{=dV7r4k4vzC_fc81i>!R+MB}(&D z`?{FQ*0hWO8W&x&qpSD5W1AtEAT3d1#0MINP+Rjgdx}02l8+GHceR!eQ@&_00`T;R z$7#LQTy#i;W3Oqzqd|ofc9aR2ES<6X2_E@K)6hW!sjee^0$GKI0j8z0OLPUu`T!u@3&MHhj5AUd&G3v+l^s6daYC{JKSgZxfx!PxRi7`_1OlaaPf$cnOqJ8}}MH(A_3 zkwNyUNq{27im;ht$O2(3HO7%Pm}EPXc%z~{Bas@TGYp#2GTPLkzyUf-M#cFlN!d1$ z&P^i+U(^>!EXOd&$QI$Dsc3)8T}6v4h%E9S0Wq*!l5)@}lN^iESln4No?x;Y)Nw@V zz`SvUBi-6HZ)v3*9?ZKS0{y9LzCB0-b~(LQIoWXf~lnCM6c}XW}r(Y90+KIAd3Mi+eU9I!4sXGOzaw6 z^rBy-Mn@-T4oLS*Z4+Sd8h|27UjqL#^wX5yUSe%RkB}CF${-l-;TT`sCQT{2Z6Fy` z?tu7a8Fhh4ut;*bQKnVv0aSCeC#KQpM)NkeVXCe}%d)V&lw{FWp$s7+2L!1^Mc~+} z9>Teh`ygfowvwnv3SJdmv@7SEk&I0X8SV;05Gb2dB#k-4xE~}X$onp1^0#O?VQVeT zX)onxOjV9v^TpH~Mw2S(JVp+20 zkamX7#U870iI=G2bYLPf*{nvp=x_&^*W{?7#WN3_RrIn3OSLhTzpn6lhca?iCS=dKgU`Uynh&SrNjU*<_7`M!!vwAIycV&cnu4-_^ zbLvshBGJr@gQ^H64Jd{JJ6MO7pmst5XUO0IF^=iYNp$ZpInF_+xk!yJ!VwQtXeR`K z{Ys&%L*Ph6(OCl5E**!Fr&*oYS80eSFEg59)wIqM!%sDZlxDp=1$Y@Z9%6km9=WGz zHm~woG?HcXCw8*)YY{lYNAUvLVFF_lAU>8!qs1pulHJ?1#V(0-lRjaHb&T<)nUJD6 zMBdG@t#t0lU&$hPaEpph!{h_T80JXS(SiXYyH7q2P95VAh-m$K^PV0mw?dxL)*P|& zOrs^94)x%mAAzc&fo>4r(O`$8>|oy9B0%1lModYTxT?k+A-o|h7$5X?AbBWqfEuT} z*gdbtFdpVwup2c3wv5zElYqvXO4bHBu_m zs-zYyOi9QTjU!n@cmP_do~KzMkq6RD9@vZ2D-_fvSb7mXqT(V|D~4o{+9=r3agc(8 zpQLj1G!V8?{vnF$(VInT0*aSR2$5oJ7#Kzx{NRZTa)DVKVek`2>B>tHllR4j%|vl_ zk-Gui4nqYYwg$PZ1Mw-MRnA-o-w>^H2~(;D;n6TmnJ8(-J+0r-b08{unioKP2Ac^~ zstjTPj8uZRg9(PP-oi%K$1Gxa9>9h%0IKD&jpY3pVwYnK8x^o7?h<%E=0VZR&`|nb zbNEyMWZ>Y>nwGtW*3VLQMZ*~b;Hl_?>8ijLronYe(X)%fWHdlMJu<%?^cib z$%oY(gkoI<8xjHY9Mr&#UI!X;h-72V2lV(^p~_UE2FV;rexlOmplvgK2kT>59Czpi z5cQVP^CeRkS&21?g@~j>U<@jiMtJ!J7YH|`iNm>*eGftx&D^01ge}Oo^v;;HCJz2( z47SgLinUv851eULZr7S)t{Vt7l9_;yqNJGUgrK5QXk7`|7P6FyaVJ3HZl#o$)zHV7SwJ;Hb4a-p)R*PZdu`I zugXIPW)>SjFyE&XkO0l)BgF~ltoNujjm-m-TPT5)(b>}Q^$3OsCkrGNtcwL;ExWcT za5>7OVl-i2piD}a&%~*3gkXbXQWq}O1)9IrWLti zW@;J{30X<1$W2_*QF)}e8H{J)B2qFVE?x4Nn*xU%GcU;C^f>Nth(XUt5BWM;M%3;I zA_&gxD6s)yKr$gNY0)bRQ5^B%qPe&Me`b;d30uu^81%`a7UR3bx6~GpK)`%id01oRFGQ;>IB3nBSR&g( zol;|9DQpG7EQ($8r1AfvVh7M%2yD=0J>QbYb@gkhfjaQ^@EW4UOB(}9^D6v?lj)W` z$`yhYXxMa#`~`U-Pa1)>iyrD}R?lPhdEQQ1Gix9xU3sOAcpGsbi2&ch;7((}GU-Kn zC4&8mZc4Ng^-<5uks4Wi40>7vMnF-DC7Ky67%>d(8#B{wHR?T>HBsCMF^qnp2G!s| z>4$WDl*{R)0OO{Dc2GJ9utLktJjNR*O>3M)m`SiY=Ju+7mR0c!J}e}AhOlZ0;2s zu^ySDNC=dx&nc!^1Kgx!mBus#%4p0#BhVWY6<0=@(5s9Yo(8Y_0s}H#y*1iV=b{nK zI-G&Y5SG)qsLP<@1CXhq3OMW~haF@jH6;2VV!io|9wN$`vmC!X24pY~h^be&$nL4a znuplZtQhq7S;$j?5D9)pFXIfbsVKb}{a{5yy1GgQqdxKNt_|kG!`A{j zGeZpm(3Z0Y~h^}9fI}{wH zMuYPbnTFt=^zJe!hC!NQ?kQdc2_m%)L2VegHNXOzM#N;KQ9C6|%ZvnkiCm)95kwu< zmVrj4j}alHaWrgepuyn?!Vw2p;fE1)Gox8FeQWTvR)^bUal{2BgKAUWMQ$swKNyrG zN}(GAGcoY)y6mDZ=9mXu$z@Qua*){={>$ocTwBD&%y?mtSkio`sQCh`WZ8jr^)v)OdZX9V>y{eQu- ztWj%OvpsRK97{AQr39!2*W7hHZE=-wiu#^s{_rhVuax>zP=wr@UMol)r=Z|Uw3;=Nav+F<4HUu>&E$ihJlYnKMJ9?U9T>cQLWdyod8EAY zwpF7A{~B&Ror1X83^bcO?ZseTpu!V-K8*yXI;k>=mKKy1MBw-?YHygwNNDzta4*rJ z9L!|Qk(goKWbS$&xJ78#A;WgQ2X8e2K_&vDL4g4`YbWro&af+ZfZToB8Ry`kUEo8u z6+(B<%Q6U>VFY27FjkRSV`@0L5^QnQOtI$#v%pm%qleen*>8}eLDoJ|vs&pkwk;XF zsW3{=r%VG`i99h~U65}mg*yj`aKUO)O4C6Oj4@b5EP7)Ik4r%jq#W-n%z;3CQrGzw zn}hE{uMUaL2p&aoN)-XPQ)E}Em}+JM)O!)^kql(I3b^P2mIMPS6IA*D6$1W;WvD5~ z=W#J)QNTM@gc-A_m2f6Egr^h*%|%pL)C)=gz-YcGi#i^dFcCRyici?$OgBKxpXF$1 zASO=azD4X0*hBX!>j=^MO3AjU`K=J-T80Gb+)lZqVxiKMxYs!-UR8P?YaNI zT%Aj+tX*`K|4K+pK|j|0lD1tt+LRCkg9J2+2K@WJ&seM0m?tCzJ>RaqtKM3dImaBA zyWn|Gh56{v!#meH7#ElqRbaV6${0mwDIL}3{XWGcUJo?(S`raULG#Sz`P<3}L~*m~ z!bz`O6YW9Mq+s3CN6~29C#I_a1uk`V6}kTP!S!Zp}^`mvE`xw*Y29*da>L z&HN^lob5QC;}4k+VnjFzcatdV`KpMNhVm^>(y%V`neUo0w^s6Li)TI5NX&tL`K)v& zvRp&QIxZ_5ycsTmz>Q zkmDZqJQg2Uo6Mjc+Ep2nxcW&{Veuyx?G-dx8oG^Aq#r11=rcMi-J$?wZ3XD%Shvg84gp%ooJlM996id%MO;PPgWb91g`-Nj6u4E{#~VY(v~ z4Dm4f{%XvhAxaWSLO}F*WWJ8b5FQKZD>nv1MiZRZS=7|a7aFj4Sq!`U?m1-EWDL+U zTu}>j7_{PODSl>BOw~}RQMq240W56^>arq{Z+HvMcY^<%_AQOE13t7|IVWDi6zooj z>(5s)OYY0Wma)`T!IBpRA%%T-D@`Dz#qe<|cjg+UF$7!`RdZclyh8Et4yH=$*VRWJ zoJ>fGaJ z>R9e72$7Pd{zU(PZ~%B**yra2m;%ILJNp}i!OSfZQlFGl)m_SEz;`HJ;29S&oZFj( z@^_E3NUB=tl7AQ$2ROV`MH&1&vzY^+HizZ2@9>2lnfsJ)=rVmX2wOgvbpJ8rnucTi z?_GKr&NBPt-!8D*B&9@lz&)ETc&v4ExJ4HxQzRQfC^Wj~9kU84S)@YW$I}%Id7=|Y zvVgro7T=B8k3q!6D!`1yF<@p~E}aW?DXG@1@!R<}GIEDNp07H*XV=s)n0x-h$%i}z z@<=6djv6JvGdeyoFV|wK;BiV3eN$d_e^dN!=5L1#CpjwoOC_9*TRL&!U_SB82XwaFx zpV=53WXbFnmr?%(>F{+S&FcFn`-aG$MOL}y_cYYp4vYlS6%P#Ta^;_h!^%Cp!61`d z+*$6|z5>?Y+9yf!ZqY6~t}W4LDZiW%P|<_=T~>qwFPE$U*Bm$70vrvJIL4pt@{o(d zniV>>uMGCP)8GipQf%K~8CZ^@+Yw%OaTtV5*wrjBfWx~0pw66I2z`F`W*ALPI0h&j zugCJ1n-;$zUjYd!4y<8@=*727YfyX?>%5*<3$#4|Hol*Gg{y&1Vl=Ik=1(f2P$XEQ z*M~t?O?Y(@D){p!rZ2~j3A?9ub42B}S;ZylHT`x0d+%hy%o|aM(1hJg4q&H07*Yy-ujs<{5lQy4}S0VH2PL#VV2>V{7CJDDfjo3Wx&J?^}O z9%X}-_qOgx_uw4lH6gaSL4^fLU6H@X54g`VBm@<{W^NR(G*AFzrR`skTW8Mu-6L$P zpJ@jNMp91MDfa})uu@7?!TAB7u#EEa%w)2Fa`^{Gi~pJB zWlS@0kuqY<@Sy+!y1A1i#!wBhu*zk2Y;g%u^I+>(?}mi1!uSnGiFw2^NLdt|KudOI z&YBHuS=3wrndC-XULjFmcgciu7t?nE&gDnFX(9v?tDf#2O>(vtV`bd*o~A4)*!K{F z&b#C8I>jg8{RN{~ny_M+B>l`DNP>ZQiw(pzC_-wA#)GNB6=7$2<^ka-H0P(8lBWS3 zNxPmpP#>Edh0&b;MtUS4fKZ}KxzkKNV@2t7owYf%;1Unccqh6zCOh-9|`TENxPGQ7D=o=QT>we2}j2@6P;R9YvIh;ap z^`^DIG=(gQ5!dN_!Bv>QvLW#4M+tNp1Iz=aB82j(=+Sr}0mVtMw`23AY*{i}m1Fxu zfzY`@j>9YF{#|&RAY6EHUrHl1ImOjTGPbUYhXCjD?IJ-}*C_}QU^@ibenXS-U4+)i zXB@xtiYuHNrd1EEZLGmS0Tx&&(J@ zYLRz|zskP&HA<%ZIw$@sX_SDSVMOtY0E4 zfE-5qTVDIOxPga>-X;A)ydk!HTM^?RD8yG|Kqs8#{+;%ZWREvM)&ZG*Y4S}@$m?XZ zAL5uY9Ay;G&bz3hz^Jcc!VmP$IZzlFE(U@ls6F8FQJnDrs^al=JEm?SWbyQ z$ZYM-Z7bfADAxKNSnz7ADAK4 zR}1_gcPVnle)$Bl2$6ZMfR%Gy?86GpKPv*+H!|@C2D?_j&Tz9flRn&@e%$R0^xO6j5jloVjUm*Dj4R@)u(Jf5&n(-bL{#GjSZIw zs_yjx9WWO=OjyRi=e8IUQX^wo)7}^$|_LRo^XNryicSAgb4eR`Q;7gXFoYm2PYEw!)1aZs_FKrt} z#g?1dPh2b3DRXk!<=ni|rUYQ7{gf=?yff#*DjQGBODU#5V0;Hn`&WQ^Q|HIZgORw z&zylgLOd;my#I&+-^o)+QQA00pVg9uFE#Fy?ksWjcd=v~5sy!IbPE>+vXcqi-;-5 zMI^FPw88ND6-iNdpr>a7QuFQgc~kiejM{yzj2FQ@alX4J7fd5M1Fp#Ln!fo}5*A3| zx^9JsL{V@z-tWJ)%>W)}t`6nlJ_1Osvbn^6rQV5c(;ozQtk)HoBa~rX2A;}4=Lq5l zG^)er@t0B|9G^G}u&(o55NpZp0QZ&a+=GE6d1xd-+#HyN3=N?SP5bp@!DJ7*yNbhW zj)#x~6ha@ZU1;_*GC9&^)#u6HDfH*pTmQKu9JJs10hb zdZ8TVzK~+Jl6Ve6O%faAa5-3)pTrto6_qAa!Yewi-{FD#2Yy`_J!PQK1xpdb@bk{) zK;P^U(m5|13ghXoiRogL-C76OU44;`rL%;RsORX!=8sh{qD`z2CwNN3FG)_)Y*XcoAl3F>bdKp;X$PQJsp(te+m?h~hcfV{7|^w$B| zM5KG_toL;w;!#!=MydVxOVFoawwEb1zMJ9wJ1j-c>?O*~`u+AoZ3ro)$$G#3J}HwU zk!OW^!GQj%s(0O zppHBLys-F@Nc{W#q@8^Ky!qYq$pmM@nfNV>!u0D~&kC%^vt_+KmdWXZcZM$Wts=OM zQc56kP5bvTWdkLwA3vP+-Z%z~huw(}Xni~Qdw+N)B=t<~sJ@y8FdZnW~$-l+RKB91C%K@?d ze#y`wj`S8Iq2``7ik(qs4e{kx4RIJSH8}TeNBjR8(8e z_YHlw5Xn1g#OCkgLv)da;S#f>`Pc>ft!MPXJ@4CLDjbLI`>R3T7d>PktRPKnsNU() zj=zp_SHC0vy_pK^0lx$l(s~boq83;kfa<#q->D9Kp4G#_o_Sw%Rk#l7CT8*Po5pKd zb_p~HDZOJ#UV%V0hxiiS4HisOChuPZ{Z34ryqgF$aKGLopxt4PND<=W1==mtgQUFQ zcWn~Wwq+p1>~^1Xq;MfA2e_zo^$EbN17cSNxq8RTz+O_20)N|I2D3QDnk%~d-A3|L zpdy4=`*zC~K&o69uw3t=3OLD3bjyP1Jw?*S7^rfwr}u&2?Fj|R!a;qGGF^e}s6Nsk z3BzM76M;1MyT6(EU|l#6Tz-eg`|Vg`VQY&oNPb@&^c*;$OP1fwTN*wAbv<3ccwcDN z)j}p2zv;JL8Zgn%I~8h{mSR}b42{!B2(lp zi{bCw|IWU*enmEsDv<%<`*#g#h?o4@Wx|l&wnj4q$9gCX^*ykryfdT^bRWNM%7l^O zdR6k>J7b|G(g0;DoarOB3rZ|9(arDTArO$Qt2BdmXDS(&t~kuIoCImZoANycY6yL+Sc?=UTy)>@g!(l z?;b>{uk!_=`FQ^f0h{ru$h+_69kTR>oyUJF?^=T>%fgen`P&tRZuMli!|MC3+b#hY zMq%Xr`DoNGC+`QNsqt1t!<}6RIGD$KSVcL0B-H8R@FHRu48TJ|@S4Ae1zrwGzXJA; z^nRSs5I9s>e!VX2zj|h)k>*hnWcr@V#uA7bCoJxF+hsBM0AbOYu28@Mwu0*`eB`~! zI%G4I*pBIU*bR^_j6kubA%657)WU6Pdl|Lt*9kCGp~da8IsUEQL6|)Qa%p7ptI652 zS&Uma(RujH9GkOIE*jZa82tkDX%gnQ7YqVR%tj!@3ixTSso+&??`!nbSjw^lXXPh z@1v5AIWU(`S|YzU(?oIP*+BhrnYE?t!JwCsjn76j>eM;}FZn!{r9Mn9ES&mpxetUs zip5(8D|`!SV|IWa==X}*!|q6O<^uS+t=6sXF)}Dj>QJS-M?mzP6kG>s`0dpVpe^TQ zdTp0fC^9%3m~p&ydPY*r+aYZ9_eY_|L15v~sG+(cK$tni5Lw+T5)?wDM`U%yKhFn* zjy0h9XMZ1(6#za{q`ybQT%Deu;ItMlNEyUoYJ<^rbFE1f88OmDJl?&0pkM}$oCgrg zb#Ic`1Bt`;-*@#QL%sqtA0B^xzlNkq=@AIC|vME-X=# zVO9*!qqtC_@?%;~WeF+Z-8FhURj?!g-1}x>{a{XmOIvZr!TcYg2g;ZoOFtrJkY*#v zd5>n}Uec8a-G1LUEIAbMM3X3a+$Rm)&&25^OFR62UR{Ok)FzoOuiD6RWJDP-L!Y~p z)(I>L-J!pocFe*s0r*~#U%`p(BTHvCgf__L+ApyE7t9WBr@OINEibh*I*6j_ndYlsXI}5rm+@jmFfo$`U z-bO&q07ij#`y-{n zEwS_8;Y{9{_8UG#kkI<%kpp8ac>Es9Y&$WXM{Nl ztv}EA?_{*o1ragUS98mQ;FCED+SFn+H)Au5>SWt^%L&4J__&ka@t;qe^;M(K(s{qPVsUio#svEGIy;co0!hqgTo(oblf8 zUPt(!WCZ#CWaFlp^26zdgeT1kTutnqy4zcd!V1rxnykO4h&n>17|2+cYXTV#aDUMJ zXG5ZRPAK_2iw$i&NLDiF;t>VE?XEf+sY{aa(c2wNevs1QZ1J_l(?uXbuE2bB`tL(^ z?Ne9?Olw`F@lF^8PU3T?;o_l-yWpj>`+JVCFQLHzM+ZP?$fIeJs-FAnClCcO6T=rC zt4P-bWG_w;pTFJr-eQ%+6$a&k3>e@hWkyb{1;_C1Po9(N90vH))3^@2h%2qG(c*C& zb`w|=Np3pM@uNU1gk54?%r^+FxJk=e`)F}Oi7ELaJV!R>Tc=#klVPHa$lli5XQd(+ zQP|silN_=dDlbT6?|gvkMMaKFD>%~c>;&}zkLW`_rsvHMkkoVt9Wa>Qk;>$q-igJB zKGs768DGI_XrA7oLQszS?C+3mr%P)x^QmnIM4v%0M=YKKVKBg)Q&Dg=1yRdgI;uc2Y;O;jyPNLfdF!3C922-FuScbL zjIGE6q(UtxR_Hx5j_OW7h|gmhZh5{p&*T*M9akG*d{Elp8ZcLU1r!ws0oI!Bcsi10 z62&8v2m1T7Or4hk>12~;fEg3QMWjLrP<>a9J)KH@hmY#W%`~CN-_T9mlK%;=yE_K} zm_Ztsyv#+zuNEf_&Z|gA?>Q1ofcrRs)UVng%pS@wEmCo>i)_)u8s-34T=OWs%4-p2 z$EJz~w5o<!=dW7|yjJ=0vzBu>wZHGB61(+r>3jR*h>P z;;pX_SW3!dp#JqpIrF@iespXDzBqe=HGm&<-!+9XfcQ@`k#Z%jgbeb!krA!H2Vr_O z@kblOKhAZ<6bOj0{EF*^2!|Gh@iF;uKT?4LhO$x}*k&%=yNIj5bvsyBX`V_z7=z(Kul44V`b6{<=$a`CeYms6yR2@hB1{OG60(xBtauBfdJMhc z>p~p#vLh(~hNApsnulZELtP2~ZUEZHnz+hM65R+j)Vv)qfIpLdm-yp*S zSyvEM@D`I06o>-otdm<+s!a?zCuGao!z|+%lLASO2SdmyLW*IU;S_$J6fvb{fg|`7 z!+s1MCQWuIAu@AW%z!6;zwzJUo{n+|%wc|1L_|2LvhQEvlL}!aGiU!QA1rj)jwAx) z{x%uJ51dOyE()g)bh(AIY=ge&k#Li=13lM#!>KCV7$pXod#~N)>0*V6(^FRzjTIgp3S1eAX!=m#G}~8dxeUMRF1FHhKpH zG=iohy!(VGYB1_lPz%ki!3{Dlij?oGhU*iV4EKg?7T4PavM(<(|Ho>8EFuM7#c8RqmfuD3DaA(|lVL1ft1Rs<9 z#>n%#Zm~w9{7dKLyjeX&IQOIR2}KJ>2sZFTessPi2vKswN5(~nM7)O~5n$bxI#sY?HE@~UP9*#ry8{R+00S}^CMIt^7A3?e z)f>N3!{9s_=Hiexw|OmK+zI3DJgR?9KO+sUR0SghUaDgCSQkiRBC;r-{w@8C+v7?J zF{Kc1F7u$?GLvCS0L?yh>m6RLRKQ_q{*`mAjPKcZng6-~MrJ8XVEkf2p@WG6ay-Cc z-}3S&pz9dK3hlr_L=7pcZ`j!G`gImSOGvJ+)?wOlB{1pn-~f{y_AivH;ts2$Vme&# zep-ouc*7-!Yyz{i#|H;L1R(%f0p5cOqo1<^^F#t+gC z@l(s+EcRHK$^)SEr}iWj)N7H+3Fo?;9DVW z#OD(~tzoJ&>N>KwuxnWcJ7LfwV$!5?y7z}`1az^8BKLu)ITjs)0$lgIiHIiQU{b!Q zYeR&I>G(!a-g|IB_|A99QRC?$9U}Fz0cV(~C>x@lV8vzt1xl<`X6)uZ z!*G95FXA1BcH%XIWHJyFI!MH+fr%wcA=&yFnCPNwReyB^JZdNXoE9 ztCF1?y~Ioh>lmQ@>~c)bU5Y|tWaDYm62*yn6cSLFh;f+k`4ODvcR+}Hs-$X{yA-f+ zUgUTLG0Io&VOLRLGfXt&8(odaKD3)&VsuR3+18rLi;a5)?~b8})@H^aQgv|bPH|-b zlW8V{H7XJh=NJOhr9tyTD--%AD5OWOgx1O5-o(6P@UUj!TF5#}X8#PC&A-MIjQ!pP zni=IGdx^@9W$cwoOr==85YkNcQ@*;U=A6vtQDyl6R~E-KrAyS5lW_xN;JRRn*q@T@ zbV4=q6&5N07!U-;O_JavV-#2)Vd`7&S#oZ~kl+?>&KG<~iI*5RyIDE1G9Z(;g~wkq zG+na2Cn)TchR_D(&kZ!T!pcxq{FLt%ZIYWlDndv~6l5;lM92hA9>bysXak_v6ikXH zGrfwT54?=6JOH!QB+cR;9${2p3H%;-jKOEBVXk`{CUP*;6hv55V)Am6?qir> zw_uSeyfYM%7!wQ?q+G#owk~s3JTieU+^1mTEHNU2W8dyb(gDQ)+P7~)&=(9kD=JUd zc;XNMlz`THDZ+Ats-g5dKSSIdS%&zz`PM9S*BBQnmt%yVZVH3pQH|^x@A#FXmCecX z8zL{|0ZRqgw{Ik{3#PD5d|xB0x{yfqDZ|vPK>(~$JmokSDogofKu6U<_$~oV0oac) z&x{q~fXSDSMu6VYqCGwnt^s&Xg!84HL?c|j-W&w6_9lB6T7F~3@vdLiCT{LX)J-y> zJYcSa4GkiC|GV5$o3g9FKmFS`&Kw9P(k>0apb+R?+Z0YgSD%uWcZgR#o84q?FVP2i~ums9$ z=l36Lqvm4}un_%7ZIL02ANoEaB2dlW`Nf2BiIa-PJ(utoMCnMcs7O5>S(T%P4vlOF-P+iUs>T6s|^{XF?BYL8M+Hd3sO=c$NqQNV7!F>?L8Wz zD`;Md$mq${FF{yL$zYWS(FTGmxb1i+R4=71E)-X7Av(0dD7ORtq4Ig!yEIC#%dj7EjU9M{|GhxO*q95HD$_G-D z(GgfONtKJ2kmPC}Gr$*+B^a)lJ*=^lMh{ep+Ai=E=+sdMji6A$W{aQ~sCd=eV{14d z`@f#7u*0j6L}Mn>>tPE0FXiAtuhme=mGzeN6w1V=D|2Va`BIcVZ%HyxoYGq5TAR0@ z3{?p$m)5yjO^6R2))V+c^nkmPJB78`FJiPb#w*(HD!y z+}v?wp(?hZJmLZPGQS(M0XRN%?MdF3h}p|oI{NJz-Ug2df!lVc+6By2mvSezb*^#U8#G~f&wi(jSmcyD0_xm}6_ z@LOwFpj8rsO(a%$j#~*|7_wn75ujUE7ZYHnrAFAHI)K7LT641tp_Px0MZga10_JRF z$4E>vL$?HZxyYeDnUDjZ1FEHryc9Y*xl3$$I=D{2EsaitSdCJ8C@m4WunOcE9$*tO zDAO?n-C?x;t^Mya;{4zIU>G|POAggxPXlmdV@^eCS0xd7f+`4$Ys?a9P`}2GksJAp zo&s8TqSa9qd6s~}44b>6(4(N=!NY>VM9%?#l8-!f3+b8uOytS|cTnq+bV%|Q1HDa* zy25!^`C{IJ!DSdfX$|LG>nkK;sG+0G4vaO$Ara83To<=Mm`Bjk;6>qfpOy$xmTYYd zUNeuX^8e&?HZZrUxJKU|=p@vJ)2Bn$MkP*V|5mUBFp@Cgk-ZFieJvBgq4Rs}CK5nN zqn5?=r@6jX0ZBW(1z-Q>r$v7bgLCe;A47~5uPZPUmjd6kMSUu|M<#_~*BnN^0MRx) zTc=|JB`iuzTz^2_SnxE-yRyWY9?~95EXdu|$FEX{=}DLaj&P&7{~*6Ys0;~!fp`fm zfXopX5t zIr*4CMh+*TDH}Dv`5fa?<;J&QQ^0_*Dc6XYg(x}Yzn~gvZ3bE=cR|xAZin$QBiN;5 zh%2_oSvQEWvbPKqRI=%l%$Z9qkG%;_QgL)GXT^y}P@;W8mdvuFDX)y^EM|6Z##P;C5sgf6?zRC3P{qHc*Dj3H}Wm zr3EAhm1LHKm$?BPO%&epboUGZfep7fN^inX>qEY{!=X1r<%`mIe)j_V_{xQC+=s7} zWQTlL%Yn(TpdZ+{$%4Q@cLvoj{Nk2fS}l$*f}^Vi$~@evki{kI4FjwVP^$yKh|9M_GQ9r4Yr}2LFvs}S)t(_vdTI| zAcky~*Q3y`;tXulA8E!=aTI!&pwVIY0k85{CA&M^;B1BF80}T|@x>ez0Uyh6#PJ0a zHDVxW<9wRZEhO1#mk}Bg#LL_hWhsy$fTaag2vFg>h^k#SI)PwJ(jiuR7>z(u**44x zJ^k%6VO4ZOtAX0W)xeN3BbfE(VCH}&PY$~7!BCF<%M+Sm=aJz-!X^h6IyMV{zY?S& zp1?BFGd~3@LpVbLn%W!UvDzWzc1R_WQ4iClBhDaRD`>BzG-&vp2efo**vHUaUNZr7 z6N!>5cPulLl<$XoCVEAe3QgwaY7OARnz^V;5&{ce3xTG9Nn*v)S_RpYaP_W%Cuo#+ z2ZOYuStW|3dPRZ@?n5WAF%6PR{5iYETko6#aEC&R)IMEicb6?shfxPo+suT@m_aoq zn)5!5T%(mGTrm+l@BTt`Ask}JX%Cz|CLPLkL8J0JWF}LGF7!|?>*mx=g##>QXatiD z47OMiTb{f=@)Sflj!ktp`~WI*c+_h=BGCNn3_>v{T6sl=vCQ8qJ7ah6ND3Z8=@fVw zhL{{u;7?MS_rN}9;s9?CfWs1HeM<5BX->^^fQ?!y2ary>8e$v;rT_z_8tZRRc{ezu z*p9xHPy=@&ch;<-ZN{7~DHWHR!sQCKOnkT4gxInMLI;VKCou#FmIs-u zkze&~hMmK3Y%3F}KxQBnAx<2(UHRGLK@m<65BM zd{`xC8qjggVW+{`a;xd;GF*hpX%(LO4s=`+wUzpkP$2do!et7kBu;p+rl93Xq-Z(z zjNS%_oH--8I-8xP#7^C!9FhSNcy9%JdMsF$75tz(MXXQBN*_GQ!9$7$|LLt?u#h8H z<@tn;feLg{5Pwg2jP_K0QyM_R%U57~D=n2CO{O~G9Rfa9+0DFp$9HP*76vCFrs3;t zYc6k0qNujd$KMCyq(@~&!6jz;1kalAb-04XsC$tOCRG*0rWItJ4JuqgxbYRILC}`S z!IK&78V1k6?t!x*KNhz}nA3NKH<8&lfA3uhX<29 zKDV2@8G&zyQhL-6hpj+g#c!ab&==^CL&-c%AkcOH*>i5 z02PoFWF-pt-Q)BRg02?me+_Kt)H&4F9PT?+-KQI5rsr1{#6U{vQou)9(N22837@FfkgUkV^M^pAH73vzAQ5M< zaY5KNtKx>2@c_fP{Ob3N9}yf1gq5BfB#A_dG9`kH26Ir6o+Q#RwpN%^6){6Rlu4p{ zPi5~yEDMx+#vQsuVZV8v0FiHDVbH7ZzD&pwB4pX!Hb2PD0+vV=_M9h8G_M`aD&3Jk zDJnpKrXf=Z*P|t=xx?kMtjPGqMhSY?NO2Mi#0sYc-kQmVqV-GNN*a6+TYNV+Zu@9D zJjB9YNMRtuj{4Pz@9X69w0 zse-=uecxtdU|mAO%#wFQwy)V1?GmQitiCm{K-DU#?$Zm!EZv+k%4?k9q3$6i520G} zUL09_NDs%Js*^4ibp)kYt3QdX$}Nm?M>*LF_C?|t;e8IvDddJr%z8;`0N*Mk$_;C; zxr0m!kq%?&tR-&hN^Rt4ol$mZ|ES4{n&j5ZF3u_05iZJ^xdclH&?0ce#nIq@$JK2r zU~r}pI|8A`QjU!1SIcM&=Wd+70#aU~TwTGSIY)%9kywcBUx2E(olzjcKQ7a!ab-zN zMUd7W{wT^XF7fg#469O7<;Rsr|OVEd;06f8<4f5*1n{fIau^x0(V8HxYOM^M62q<%2?%}ACy zi>#$+xTAP6D3xS%8Wh*u0_<1j2<}zCW0cAVkLi*^Z>v!PtPnT^-Q6c)m~_oJSS*wB z8LA_DmPLCc3l~`?!5FW0sfPJVdN#%A=q^Q>F*5Q5S;*BaNsce&M&b_i8wG6=c?ry@ z;77TXABKvr09ztqJyLT}KErU$IH41uWmN^?4H*8`fQI3QGiYH*N(|IumiK}`xn#Wj z-Z`O-Wc+9=n%=l&`UCv5hk+?_$T7NLYKNtmpW=|PHH+~QWbqpqs1==@Ezkk0%<2@G z`<0zRpS|HBlUIYKlo4vOy3*lt&Te-X>M+EO92Y^u2^o&sri-cYwkG2e+7U>5W~21u zJFp-Cn6;}UF3dEt6vZXzQo*;$I|CH$w!1)%(U#&7O0YKDQqYUaD2a*-8OoPq+h$ga zG}ud&<=lfbSC_Q%$Z8FyeQfF>gLK8$SYX$b!cdjy;;NP6UmS80>_#9k;e!Tg7j~P= zO_*d|u|Rk$w$P?AKOie62Z8g(yj!07V-(N0znX`WoV4&L>jaG9Z(>~aHFqW2(h!-G zVB`#gdEy&4vZWxu)lfrsNW3)nGYqZKkXBU!1skSNS)-`R5(6g>kgK%KNbcxoW}x9# z_1LLniERaa1C(h>ogTsThD;sa=ZfA65|@IMPo-c5_C8Dm;u%rc`#UA~6iplNzF13% z1_`byK+_Bv3}6pgW?|bC136UkY%4Jm#%05CwTd)Zd2?go~ zX+%34%hR$ZfsoF&`CU>XE%8Y(nXr_ANX4#H^DzELT)^#&zodI_$~j6fCXy8*MG)Q* zRoOIwgZ&j4kwZ1dk_ywv^&kQcH_2Hn5Cxbaq7`e1T>_P)Z{-;)=69IBA%+=89EMrJ zO=6xQa98r6sW%V>s3HYxkAOH@qekcva$ii1_#ZI5$55x?X#4iyCI)qv>r&VcrhRWZNW~Ld}4;Li5tPv zsjuv34$*15fQ!Apa6(D)Qp-UopCEwK-~d}b?@lDel^Y16U>o7F54BL(o@5UsimV)! zZb4PhcJPGSN+QIC<<@pTX+Tk?h+Bn~S@9ubUh2ZHm=69GCoO*AyEKAMk@yn; z98@_$lA%0R0NKbT>R5?)`TnhCBQCn<#4WtZcpHIkvE}UX9jS90X1T>aYL&VIv$mld z8~PP~zQ}TvF$YVB{p|;Ur5T?TK45do34rm!SSpMu(5eh%);JZ*uGce@ukOXk83d=; z&7VOJ!9ehCTcD~#cgpfsz?d~yOI5jE%P)NT#v-isD436D0vk>Kc-dHC!(04Cq%0mz#T{yR1{Ttu6AhZ zATK~%lWiXj?~wd*)BxX_K3F6M!Y!+N(>KtXq{%u$w6da|eWLnDqwPBaQsz*uqNI^C z*y-VHL*rj(jhLN+v$5|A2D9=hq=KFb>Ukdt1rv^=o7OSF{n(fT6HewVK2YIF9+V{D zu-My(1vVs^OMWusaPY-4pzGn@;|3nb>nyF|!5mj?*H_5anZef#zjz)(7vI&6Lu?RG zf*g298mXf5VUbk(fPj+H;LB~%mMUzETx11ECY+~=1YoF>hosm-DF!J(`NC=E=qTJ} zevN~o_a8cN_8tb%?Wqgdt<-*)F`=+DtEnn!7|$8pv78zM)k7D^Ao(SKLv5flkZ23k79SdkT+Qz_2&-%hK^`9@ zVtj*>f1Km-J4wEpgDIK040xL&Cnl?C1a>OkuSIqj)?jhBlV1N-qA7-WQT>W|PBg-E zcGiszya0i`{egV-d^DH@U_@{e8>*=+m|+Z#n~<3R1`Sa@w3TWPBdHHVp}6OrK~MR^ zv#QDa3FVw9RoD=9{w-gLz5C<^Mg;5l^dJKFEp&^)ExDBMjg_H`J zwK!=ACuwdKq&T7Jm?!9)%o+edd(NZLlqdg=R&R1KB0XA}^Iin-{DECL!Ky9rX@?Tg zVHjlOU|>ff24yQR9GFT3}}t#wQM{}F8p*{h-xg!WTbNF_25>6 zBRQ0DQKQX&IKorXRAGpOd7@&BiynH&;HiqL1!Mn;;GpbeGBBljVnul{D+3sc+_s|K z0v7R9oV4XVxWWTpanaCAYztO+T)^%iLOpQrze8_DlZ?MvEGzcJlFDw} zAcEy9p(9C&C-{|n+p=3BvjIMuk*e%*B#)5leKmlXw-XVQ-c7PKyr!pykakkh zR2CO-hUISLUT#s=r5^1l{qhU*Mq8gwI z;JRo3T6ubs&XT@s(M;tgBO;u(@jY=wUQ$H@;bx>2I3|=nTViTQy8*bMDnW3<%@Rme z32+X30kk_oOo&aOC1DKKb!G;(>-UNpB{bzgrLh?G-g4DdzZsu*IW8c36w0Ir~fEafDv ztz`V8NSQDdW*exXV%%w#fvVC4Kh>us_UN}>CXKQNoa8%yX~x~ zMTVLtDib8VaK@dSlEGD+JBuN!=zs17k&Fz^y_L5sGH@@Pz9@K}M`!xi#cPvPlj*h!tM^#MGRvRXM;O>hw}hp9O;bVd<%cIu?nc~py}OvfW{=_uu-`uZAYduNwjgZ=P+4W zqQw*y2RT?IXQkrsyR5m#Z)%LQa>>OGrt=&ixQ#I^dfF7?Z(0^t6imRG!_4fy1{d01 znKnofgC=X_fzHJXwIcV!D7vJ_fmQf6yNqMAJmWZaB*Yph9N%9E1RnZQRwWaN<(xdi zFM$lvvyb3&FRL`sPE(+|X2FvcY)=SA%J(~ZOWumGyM%UM?0wCemTbBnRP0^Q~c!;`M z$Z8RVA1i?#KIxKthSu;q3`?kJ(QJ#q!pP1nL1wd=Vo<({?i<@Yag)sTZKsV38#q0v zNGRR031d8;bMVOA`dMyX)?PGg8KUZJ!C&svI#lnWruIP%L?ZQ*35mFH7Ym1F#sYp% znr&EIi?nfJi_08T=3G^ldo5If6AO`Te>4OajL z@G=rdivy5Hp^l|!NG&wBO}^$7tj)qFJ(#6eai)}}1dt&yBg55PS@s8LYQ z&r_@Ow@a{m+@OqL!Z~kO$k41L5m;O5h7gS*VHM)>;35oqfm;_QHY4!eWNL#1mh7XT zwrU{JI1Hu{5`U7i>q%P}eLbZdeg%Bc??PVDjYRggaQ(FHOu5hrA}92+QV!o9ehAbW zohT9CFrdvMbf9hU0~1rCL8KONVK(T34JEL&8WOBZD5FhBw(V9xCJ~zhqqi{bVQxV# z5vt)HWb%Nmo$*$&g}25%Q0B0yaHtzsETd+CrX_cuP^?wiF{0TD@)P+Rp2XP`rLxaA zt!yqtibO2iOM7tZV7CVl=^7bgK~^+c5q~s@H6XnqK-zvc^2C)H&|omuBSpd^YQ1x4H3R^v-sX4;dfIU1k zmCYX6GpwcUSquDdk3=X| z9uVJDtW#w0+ed#IYT{~{a>=ID2RLr&IPoAwQ*XM7L22KR#W54k!^EAy!>HFf8XIwA;(MH3#AG5KLW{Tk#QeIX2`#7 z(s6z#Cv!8y#M~5^doipC#+A%e#N8*5HkkAPaKj+Tq@J&kN|jXVLT0HW;>rolCM|p0 zAyR;I2wD)tf?91=K%6%%I92R$vi%?h9a#@ISRkBM8a95rG(j%2x&M$N_Q*G7EU1oJ znX^6w@YyJeAFDCvR)&BlI2}O#-41fI#ARTP>UQPWfKA}<7E{pS$!)DWv?oCygpX*# z;h0G7790sGR4PP8y|lop;6%qu9I{1o5j&N*Oh?dyTB3`rq4bUwkR{Q3p+M-G?Qyq>d0l7QOwtA1dcy(cnt~9cUP^za#>lCutOe0{_T8C zkd*MI=%WPry;=wS5K0X`r6iv)OOZLVJID&YRv{JAUYU}I{~FQ5@Yii-r${&?$3%zs zcopL0MG}(2DPJK^G?JoRdq8NCq|4uVUj{SN5qx%Wm?BHJjF|)N;jn6$mH}FU>H86t|*p-1%O6(Z5{qsLGdu?n z&#frrt60?yIiIcvf6Nh5JE~$tF)P8|Y6s+o^PUk~8QChcK`A7Ak{YZxHRx2F02(2G zP#D(eBY0A&nx@A^vShOg($+(~J7e&o2(Lhf#Tl835&$a&87;hDh9fYXZ3#&B-}g@X z8Ohpd4LUWvBDAsA+%jiUaxv%6_Hh4@G~ud@9{2}EMh$7Hmp5?r)s#zSoPEp`4Cwki zwBc#CLWjw&2y-N>ftvrFmV6LI7DzL^ZoglE^Xf!T*n-?`X`=Uiq#EQKO&WJNPCXBA zxlYLnRe^Ze`o=&@S;KH9P)B(l##k74!$al?rpdIpU9DdjlV!4yrzG~`Uz#~HmGOyf zKpoa-3DC!?6{j?l5#gPa(#ie4g9#C{f+8=JmMtTl9HmY1-};Z;$+LqNHC-)qSgRit+NFp1h@ZJsnI*y8i8;kyel%M4EuH4 zQ%+Gs*6|}WvKY8*(S0H3w9v5vqoOe*WWnXVT(mVB_^wm?(I&2G2`q8#i^fF2xeGWi z&{P>Z&_XXPzx;^`tEfuTk2#N<1WAja!Ov0rUU=V_*D)LkzTU{O0BEr`7$GHR6VHY7 zOIO^f?NNF{*vW7_*pnjprDo0mx+|y-HOGs zsjN`o%oPe=V5SO<3ni?n&RBk7_ySr~LS`NuyYF`hK(+W7VbVNf1Ozm$o0)N84zKBH zNM3HTB;nM+10x<@V%Q*-UtxktT~{@~jPS40gv6%jBr*9&YGO7~4LLI$8AJB3oCP^B zfXQS{k_}UW&hQNA%sTctwWfeu;A#YL%|&R3PnlW4K>TXzARL7FOMwsCnS+@g>E=*n zmy$7D?6s|V3D)t;NeL`>ykX>p^!1@NBqxgQ{M~O5dyUgVpDhQ%{g%mc&C3xWCRCX@ zm@QR4?NrQ0(7r&Qs50Ru<k^t)NB~Y`Y zs|}hyG~9m%+xozX=DDLMmX6h)3M6>N(>w?LZ<<*0{#GeM;}Uq z&L8}aCl??DG9lPBSJ z9KlwLL8PZq=6QO`) zeIi^#V_8$ST?tc4ATwC$zG{dWl3AL>S{9PYHwMf*^+$=r1+g0)0Ei}nRbj=zLlvY${rh8U)za3k0lxD2;2 zf#Zb7VVHcSXsoGBd4(KiqnJ$*t)w|XcXV0BDbbZ4!>D{lXJ;Ejl6^m^LEN;z>FzK1c+j@B+ahGkjhZi zrZ81X_zywsTC%0K)Ig;r*bN4y9@}GgEhm&3W~ zsjb1~dWL8)98*x9rP@lk(HA04hP|f1;QvH-$t)ks!V#jfsu~i^9DA@s=LAE9Q!yp> zW`#IT(k*lC!1-T9>%#jZ<}Vl<%it1;XG#v(#eh6<^5dvGA`ekI27uOG++C@(1ecVu zzyAbdaOgzFT_T&ix&}t=Lz$h)=?Xm7t)%0+W>ijMPXZw+p(hJIfQuv*MXT2^Z-jIN ztjPv8cc~AVAas;qJy2J%b?f9D;|CM6H25O%9biZ85H71|bM_o#4m;$);)mBwKz*@Z ztnm4f2+-TO&F412{eDRRWe`c0;_$l}SmYp5o0(gJE#RAmw&G+$e0NSUyJmCgqc=%% zlbS(q-;QIJaECy2B{2{L@Y@bSSC5Dtc~vgMhT^zAV&Xt+Ikp9yR9$0`B|(>M+qP}n zwmFU0=CsY%wryjkjcI$@wr$(q`C@k?c7NTBtgOn&9~p6??g0)gRc5)*=~U0nX?XYV z5jK7vlLeWW5FK6iRt2>HU%*hBX80pTfE-4RwEdo9dOT>H8Sd5@8F?{{7%QH$U4ed~ z+ra(S%pgigz}TJppLdj_n|DJ?=RbLN*d^3(q$B3!p5$~&K@X|zVu$<@kw=rr*bOi0 z$g6}TSu0N>b2J%!y@SiocKx^KMM24`jx)BR`y#{rkms!~2z75oW|?W~J5^HKTDyiv zc|pK;5cKli-;`%qsn?*1fmKY~t{gS`fd*LX_AcpC8<{7UH}^UMf9Wmln?JT@b4=%D zVcT60Z;jq;;Ea7ZdscCm6J$nwtxqSX%$l7t5>G+9W>{wW!IGzPgBY2syk8Jydqbyr z2W(<8ZYp!PB8r^ImF5GMw`pOmc(QJ|8-|r-q(mB492X?)#cu$;V1(k}9Zvn`V0t03 z$P2 z2|{EaQY(0O>bLb@XC`1!xtx-ou%hZJ93&y$F`^W3Z}KBcN9ZOTA};b~qVPYhy2U2y z!tQF+>!8Ay>AF>ZbB$)kP{_cOMzeI~$We%?rwOZ73T4PvrVug4kGL0Ml^M;KA(Qh< zxJe-PH9eZ0{qn@w(K6jMk2Z^bBMNI5rZ&eS6R@d$mf}j<+<*2uKDL{#Q4Yrk+s%!y6}+; zrU-Oqc0hmT_sK|Fg(xdJB9Vgy*mbERQ*GKqqem|9<#0X<+NYDc(f?lRLO(?Pq!Db9|D>LyN%@wU--Bra8p2`$_O!rBNCR z)%{2@ls@|1urmuWKA0QmYQIEg2a#*a_dGdVR0llP8XKs=CcF`=OrLo{Hzr>urO_nl zBg@FGGTnuSkf9&G$Bcp>G1e?xRik4QH65mec^e-af?On-6w4!U7k!_3j12PdCN~W7 zUzBot`ulLOBHVOoPA%iK{dneT9>!mt-+J4BYX*NeeiTN=I6{8pw~I=@X}#S0c=Xc;mf$O{*}TRwG;zp}xlyTBm)WN`&6 z574Nul6XclTY>249nOV2$6;!`fv$9BJPWK1_=^pX-y;!JE$gg}RP zNie4s2w6AT%OtcqoYa=w8NlYz6|Y}JnFdBI&w$)B56G%7B7n+w;b`jBr6B4^Dew(l zjErjzonQnDLd$!ncV=};L7i(Gth)84s>Kmf$Lot?xZO1pS2zZbl8m}1b3c&V^W>0D z1?r+&GxL0kb_uylKsWg7u1_VU^G_?7H2s{vbKx3bOL6#1a0@n>It-a! z#>5)Ns>-n|4;iK5&`c+N$EFrrG z`#Fo~A=980v=Sx246C9LoJ2C1eS?llaWd47_pNag%9eFepU8|qXo$Ty(FL95&U0A(!QOZpDzD!? zU>3>(NMzCc>s4EZC_d3fh%@#lJ?->KdRo?J3xD5aGAAF9`n-KKFnGA`*xpY0J&?h#H?FIk8?f6oV_xKO*zY$+bVV?bS*&U_j7+r+2L;8s>&?~_8x%$A zQ1hI^!({PxowFH2o?~-6vTg;0IG_ED!)fPuEw0R>27^1ll8V~gkxSN~go(Y`O!kdB z^MC6^a2ipe2O%6P$r{~;^?1;f|wAb!L@Q1$0FjfnKKz1FGJ81rO{cG zRXdG}TM*fiKRT*yQleJL)U{Ubu>JwA=I~;@t;r^CKhfI;izT_du~|TMVR|wF*`4JV#8O+zag=c> z1&=a4!ilp%n)&;FQHX^HbSj8~3|1vjMKXRis7&_uM4S|yblE=2xFAuEjT=oQdKHD! z1_n5|AB~r^E7|0OLg&HLxnMmGK2GML9i@-(s2>aVG>f(0#AXmTR)KfL$YcbXU>WZ6 zXd|?9tFpx5hRplu#TA{1%o`oaM?@(>wBq6*L~}LvbD|&HE)mF}jlu3!wuY-?fct>`>4Y;p`YoIYquI`xq?244>v}_ z$(U0SJ2DtfS40Q(>SaF`l4eZ*1`wAL57HKW zVyacky2=wEZ9@_x1Yk3OOW?7u=Yos-gAG9ZhA6_dbRFDJO`3bvm5_-FgpuqK*yDMO z(0_ioZ!S6I(K2DN!e8%`F$$q32vRy7g!ir#1{4jm-8^42K1W zusyLBU0R+w=o{l_@ z685C9Fd#_-YIi+wbGr&0m_7JR+FYu6VQi{AmY#2BNa)qG4 zHwqr?YIJ$&fpUJZI=<=nV4iAI@)rvoMT0Dq{FfKV$+bIK9nHo8iRd9t_3^QL`2JN( zRY4)J$r`x~O>wFu!q}s(bT|N%72XLiY%F3qKdEhL2BP%qGbS%FIgp0peAky&5rJ`=kK$Iffnv~EPV)yJL%Hma8%C& z9o&TcsLE*h}`j3p%)(lM8~B0J5LvOCVaqzb~A99`tjTNMVE z$i{)SW>_v!uNnAkfzse#!LF)QX;$>Hz;na&RXR}GFRw2`l0<=wFL^K@tAr3;Xj!tb=#8`8@lC+&#{aP6iH+w9nOhn z!p5+1Abyoo4=$sj?nJ}U)kPw6ejfAZ4v36B7mec}U!iX)Eo8sDk#drL z)34LuWbdaX3;;>_R|MdY4!TR`kcU{c}aqZQ8-eN?Bb>J zf=d%gel|-G6;NET40wl1jh+a02`dwfGSa76K@>zkW}EboJO9Lq)78vT!YHSaNUvu3 zcWioNi?~m!!IX^h&Vcm3)1|2@1X;4IZHFt){`f_hcN&vr5?EQD;FHcp;Bf1&P^?yH4`4etdq%%htzZegk^`VG(s~- z-$&+Rae)B2_UWz-wt8RS?=KLXlC%rIwVkOa+FZ~EwJ=_=VYZKRvU*r%8 zGXm)lXqM3;c?updf4zSNKcxHwMY;l@?hQv^D+Y4RB_{rm|EC`^uG3GMy}ZY0q*ge+ z;YG5jGALm7^l=G`kjMiJirJzT%hM1?ZwXYpUw3a)5?UsFZgTzRRVR^M5Z&f2>(}Ao zCh^*Fqag+*zRq|79>bPFEdEpd5*MHQZbFXLzI^p@!B@K`nh;L4 z{qrQm7HtPfTw}%Zu1)@{_y-YAb(~VPUh|M;-GMX%-j5Or1tKk5e2B1-^OwaN`u3;O z?hgrxx3=Ot;wS{Om3wy>Y$07&c~!>liNy65QfP{ooXy@q9`Bc`x}UzUZgcZu z5-7OpV@%17Ps^WYUEh-^{!7s>BS`yr^#TBt86D|U?h zo%mKozsC&lk0C9TJrTOKuN)Z3?Dx`hhICy@=-{>aywP-FzBJd>C zRI6sDLPi!+ETNvDc9!=hcxG^Di0X!kB8zQFumKP`xHL;|(r7#{EGM~wpVBRm+6LQPZe%$!6^#8bZDQ zypm%iRFFQ|DQqLx6|M*EEM!9EVkly-Z6-;l0_riUuqXIjNDiYLz@ufcbjy&&G9V!m z*aaQC3rnI@LG(;QSev0_^tyTaMlb|uXV>M!Q6{a8RP06uMIAV&agmeJ!_$U;nC!*R z@(#<%iaLar!ud8s1Ty46X@H2Iwi~CpQTfV?78a*j`K~t^_A2N(LWQGJIQC4DpbWi7 zb7V2hwo~(Dv%nEt@#X%i%`W=cd(B0)?gNq4+X8^`o)J9)hX0mz0+?A@qtOLg@L=7a zh&v|ew`b^x4L_`C{X(WP1egjj3@Jb$vZ6=_ZpJ3pk@T=z$@Aj7`j737TV@XOJTlRz z>$3#kYednxi2|!LnnEOWXedHbXv20$Z4p&6oPx~YEuzr%rPDx5G6FgMcCQAj?rI>MJiAZNKOpn$1ZafS1(n{wl-2lY z4kMFDQ!ydobnr{v;ne>+Zq4XGCLCFh;R1l*qrZAuNsbIvRRtdFXI?Z%ltmh@co$YV}>hD9Xn^zuqe!! zg?dzC%;XViKkOITS7gYd(BvO+A~=vNYMfA^dUz^rW$yi)O4IM8VUN)$_NYy zKM0f9g9KYyWCx=}c=Qc6^`(vxQSM@qLa(IAtz+nLv)m#@xG0hu95uec>W!4hYBF{Z zI*m%Nx%!y#9I*OGaq%$*nQqa-R}9j;cH0NJ<6v^6Pb9iySO0Y3 zqcXzhN<3@(6XEV+qF0A&QtxnI%?a>B8@wS%1mxCQ`7Bz6a7VNQ0@()TmufyF;gLH} zmAr8?z%WV7EKS9_xKYNa===LqHi1*HG1iS@nw0Y}MT^|r^WVjoW7`Qho!qm4{$IF= zKV*|x8D1*L*Dc5tLUab`>gMSY&V(KF*{79v(ZxNL;F?+G_jc(B5&`fe4S`vdUb zc@R%vPV+F0aLie}*X_N3s{di^S9t4*1+in3 zBIn9{OJvLzKccoEl24OZ+P8{j95SiQMc_c66OrM7_Bv#D)&BdVm%3-4DuPG#y?2(3 z_IiDo6f()Mc2=pdJ5SV@2NipidNLXolOvUOZSW?sM2f1=45kAwa7oIePsb|+VNJ2@ z-Q&yRw*lB=Qu~7sI^?L+=IjL|3DrKjuyhHT6$vy=gFB2MNYFnK;J`G9Ih3!TV??b; zy?0QRL`2leZ)Ns%3>a~z%&g%XeGbJtv(m6~7T@6h436sYB{cWfv{X#Tuo5%cR^9r94x-d)CGp&_)o&h4^C23r7*H>UeFo{;Z^XPq4u~o8+ z8bpM|4Gy*)OJ{7m;A_ywb?`_IdU2frXPj@lnzOt#?ZNnrFM^~zX3Al=*>_NttqG*W za9!knTy>goQ~XADFC`jo&7w-2VM)N`Npuvwc?u&!F?rJ`T1$&i!ar`N_*h>xlt(`t zw;RHr^o8+L@}dP$!cQIIs4$-{Jn5nM!Hhl;!so9NOA<|JMSye3zui^NtV(uCj*N6g zS_?cyZgjL!jM!FtCspoj?rTUQ(%K~ES#W~Rtrxle&XA~tS6;NVu%S(84+7D~%}Tz6 ze7cp}+vEq1R*cO-(C1ipJqeTM_>iJ>hypY0ky*GVd97hG?uIbnl=uSd!g|J)VsyAk zUoYoEJugOm0lNd6vXwAmte6AA5;8%x1!dr3RJ}qL{FCOxzELrZ9;yVv8L!3Go}_%i zPiXlFtgpd4H0VT1E`@=u!n3%GOq-^FR3R}1Ui6JJQ(o7tRx^~f+sni!cNV5zqi|{m zQ^7d;UUT(slA{@{8qUj~JMl#NbEKfGHrm&0=f7szez~ms5At{7`|MYM6tko$u-Z4e zc6QECgh-YVyi{itqJYNTt--WbF8widJhsVi zBN;lsik3!z1j(mMv=&jWfCsuD#Scd6WKo%AHKlZ0w5#XB-_^^PpG&L7>8=(SRSiO`2#U$$Ur`@+u224{!LY_ zNJpM)tq2_IU|f9DBZiFj41>rqi3jV|Pg$RgNcvuXnA|{_ zh$&9IcBgVzPIm|T6gKcOF?N1rn(>B@Dm1`BKKlvM1E9on8 z5;!9oW7EWcaMqe55kT2H9LW-g`g`HBSt}2F^6KUcF%Yc8((W_8(oB(U1+#c05znDx zpvCUkeW~|p(99gAT*~&x`V;9%s}NPO*Mg|2md;E@tU1ydt$F7W ztbZIYf6aSPKdEudh8V;W2~hByvf`*Z%jy79bYAYaUc$i0K=4M^@Ssl;C}y&h2(Zb(E;&5~Q}pvs(0Y>*S7 zLbX1TwTT5+#UpBZ~ z;z$6wjgRVcN>W&k!}3{i!9hKIh~PwVpUtk;cPB|tAtwk{h*9_cE* zW&yX$tlN;3T-dn%O4R7AMa5*6co<_nlyPuGYA@!CI|K5d0}sGz5Jd$-HE@Pvj`AOB zldih1n$dq(j6;5K#9nuo?@ITc$}7kb!JXXZ2FPN#!l601+{C9E^96(GOZe#rPK)F8 zK5i=O3~AO4yNmcI1Q+mCAgF`5#aeUwLqgc!An!-SxSr(PXnTEDOqs9#H0jj^Kv!(g zEz$i%{4<#+WR9wk^oPw3^6Qv@&5F@n_XH*vs+9oMEVECxfVK#=+4je3zubicCRHwi zGGj<3f@q5rL)+&7SJ6kEeb@`AmV)-{Sb|8N{9{S}p7ZFb_^M!yde2;u%-V-E8~f4d z2NvB60n{cAl)o|x7MWzeEulUNbxGf1m$s^%xfeY;BGxZZ!jELHa63dyf%@A9zKMB| zLMQ)w?5LNfuWZP>;vmAxaM>T5?D%rpb*Dq7a|4Br?Nm;L|=BZB1Df;sbJnJg{ng2$_PuE-xUynqsco83D%X zv_`h!nSv1Q*W9=H9f>k9o+7!P^2bIH z_poc-5P1H6LQNm@U#KEVh&$^mCi*0Y;{E%lDj}~87L4{ghOfBhVh$>+bZ)>-T;AXZ zQayqU30BCpKvs`hNr@yYcx7|KH951v*rk1`A~q@6sHE+b*;bp_i&3vBZ*JEK*3hOW z+kVrvEX{%I2(c&gBnxmTEVsO)tupL7u!xlCnK`DbU{ngCGgMPWJF_LS8NLkn)ST1I zzR<*U4?K0Lm8Zu{o_>^sx#ZK1j4HYTuiRF5R?o^uo~Nw26OxDXm-Ao8>}(Cx0b1eh zOJc`>7|+;u z5wwD(p_NcbE z#UVEfHVj~cO;4=TCnfx-FrjO70OiULcC1X!xNmD)IZ3;s@*kf1tcHRn=`7vJTdCjUgAw= z(W$Q*ZW3aCmkjA|VADuQz6*r=3(ot<)H==AA*=ZxlKWLvSj^-P`l7W9slD`Z4b;jp zSoE)|DrUh_ZXBPY=61COWBtAzFu;ukaT#_@`B98ij%^eet&-rt zSBk&S#ehAb2Zr(W1D9@Iupi-?-Y3EWcYaiJTN+Pq&Xy01D-(U$sV!te%{2=m z5l}`-5JWC0u>E@EUvq**L)6<+j2;s=Q|i%{8Gyn525(=peLrXjrzUbEA6D0enWe2h zfekWqq_7VQi6=-RY1~L2b?drYnQL`pdq!=;@f18wJRhEHqLl!ZF%d3 z3^I}~`p+yVQ;Rp4sF&D^YKS!!3=2nxqs0u8&^#>$7@;?H6dtA3J(#Ju8lzCssDPye zjk}j|qBT+-G>qG=?u*wxgI%)PA7C=~Tf>b#Gy<~o+v?iPg+LAE7ePWNHKkptk(}Yb z*iCezOhZmGrkec~Ljg9~^PP^Mx_^1(dkU<2qL8=*uuj!qbWt^Fq9V`#leh5+{95k$ z*cbl17f$)ULH<4!{yP4?X#qZSe}@9U(t&UJ-!Bed&)=UdAK#E+$H5WQmmHs7q>w)h z?~2hx3VK7Y!>8pRvZ}DnyXel%b!*4HjhE;f4YMBCoa>(NpC8tWma)2;Pnsla8NKah zZKDnK9@i?Cm+me8Q?$C8&zdxAtG(?OZPyE%&zlr$?T(Yr{!_f|=54bL*B{pW)p#G* zYL?x*n)8QC{!`AI3~DdEFL{oRy)S2M!wvZ#)|~R{|5FN=k-FT^n$&A^y)PGR%MJM- z*Sxw({(H*6`*O}U-LT_ft#p~G%l)+J+_&RDQ!R!ak89P-_FeAhO?tJP-j_?Z&$@UX zb~%HIQ)e=kUL}n}bGjs_RSB+XGJLiaxspj!4zK@=sP(SMIw~)i8at-0#5(9NnVK4x z?sR{)ZKQhpkLx3XznK?46h_I|DE^2GuORWj!v zrcb|(TY`Q8ueL%qYu5SZKj<6i3~F4-@Oz=^K&iRRsedCVLk*`E&Z%a#p;M+&svYg< zq`Le2?0+tW`_xb-rd0C(K6XO<KiT-8e;&!T;u?U}9<%9~~!=aykg zdZZJ{n`RQ%lwrzxq!Zib^*-1!5HwLw=E5*aV9PdKG*M6I!Z1#7&NlpaVzYy1i*m^# zZZOl-?MNq&cgZpiGRrjdNT-N*$x4V2Vb8!M^GK(h7t1CtHOut(kxnHqmTlZ(mTBA3 zZU)*l8a>x=(qz4b3&R>gCD(AnWWBu$!v+C8w{keua2bqJy}NwXCYo)_z#8NKAG$|G zdqg(rnOvsnbWkqY|L<_nX)e%Nddw4hSPoiVT0UHUSRQ)fe^($uZ~Qyv|6Trn-Yn&_ zAMW`7+f;1=-wv6VZ}0CG7o)Y~c|(>!A8N0K{eACG7su1%iEDehfIg3BO5Y8J&o8%@ z(+&Wj_wyp~b!$F7-odcP_wl3gx*F*Hxc9jEwmY9b{h4;r&F?0KE_&mkm|NdjX$M5N3QTfWS z`|Dv*Tli~#S^28_z76nl_tj%q3z)_H{J0t%FQ3kL5`0bnesIgbOYTsvH5B}O-OTrO z`rJFc+!px$crBk6{xShRWeYvO9(zptA11%{S>_A+*ZX@uH!dp+ANCLd-Jj2rk6V~~ zyeZG2_4oF%z2DQ@cLAU6#QM3*#ammOIj=|m0!G~gthM*gj%>=P&52r+AAEcHS95cWmr}F`myRG3pgtG0Qi0~`afGXsO9(g zJGr!MA6$AQw0s$DRigm4j~{)-Oi!`yD9eyN9&3btAEt5pyX2*39v>^#YUBKU+HT+TVAVJ70-?ou60dvn!Rq z*Rh!an|@EC?LxImqsOJ|u6`cr4)$ls=gypWm+0QF>D=$L9otn~CNR!!jqyE4g5Lspw`;~g)Y{h_3xg5Oj_$UuqHvEcxtH^M zcTBEB+3S`{s>SsoKF^N__bzCX+hA*fW+L@s)EGt-h$L)AZx?!|E}$;kc{P{CK!O zR?hF*Q)2?YPfbz_`vD!^JJRVXYRrpkzgHKB&n;^mUiGMm`g!|81Ki#2Yi}%s{oYRR zoAUB{JiVV^?mMv0w|w7@>N>E7D~ID)iG}@M&jzudjn5X_lZx?T8gZszy_Ws_FZA@o}ZyA-??PLRS#-jzHG|~SLaz_6iBeqQ)@SAcUzcifNJO29fxE=F#)-u1-!|P=Kt@a_Ee{;yISg$-gGwI70jr(4% z)+WB&%}Y0~wA9BRU3k{o z@n2s|HDBPLpHFwkt={W??emuz`_jTM+J2=glHBo3EB(mbIJNT) z+@_xqsF)Jkie&%Ji zz1WBP{U`jIPQd=wSNyn(zWknM75HbSDXT#HMy=C*x)b3i8et82){xuA!J##`=khF@-e9P0x5fpsP7;&^@}{p)<8Ec_{yW zbhfRr|G4QUUcNcrUSw~NPH-li*1!hdA2h7wN#Il>+e>>rM7NGqK53^XpyPXNb#Zd| z2j_lGxxF^~;PgE0Lyt2Zjt?0ki-f;zz6dfNd>o*EwhGK08mjFc zON_K!?zPmka&P>;HqZiGtXJ++zeOV-y!6UwUG0Ga&yyMk;o2kdQO7UbeQ|{6hlEUV zFBV^FoxYC&-_FCS=g`ADomHvlFOeR120#0R+k9=y1C?^Nyy1Gz6})UW-Z`I6`Ik4i zJCXq(N;$gUH@l6{&reH%r^xTb0JXz&e&}}PuLI}oq2POm%bM+ujq3Gq+TmLg%UJnw z<+XUG^b-TDRZfTNd_im2#DQ>|=B3W>d~L1$yvK zExUZqPEPMne(k8m^4JgWVn8p(=HDX&ucYr|?|NH~*?Wd&-_z9ha!Idjxvu!Jx7M## z4tb)$58g(F^|wg#m7;`RDBK?@S)+~c#`|HHT&dhXtL1NxOp3ZV6a6jeUTQcF@$;%3 z_{p|#Kf712{QALsk3ZjKfBs%>7km_@^%MAolqX@5!hFjG_~v+W^8Ic}qv)|$`)ppg zuGSsvP+s@QC%cXby55-Ewvz0Ak27rU`zieS)nl-flz7TB|E=o@N-ra?FI4X{) zO&kCi_^3TOY~`|4v7mx=gP1>*(<8SU*ocOuwDTxf0Tu)27^0tSZ0d zdy<40T>(v09LPLm@y9HD5rzAF63(aah5?O(UbkU&0NT{J%M%% z+-Mz9%SqqxqX)ipq)(VxTJ_xX0AD`ii)?v|gs~BOc;fHEXV2enj?)_%4mL^zz@&0K4~(H$=mq zs+3aRY(6-!oE-2WF!(GSsv*^>A~U>Rp$@8o0JEN$F@BYzI0o=?H?zn z!3T$<@$=!RYgbI6eh17g_Z_GG!~@FTyw2&z-7)LzVOXpUyu#7)^2Gk*i}T6C(P_ub z-yY!=U7r5Q;)|20(R)wW>0q2%gNne1b=9ncc;MYWv_Yfo@)QuO@9;R?b3clAi!xm} zs;@VgPi6YPT{ykaWTb_#v}j)&c)d>C^*tP?0({3#mnL4dshav#;?Y?3DbTP- z!(L0wMN(AJ*75mH{80}4*d|W*$IVz@Umh#^2NTQ*K$LY`MJ@^@LL@~}RGCn+%9n2| zn0ts;FhD|-^SAT`yWa;VskNttx_uCkO#MFQvvv4-68m?oa(dXE{@o;%=>BKEz3EHy zUB2l!eF3F*nw_D?Z`^O9!1LMCi`aI0i)xS47N*4$`{74rCP`n7U;CXC=g$b-@_92dHw!b&VZX#l4V7?=x+imfUpVUe{Z*NXG0n<;^GI6weGIv{KKmJ7 z=re2EQ1h(?ARHIhCoGgfw)aAe|Dm>kC zAJ{mryf$=htXv;~J6i16{@SJdyqL|lm-IcK-SQJRLHxdp_A}j^cF+&>*6TWyZ(}{v z-~m2XUKaZJ-7IeSzb=NW5i1y<2Paf5XL zryh4gL$>V~ixeoiGg zQn%$xUlS+&?cOse1e_`UcucF?8TTnmTM@>SXHrJ$isG-IVIRzE5#( zH+z47$=~*R?dmyPcYq(`n@p@T9Vp5lZhGts4R-S~$M1BT9g7urO(-ue#FVgT=S&CJgG8Lwl| zuXAE~`nzt<)=oQWzsAKox93Cr&0}V2=yRbSYrRf8dcWe&*B)QnZ9Kd!FI|T<83$c% zfQ$Cl7ox)yH#0+V*HiV2+RBXskoNZ72JJqK{jKtNYB^*Xz&+dUe^2JQ-r{TO*oF(} zo?5@VI(W)G9p?7#>%iXRN6u@JT4>o^_h6iCVUDcbYVas~C!*@sH(#8rTxPBj^7DPX zIGblS6zX#Pu|ptCK>)qE|0VrgV(NN?9z-xQ_arVXPK$l72hqQVD{MQGA%@f2c4^h~ zZus|Qe}cS)eLn=V`LI>4uq7cb&Qbi(&C`X+eyPEik?-jQS?UccI zO~=`a8yUi)uD@ckNPoY#gYh#o9lbvw^QEmW8@jgFfi4ODztH@75^q zxqAJj6%Hx+T^*BP4HLN(i=&9{wu&$vjKLJBBG9k@@bc<^IL@QAkLG(XE+x*e)ap_jp5!js=s{ z(zuZV-!rL7%g*hsPi6PaoW}pd=4X(EDRwTEKpj%WX=-yOB`Pu@^yFWe&cHj)2KuMzZNPj?Q{d zNhfyy>tz%Mam~P`($E}Ptt&e2`2=CyOcY8iIjw}*2X)jhZPh;^Im6=)C%d1it)QWs zQc$p{pB83BRg=|{+(qy=J;6@dK$kT(dQ{Sqm&Zl<=*6oeqlqsy zWwS~Sd9Qg${*zSpQe-0$GV*p!lW|gRx%!<9Oe;Fftt?ypN?dbrU>3zF|F)u2T|Lu%5ECIzF z+)AGTFg6aqah}f=qkt$LWXf;W_&bdR0Q@=RcQ&uL_j)KIAdIln+i%ri;A~nNatevU zoJBo{&=)1ELlaIIyNGCvdx>#-oB<`(s3u|E*V^@tM+V)ytUqTBpvK85|2f#yw|pi^ zeI~)e#(hmEpi$Q{aQVkvNjsV~C zUd)glr<5U78VLQ0rtPIl{)`>pQILSutU%5+^AFJ-%FHHEqQ@!ed>5?KrSk&6o2Ke- zh79coN(pkzVEoZp))NWwkxFh@cmA=&EoCNl`120`IV`$XgETh7I~hp|U$aj{&YbjB zjPVnpS4>%BhBMl$h$D3V_B?tPHfLZ^?#CLpza-4bgUaF-6f<36m78}oF)qCh&{L!C zGm4Y~p;hEsEZ0wL0EZ!}GB~=Wr^zLqNJMnn~=z-`PhFKDQ-Fmxwsf|f*1lVm4A zFCBQy?tOdDrxMivnQFDP_aZrpP0l2?)IM3y#fn4PO`hvF;Tbl9>?U!KHpH~K27sBj z)JT~|o5o|j&Y@`Q>>l`Yd&>ts`;nQjvVU5zhm*FJT8ATx5ho^@uMt@iH*+pTp6N;! zinAOJt@az;xYVs?IF`=prAFM?@0$pauHw=_qs12dUC69F1kXDjDX3)q(8^*XA#LUP z=>k#Y96e>ZF_X$tloj0jCI`uQ6S6)*Vj$;aaY$A>v4k!yUezB)2u@UOrQICy9T9(P z<>)kvOYV}dF6A}cyYv^AJd<#VhTz0{pr)o(#S#mW%FP%l)VuvpzlwBpQ}6+A8>pV0 z7I7EMLJ?cnm1+(9hh4%16AZucJIlUHBCqfPdE)-~oYLQL>_UD4h<$REzmi=S6eSXl zd=(3wE`6C*1H)6``~#TsXz;y7Q7B!@oaC)S1Q94CsR>JgO6N#y8?+oIu=z_%Qv3Mq zin2|wR;Ks_KOOb|%eJFPMere&(@1bD{Cu9_yi?NU!c+HO8FUw}c&0jaO zb(1x=TnqV*|4|kvj1TGAxeQkM!jdrDiHZ`3`Ak+lF|AJg)vsFK@ z*->n-q49TLa)b~cb|jA4El+5LWUs+-*&!cqR8)#c#Kz2&tDz9zv*bsWT1YAHSSoIE zQr1Q7x8XH4-w%||4Q!isEFiffE*X2-j5%;I-n37ebz?Fj*srqNq?G7hcCm3PMiTTE zctASn+0(w|rl{vlagh8KfG@;a-q+GHkbDRZMSVM#M`mEiAC&_B4yo^~5wNonj!R%Z z`okvCizKoKYjrKc_g3bXy$}phaPC{CF)Ig=^^2tPm!;>+?m}cC%4xr&{=ht%G|QEC zO3rz}kWpxA3zou@K_3b}sYH%x3lVJ%d`0=ZVXUpLMUWzoBS-eN8xwK;GA6R=YZbQ` zsNGJaVa2(;2JCG}(w5z0gTX0}cQ~xq*o%Q(P;!c~ktELWbzt5_WH&S1ZzsmgpWztc zkl4cX8g6DSvUfo~w~5mGttq*WHPOXbf)t6{NiCq3++#iwN5d}U&AY_XO)X?)d3x%w zaaffx2E`V=qAuo_=ewijuG+u0;jgS zLLw}TH5B;VS#rd~T{`rVUuEpOoJ0TwT&X!;gnldgf%2MvI#hNH-$YdnG{2pqpsJ&& z7CUCaizF7m{$;_2Zn=m)6g$6=%7~Ccr@nN2gW+EkiOj<-A!Eq`%g6h2T-NS88#A3w zr>g`hs$EiZ9y=o)?(^+z4N`uMKPY}|s@dQWz0MLX1RF$-ZfVh5VK&5|*Ivnty@Hg` zYCB2Eq5u$Eu<_`AQD9;32|#EhM(Q(m+=#jm`80wTezLzo+!^VgCsL5wur%nF>`%YM z)%$8W3$PRqZIoIpW1(+%MG+6Sk1tUZIzt$*7Hbg+lXvX4ikN>nvW2W!?~)ch5kA`x zE?}M`N>oJ0mx7=3xI;-gblM9_G~u*{+Ac2n_~7S(%%vmtxVOm;oDuS%kEJ+-~+j{@PS-YD9)FWA@Pxu+@Ggvj$ z7aTR&pDUBOOp<>TpJNylQ$iDSjtEzz_hdCVSf||nvCHOy&pHtaHGRSf(=Icga{|iMCxFL1&hxq?^mpQ8E2-RpD`Iz1QNyc->YF;Zhrld) zplkm$HGJdh$N@XcX(m@`Lps6hNM-%RFQOJt0XZl&UGA(Z0HN$6J!7v|RGKqE)0-LP zYSQf9<*poyPDXBtN2?ZT8?Ezovak?Ee?@& z{zvZ(i}xuW?DHkI; zL2&@4s*ZwJHryw&^y|wyHR)J3)TtX-{7`Yg!WxPS&H1FPrxO97)9Z|PBb}?PlOET0 z5|QOESNLvvvx(*4kdiX)p>AlGLvPf2w3yg6D;zhPz!cxnD4O!=z>SE^Ic_=MUXB5* z;#kvUBOOzWwPgrHalO)l8nTE#oWWIz=+Z_-IXQ}0nrE0I(;ADFzYZf@apdIQ<7Eo8 zcyC~H=!Q+FokHt$>X4sN*U0pCJvL;8#XGZWwUX8q z$KmzWWTT1^p)>ZAsRc%mR1T`0RzO&JR+0O*Zi^Qzl`h>GoDvhQ-MC%RCvu3NmGUyTn?PCe=O;ZL$tEvXWdp6`Nq1!IAfRb%=w zV|A`ASnrFlhI5P@cxOG_V3y$t_5$A0cnp%t(2SVwxlp=L84OO$Gg3p&3LMT>n8y9U z76c4CaMWc;7rSsh@^v%uKu2)DhWXKo1@c~4&#MrFLP45JkkC}7TTb*>=@YF;%rcIT zAZ<%5YIcj0)OA$JTLc@Bco~)#6RShq5~7trkD9b?U0K4;JJ#XZ6^ITDF1-6!n#d_c zo!evAF!sPIOWr%fsrE_$JBc3isGaoF!(m8`0ej?cokkp!&x$o+Y?AdRYlYB9VWmWs zvcAwkF9}6!PpvT7k~J-j^W+G&K5MXpl@e6P{)7aYoLah1<;K>^aYHKXGj>A=cB=*)AhuNxdYY*JOQ*4ZTy z)9JI2m60*mSafTfGSZ_#zv5ejwch0=%D6q?klxW^oFRk*U{k55iJn z?dB-=*71@wb>)+tB(Uxeyu;e`_b2zHM{!~elA<|qVqDnPFOuoT1f61oa)}x4x?Mik zFalg(b)84#+dITy1%ZusKB6N$Ws+LdNn4)~>9*YDS0=*z!QEdBDOV`39RrKq48oE& z%qTjm%HbXtHJWpBh}z*!E_7V|Aovep4q8~PaAv2-Ro%Z zuACdoobIW}%2qjI24auE5aJA=0YLAyP(W*c%E&(BZKvAI1tI#MG>GWkx&^bI=em#+ zgSxf+9LLsn2h)JpU9QKy*-4Z8d>ebV2|y%DX_B^Z2qtK8LmL%lWX=cY-OvTs=?64O zo-sMa11c6C2ggA~9WJC+%Cdqp{_Q88TJad$sctWW4J>aW$ygEaUIxzi+&5rfoTBpqRsJeU-$tIP`iv-jO3YOjU4l?I>LAvCsVF0O)<0dQ^+&vp`YPr(k72`3K*AMYuDb3YaOhIaGtsO%7WA-cVmCz=804<(J=~Oo?vGvl(FNyZ=uoMpkfVtSDQH|QdAR- z@i2}fQIZh@Dh-%{r_FF#J&i*taoVofKtZA^g6UIpFCY&$?J z5Uuph8h(o z9Y^8hDGoIOYmdn#TYyw$urkMPQdDg1U~7`WyF6kX!m8kS@$0YxjZzu#N(6J}^c=|r z+n7GwXj`{Lh)2zdqGwO)zJfg2x%*KJELjY|A)MuOuAQzId47p}qtoj@u_?z;G`8~! z&iI#OdCJn--JFay;#%p)*xlDergp9cn)gf|5W(6}YVw@JXU$r!NfSPTzF~L3C$N27 zxxE{$JmOkqGn)Y!7`}ufB$F>(@B(mw1#FUG6Jg1H<%xAxCLd_Vp#kzs9n2zOE&5sd z3MaiB%_-tw1?fqd!(7)PVyY)ss`P;yrq%q!K2Zu^RdL*tIbEXOOJZB4s&9SM<@;1tOjmx{&_ zvEVBuXq9*Fc~<9$)CRPq$%^L2ZzY<89HS$?Ea=J1P=SGcYDS+jUIBB$bL!dI5C&cA zj7u1L#c^;l62UW-F}qO%GOWiAol|>yh04eANODVbk%X(}`d%D~J%Z{=EEzoMDhm)3 z4^08}twm0P9igB0d|^;IH*R(PH_%~bSD*|dG7No#f=gKTET&Tr5cCGnp3SDbeXr4-^IMEvpjB5o ztnCCI&DnaD-7wPNrJwi1fg%BO;Yj1;2QGyn9$VR!9)XttlGh$R+|fmvnvOt|LEJG&$^B3{xx*!z0Dju4g!S`j?i2khSy+sM;*ghu;GN=9#{I@+ygg9Z7z z3&SCXl7!jNdd)eeZ%QQ%nra=7SnKN)t2o~>?mi7B%TYpAo$oG00S2ryOAWTVW zv@#(K+-EH8b;i*P)p6(IH_6hK>Dvkb(}jdg->Hb$iMx>EnW_S#YaHhS9dDu^o|kUm z%bvYfwLY0q#9E|KWiC%bI0Ev2f`G>*QQ+{{*hncd)tWS49mWSJ!d2a`h;Rhfm!bXu zD~M&f!^sc?Co3@kcIo5`FZe?M=YAU2$c+aD-2lDDR~sA&pv@U~1u}){13k>vi=i|J z1Jrd3WP0C{!_z5L*E*vrK`#Q&0KdC-F+Yp8K0o@Qbc`XMaBZiBW3NAv3!hYQFjKB%Vx}+Pd~RvkLH>HFl5Hgq6yKa7RD^R`>p27s^bViZf2T z;|%^0Rls@>(=9c9B)2)>&BX4>FkxWK z%hd-NdKYHTfUMo?Z& zQH5vaa}g};D74ZJgOYISY@o#G9&dy_9g~B3BklA(DOq>8F?55d5hWijjkWC7h9Oss zX8*eLJUJ|*509PbLCWx(`l@((MA3#9xm;djy-aV1wcc+{UIU-pRB%+@?*L z$r+iZ&X{#@*Xb4vFubK}NUT12X9%U)ixJG2C}&tE>pgmOdYIkHqFo181i;2pAosc- z2;i0jSBFwNdxA;*19NCCAm-SyQ2@$x6$)%E&wrrEz|2sO)fwPgs3%Yr9B*Rfz=H!R zS2T!;N@0C-$Wz4sVSOeK#}c&M~4ZZcZ{@T_+F2 zCpuD5F4Me%cmq$+9Rm@n=wF|dXfZq0+*hfco9-H-15yJLwHt?U2gxTXdk^!>Dj1Op z2&p=BU@K1&z7EQj^-hy10NLRbE-zmj0gL7VHQ;c1(zy&WltNMHm#q~DEs!=<*$r{P z$0SqhAB}qfg2ti|4=6sB^Ls6Gs2O=^{z12hiK#4m8=c~Kh#B4ke5a$iuTPc;%K%5( zc?Q#Rogx#<@dF3;2Yoc!SOiSM)Ot}4>RMu0w+d4b$kPz;hB@`Y%4M7xXANjIu4a}a zaygLq2XvQ%3~DA%{HWO0tA_SVwDuAQp?ZS!z#@c9jaf(+&nN{F?F*px)L@|VVl|Uh zSBesVth*m600EFCOvkfB?O=HTtSh}4c*U;7Oq%6vtgyHUYZI*hX>JG#86pJjq{r-3u#bfEJ@qG|f|>F;=6E=>B;i#IBO?Ik!;2B2au^lenA3q$uF0?+XggKVZTipI z4fG7|fcmSJ!#7w)ciax}?x(j^rl~@oqo{30p9{<#xj)Ovy()riA^T?f*LE;ZE2ZaEmdssjOu_%1#0YQc(pLJEjbOXM{3YgsieLb5>R^9;W6=y1IF)6fw0ypPt zgxpyKdbpd1+GEuNXCHX1o*r96>?9ZI2tH#}U208f>DJ)An@>^ZpB4xN)s-2Cx% zgFrqH(HPxM;aFf~x1wftUz5=m6(Kwp_j*oPpbdKF+^8|KlJ$NAYBD!1N!$e-1Gw4l*;8^3L7U)xnEFW&TqoiRgDXnxQmHq82D3T_ zvG~^dg)7jq7Es`l*X>@H0B@#{l*i6JAofwTQe{miJA>#}7>7ArfX8Q$zIOgv3^C}W zFjen~S-)#1`sjmU1mD#tK|YlANHu;qof5}S31)Iz94=6UP0xq;F+vlj6|*Y&V<&*X zSCg3SYpr>3+Jt(i{nWGR90`^ynALt?O_9~FBNm;IBXkuMRStEqV3KtA>{eU^UP<<` zR)KpqQ$z?NQ*s{%YC1NI^Aa-o+F(@%)sX~1fq0X(8hV|pSP4%z@~6KuZwA^r{d zzRsAaMp%z%xtBKqX+y;z2^=5IKVH+duqC=IAeQSU z$RIpHzP{Gz;kYl1P=#KE_J&CxOy%L@NhlC5s(0mRMojx;oXCQU3dz-a7 zDsXk7fCV%%nYqxHE~lVhQGT*r7$GsPUN@rb^A#2}9Jscp#8iE7qEVu&DuA z{adjj>ZTts&H_s=+`-N;7!eO8fcImQ6&2Q6JJkz`)l%7D%m~&&epmtqCIV?eIR;5H1i4F)_JSThx_ZeP&dA0n4BBa1))U}RGI6XZ69i@Nj7 znzHmby;S1@2T@3hz7&tXQleBordl(BB%y%$5UDAa1r!g!Zy>n0m`afj(I8}cNSNTP zByJU8*Eswq2F<|8*46^XV(lUDfDkhF{Cll+D!|N9a`cUqkUB_dDwraVC8h5YE0}H9 zlr#&)FKBS~tT4m*lVfN#TvQGLoYoo!g0V@yY;{J2;7+W4kWctj;to!a4$*o65-Yys zoPA3?Zn3ntVv5=xO($*wmLc}sDMsW<@HaTFgo5U`1Yv>lVr(xLQCQ_yT&M52em z*+nbLTqDY{&b>NltGnq&1{{dp!(vX2=>iQMhV#wDbP5h;bQ=tFSs9)YUh$jfSyvst5vsz5NAGtkbP#tc!)6qLSzleXw#o#Mtq`~l zmZD|=m$N=@J*0fZ?F5TFRiC25W0=$_ia5uWCfIR1nfXttQslZD->3>{S<&ADTgu zdaQ8dB7|1m0l#AEq7G*iBp4ceA2o&*B%}vmTja!ctAMEnySH-c(X z0|jx0)2IN1_IMd>fIgfCOwjWKrKzJw7-w`w=j4#$SIv7;yb90CWUL$(g@GUi?MfVV zzpjG1YfPwV6Kal2;Q}dW0L}CG{0ybJLWr2wEK4-S08gOEW*$dkc9tRy-NG4Y+tK=+ z+RpK-1pzr!V#etfUjkjbE?c^Rb-KfaObf-@+FjMyqse2G_#XYL2;%COgE8O%81U0L zz)mfWi6}J1IaOVLSrB3m6ddMo!lx zC7ietnh6R%Xf|1ir9h`;Dh@8ffLYNqc_1fn~i2wWv|PGn6d8Wv_P zz`dE|9q8k@yG~8lG$d$f<;3FVo9Gf^l%fuWrA`R4`;is}xRn)7IbN(KTEkZ1Z<#>hfMF@G|gnD{5eR9Dt|pK}5+qHRPpwT7%3%_qoU%ZrJwctF>p-Qwua6taW;;au=^a7YRbOw{J4 zWXXo|Lo)HGGzZ&2nyClK1*;^UNvsHFmdSh-a7coJqiSX<5gHH7BoILJ*P;Yw@^hZ5 z=}mUa3NWm@4R@s00Z&FcJ*_~oiXTurfOswSNSpSg=0(*YZ#o$~D&)DuAIa&WQI%|!u?OlAux_2?o4wXmB{rbSdsap9e5`784qMLLb+3n zRg)oBLyc(q4$w4>G8*DIneE{h@&hZ(+PAElsK@Fh8z1ATdHT;Y&}8L@=UVUx1z1vI`T? ze^3Sz4O(|(vKl#A`X_F!ahVVl6QW111J-+}u??;G7`p{W`vGaGs*2lYk)Jqh)5UxJnY?wu>3ngA$%uuJ(H~k)8F0R3fvD7&L)(r-{oV zp{$O%r!*KLmiFqcLT8VO08_7>oY3b)fGJ7r#Hc-5HjG$M+nwf%Ag8DZn)i2=<`_^Z z>8Hf^LJxN3g-Mgk{U|0MSkct1K8B7ancQ{R>s&Nch?@v!3nAFCshBWXq`{5QoKvjK zC>!{t1bbrltYys9bls9ZJRBs%fU)VyO)R=IG;}CvHdsVSG7{9O^B!rq5(ruwG&aJ0U2r-y{jDyVtHidXn*_ta zs4YsT!FUw?YU^VkKwX%{_v&zofHa!q^bWGg!nE*UGE`mOE&~ZK4P~*^%|Jrr9R*Ry zd0PboiXvk%bP7y3BgHOS%&f@BJj9$wi%BzG52_igij^%UsloA3Iq3?eJ)k0pL7Atg zwsi}T>T8nEIA#n9B+`jM4ZXs=&(%ZPUL{MA%FRn)1O<8EqQlitQwn{6^($Y_=B-jX zh!t2YP&8;OOjjvo8f3K=gcyO0LHdUJ(74P@m19YPiGrvxsEqHM6*QDjb;~4AzV*v1 z>?8Dt&la!iNt$rXYd}969Z&rTz?@E4>HE|NRLFPTti~ph3TijRZY>1UB#Oz|!el?1 zU-Zu;CdHKD^byBdMxt3En;Ip;R0~+?5eur5$AbCe7+lHjgO1vSM+~+HrEiZEh-@4g zphvrNUy_AJvI)~WCP*k+a1u< zVMsoTabu!Lu{C$m4n*vOKAL5MnS6G2%uY{<>!gvx1D9-1yx(B#tm0L}Yfps9xeyuQ z)8f3!G+?3U@!_suDd2jj73LZDk73p+fN2#J;GR%{Tt%pwMOG}5;s6C(x1b^U&`?PP zq;G3oA(5ko3}joQ3~0_!?vo!H$W1;9u3EiZYUahi0;VGC z8ZDRZ?5>N8j-poSrtTpIihc$xbT;3V!+)Y^KLw1%mlH2d2+rt3_A zxdxK8Hd6~(#x!Uskou+(ok}OVq^^@y_8Mx*$JFrE5DzF-Koynb2bk)V*ap;Qh)R|k zC}bJ@pOp!CTdG1G>S&g{xdl>{pf5`_b4yOsqEQ8wY+G%IY?N8MWS32c1BC|*N6M$s zLZQb?#{f!L*>VKxbu~=snLPP}x7$L1sJ3In z8lh++QAGd5U#id+mI8~V{>ZM8BVcCYp6{w10I5j-)rC=#;tz9Bs302!4H^SID$z|h z1$|&EAo+IJ&)_UYjZLBbN*X`K=Z3|dcOwJnuEr_(UbiI`ZZ)(N`icgRARf^rA%jPQ zhj}!;`6>bi8o1x3V+n_;38Z*M6fshJFsZ~cMzm)L*)*I5 zxJ2Htnxv5ENbSHy1wH>28V^Z9NBP;9=b+t_qrbj@(WllPp&jAObFLiZ}&yIOvyo`Uq?f z4H2SQ@w_?OR8Z%T_%sm!q&}$;(D^O#gxqlAwlj{3RXYiR+&SWp*W*#d1K}86Q9g2h zN>(9?DTZS?B3d#ewFhm^gH?_~2orR#Oa=t>3L_beZr`d@Qm;Q4-p*~p(x#+gCQ*Ny zYeVA|J*#z*F7emLj#K*Hq;bOx1gcmRfuw8YVyRMSS2T-zpiXFtA`07u zML0K#8JrTS)4A1r)?H`sA@gMbp5((A@H5ODa^OguT@S-z#ot8 zKvF}$siRf431S#R02c!+>sH8Gas(m;gS5FYvf&Y;w(krUeM-jWI7b*OPLns0l`#=& zMywE#c$7>8+;F5-EU8Y=@HuLyvl|8mrtwsq-C&o55^*9x!8LcHA;vW@ zkyhMtZG20PPrx!;W*p=>n(ts|?V}(ubJ0KHsK>?8mO@Ha$U#GF7^6xiMq0oo@vo}` z6$ih^3^p);K%S6iaKvj#K%P_fLT+g?M&M+{D8iCPAcQ1hM0?heCjrz%QyyhPiwTgW zA~s2Bxg8B?mmXn}(X4w#^gndCr7b~=00*Vx0a(w1$*d5vT=2Qj#jH~g<8sP!rWO-+ zMK2fg{xC2MX+~Oqr?PH0L@P%Kt(9>vrp9kj=P=Ui!GnPqmbGrCdXFYQ(tZFz#eB+5 z4gtXB33eK`#&i+6HZVkoF$GZiNhoP!wr(B-0|@&3;6}@)rwb9BCEjB_Fm?4#&PICe zhCngyS35{@5zhQkCk1^E$KD6|}%wR>VTZ+3Qxa`arc_%}@rx`#7_t(Z-AQTgO< z5*>p>Vyb|0L);( zFfr{E>#_?z(YHCrq1+G*NJ-XN1w4psSXL`3yA{oY4A_gUnBc@BoQ#&`hGHS{12k~T zXC>aQqtc`Kp$rmC)dH;quAi-DOjN-f0ggSzj#bgx5XOmKdT~-qjwaK*!;CA9=eraM z$!7L_hq1_v4pzqWERupi7By*lv)gPymGOU5TH3B;9wSh3RlI(_xYawa@B&;8bDL5o?O2^CjvFQQfT)S~@gV zTD4T&-aTMIW>smqU15l7pleY+YBt$nTE&qsJqlaY z@v1?=E|`iZ%2VppAbe<|Kd>oC0AgtxofJ^!pfJe~cL4H0H_QqAQq7jOdSuEcV%S5o zISaJW0}#UjY)%O=0gzw=$+)UMjad`4;BZ)!+n)yzFsi_J0W1$x(+M*WK$PSb-3TYw zCqU5fbLUGUETB+`*mh}w4hQ>`pl#JDpgFXkPp$xCE5C9)3<)-Li6t8;FDIXRh{GFA&n6ExgyG05ZNqk| z0gFgsGx3M}q1GxWxNG11<*o*OO-y+UcBY5ag(XEbL5+=H28^JjlBq)@R?7sHZol<}T z-j1M|$E&#b0{LY$V4_8gAgVOLH~WO?CS*m}g;_^yO7^g9$**zIR7Su=F3imD;*DyOkY1rh>Bq<+u0)t44 zdocN2#~jKfjv#W8EAkAk7f~&Eo|ri{`K~#6rppTx9r~{=j`At^@+z44a*;Y=fCOQZ z1Z5Lf!R%uELeJBBw|K7dJfIzKmbZ!vd5%ayBJ7(%w{*GAFuU0qB?mlDlO!{mHF$N= z*Tg0|^hTM2q5mRvY_}nW%!Hv^EK{iFl4R$pW;PqMYK%xXb<>_05yOAP7t_Ksgjg(L zVFwNCPr@Amd_|XJi+ol~Za|$hmp4gK6Lh>}n{Av1Z4+q>h8t-se$Wz-IqNzI>;VCD z4wgn2a^H3ZC=Z)wn#AM?Z@lD;A}a;T?c-XW;!D!bIuV_Cr7R2t8C*%)_G zqndOwn7bN}B*8VYo3OEpDM7|3gob|Xl4N#{T684MpL3WqqW~9c)*5nHi-w=1k zZDM&F(-=;Uz}RqD*dl>WtB_FMHsEPS&Xbv^a~$y@-~e zkx5R?Tjqf2&6J9=)in;M$6g`OcW z*|TL{v$~@wEC8C`h>{~Fv>eXaqKtIR<-bACwmXkv`bUpTUCpggy`BJbYoKALDcOwg zp~jEx`GaY?n+om%>KX`&olFV%4=~`c$)nL3_CWWhCjqbaNqKJ!jF}tKl`j9{iO41@-)@mIdb9lM2KzL^!fS{%u zRSY|rx(+60R`e<&c2e4ja1fcEP`y+vdho`t@^m;!>%Gz~9IFgQFV~I7Ayfj9sG$L1 zM94|9hK5u2X?99S4usEI^0tURrQ}kPzfN2J6L=cO7eoGH|6Y<7Y%a9Qj$N@9_!vM% zNFS;ND;SCw++Pm2Te443NEqB|JCzNXZcPX2y+0n31K>RYjay}Th=zj9@M-5KdB#AO z17S9^$%KhsXpzjzc6DAm9aM6W%33^KwZyR|kqDE6GNVR#o01laez3Y`md+OiJt z3Gjx)B&Z*!`z@fkz;ugu>2in5$F3eiO|>Wp(_NJBISxIfOWHm~iToDSMZp1IX!Ujt zMiO>~Q5oO5?NPlHZe)3FaB>#DjTUAd_Ym_Lg7mmJ7##-c19}A+wMXmNp<`Ens%#(j zj>P$(ZW+PD;207Zl9v!sqL&8Mnc-lP zp<-law32DSP#tYFt=jyc6PQ3+I6~OsuHJ~<6!@tPvmmI#~EdX#~CF6 z&~BrN32|VKQkI=?Tnj0=q=iv$+X~WjA*tQRCO}XWa;hR-+O~EE{dHOp6B&<(E}+bD zz+z{a6kEJ{XxXAFyUh)Ynm+0+q!PSmoywTQA=fiDN`zNo{E;~%B8%h1)zjl#-*|R9 zc$!qP{VlDj?k}C%yxm-c5dn>YaNvw(BxgW=mvMRC{Ie6R1SAWI->6Jr3QHttA9=i9Q5_>S;B*j1CW`G)0Jqdq@iM?P?!ZbDZ%?1{)>c zImbv|0BDf&scfIOB6U9KZ05{JTormJY=^DZNS-978nM{pFE}C;9D;UlH~#?rR+Eg%Lgt^r;fcfMW}FGp-qh85EAl-G+J3$Znx6 zN$WBh>4*$^ORskFm;)LZ>$d{rC^#MWf#T>iqZr-B%AGIgH9;s z04puFJn9fFZiu&;B7q+&?h(sC>m_zQlG?;H<^x7jptgGo1IU2++zG8XV3IL4ISM07 zhE9ikk1W)|z@^C7vexN78w_QtZ-fjCdX@yT1}tssTOf-C1r5G~@yI>k=qS}dHQm5S zH2&%RL$KbsNG2vDfv7aX3tE22=e3N zS^Orl?IX}f8&xT}SrIFmae+xLltmtvmjC1U%hJPT#Nsg}Q_B;Bz)PpHl=J9u0|VDQQt&Fq10O`_)`kbl-H3 zAq!gS7tuU~(Lps@E>%c_9!j?=zO=g?yY4W6gV6_=#b9RwN=sG(p*8dBi**>DzNg72*RFi$Z`P~+!;#1R?3%@1@&i2W^hjpH(-Q;5wzSf{?=$SXJi@L zc2j0HkF`>9uc6a+$Qm4gD|6=pR3bN25t)Brf6 zsq);)(=#YlCul+Vd52WC4zm_H$ze0}5&+wt(HT(bl0)G2uxRkKn_| z9V+x`304$DD%}1lI%s!eAI^(>x`w-u0Dzb((e8Kl4Xszg4MZj9-T^~(5#$J)t`FguGE!h-ckI+mS4$i?dnCY8 z(FPD+v-nk35~kh*O@asU7HP$#It#pLG9fxcC%{yS1&h2@39O^-FU3fEnI}?*nben? z-tR!^vKS>cLcl?sc`AL>yp4~TBhX-gkY)s~*33+zdz-W?ePqDVP)GW)PY|SsWDgv+ zY)LY;AOO*YEW3vW%Rz=FbpNc?l!R44uW((Xt$U=rpM;RHR0wu}vIGQK<3Of{X~{CU zF#@$8k(eZ@nVF&uHJ2Kho<}Lh2Vhe&Wuw@7^y#poMuK02_QU#N%v*}2uCtGGDr3#H z=%iLlKS^9#OlH zbPd1l$jb?XtJo>QTpJLdg3^$i9A;BO0E43sdB$Bjxg?z8!Y9LbBnpa(kuJ)7)>j7j`y zW%(jPiu`Y=bZqz}`ljq%x)oTgQ=sJ`Y*Ly&D47bifJ=M&S2qut&fLYTA~e0!K3C z8QTF^9h@q8;nQ&rDE6V|0!K~=JE1PXR>SGCIQ9X?XD*9bU)k*n`YKp}psKu;+i*bu z{z;_YriyqU14bb#7&$=ME>p^?jMq}c&}p;a!$y0<3jNK@9e>9ia?iS2R46l9>WAmeA(?@V*( zsBo-AF*m^{6UN&18R}%yWJy&q&DKyHX+RGd)jU+W-@`sfkC5($S^S? zp$ST_Ci$2|M3++m35Vk}MxBZc?y5Yl49`IuNrr4F@offMR-LI?V} z$4_()SW_&T&4pJ?CYf^c1NSqK=43)W+n%MvFMy-TZfo-%TE`v1&oEiRH%9{uk2ryF z;wWYkrjaT607*c$zl2*yISe4~Rio4%o%OeQaB4HHzT4gHIEB84!HmNh^*`1KuU8 z%_>Sbj~29c>oyIgTcCCyR(gA|HY5U7su-hfk5)USETb#mIe9s9x)z{o!=q5i(jtIR z!}DIsne?lWt|d^LrI4hTFtJ{?ZcBioRs^LL9vW@n;aNC{xo%>xq>z!Ira9^7P{=^0 zlv3f{MFhnSR(_1^{D^KHT1k>fHtk1S)}SCl6^*<_Oz@eVqEQ(VH*rI!1Rb(?D(>1! z6-&@2v>)297vrqLu$A~bk}tD*)krH^{P;T%Ceo>>G*)SrP;9E zcOaoLoW*psTQwp}j2?HRVzEvL@bqsayJ+1*di0P$DV%I54ViYp6jWEkV;-zxK=sM~ zYdd;1nL+lv1uf~OpFoeeF_DFVxx;b23+T8(gi@^UA~dDbox>)D0%=YrL-)Fbxup9t z>ekGHP0nv`qIs9dJ~lwgY@8QQByGuTtBJx{40!4esF}t$DgjYb1~|IQ7WqIUy4kiC zhSXnZ7IE^Y``r$zPBu%9SkxwfK=HaTR=tBr0HcedpoQ2opfIvQ3MgX~IqVW8K1Z@; z1p&DaDJX7-ol_n(U>s&DR>Wn-e9-GnV@rc#1lpvt+)dnq>oS0(@T+q(taFhlG7H5P zobcaK706N99oy@j-G$(6@<_O=rAE(=`Fh)ywnY;qvhZY&53G$=aLrRNNYgb|?t$LB zygK87O*uCXLHAC_dUDsHVcXuqL$FeYaZT-Icb73Ml+MJo1~~(4L7zJkxe-r=%WHC% zt5$twxFiWeYG`{sNm>UnCmzOW0z8&MKmZULt2)CpJrrsBtaE#)KB#BFTA?PdWd zu?r%zp_7V|I3xBpBjm8~q48)yZN4Q9enL2o5o60sO9Zu05KRM1*bq&K6eNX>1c?DB z>Sg^5H~}=%9^_3rYt@p|ry#18#+8AGAXO0zB%L4Ilm)35yv#^HY*oy-m%|^Pc(=eZ za|8^khB{5#ArxtU{y1VjLw?K6CYG7m{8>cg0#YyvL zaLNjQ-o5V*>C<7Ok>$MZ+ro4Wm&2BKp9#(hTbmh|oteqBVT{S3tezXuA~H0|PT!UV zp`XhzrWt$1RmnB)Qp+O2-+9Pt$st zSaJ=R#GGYVC|o|r7+lPAYu`(_LSaIBYL*~H+)lub8IhRfU&Y2dKvu{8JY=OwLQCv& z_Kj^1E$I{FyqUQmKWL!>Ix8E(k7I7O8pJ6RxhWVW>(GFxs{jOr~tg#(V@HYv0S zq|}^qC|DAE>^{hhpY$vX6J*W_xjw}WImEGzs7TSIRhOeY6xkRw2@RrcLyfu-iV$j- zy1@so0{Afl99OiW(MXk4&Gbz;W#bHqBput07H@FzR{R`E*dX~ zfxFnDP(`v6HV~S}sjL#8fz-D%oHVk8EwWT`6(NRx2{EF2UPZGRBqMVP%iv&%(GF=5 z9!}EpA>%;?@q*58-CIIHR)ZxTIIv*6iuodIo0{&9?;$+mq+sL50bWdiLhUIB%m_t5hc*w#?#|8y503(S50us3~ z;sjt;Y3ti68pM@G4Ovor)}sI-hmsI3-wy@Y*q^dPVJPl9h*l$@cVQj+>rNkw9ANt)fe+4Fr$M3wPpn%<_ii9b5wv$6w^gBFvExS$ETJ`5;)gcMy3>_n97vTmVL{}EOwy$Gdwwzi}fsx2;R@oUup=-Zj$fl z(Nt1Pjyd%4lYPD^R8Wv#=S8|NP8z->!qKKcF_3!^%Sf}RPh5?|C&kgA${lFh5T3jd z#UFWxMMlv{LrFI8VOi2km!k$2*3LX&$Tl51B_!?R;HB>1cFz`#Kelg!8iovG_ET%& zn96Mgy=lR9oI$e`k*n3hk~pEVsP0GECe}Lb(AP_@zVT118eZ_^V}aA6VvH`>N-|)S zB8K5m2$$@7Aq(0QY#QTW)*S(J4y31VUiyi0KJ}ha*BCRE5?U3d8LWn6Fvw;e?TjN3 z+YtD=@+g|N%PJ!DuS_xvDiG}l8n34&qV=`xic7<^Xyop||X-J6j zv7wlLtPi8KlHusxXQ-E4Qpv1JIB#n%gc(%*Q#EOKk(&ET<#2{YNt{ zeB%EhbaKtnfobBd$hL=`Xy0HXu|<$Dty@4F)G&;*AtS}u&NL8vh}M?6l@k%m&~(2S zH?;sVhri(zIU}&U|x?pbGof^71y+DM#w%!17kf}4_HS&BWeJB`E zS(IYoz^eJgkd}Jepd{2UfH1-Zv&;{!U7W=A4AT?US6YiS`nqlWl9WlHry~yQ&IARy zI0aawZ$9A}X;9jpC)9I5UK50i>I>F`$BLO+Bo05EiwBx!V~Q-f=XL2&kL2t=nNG?! zsuC*Rta z#xbu9!n%7CxqWu%MFbS$7=R-19TBGlePg;Y@hh4Jyh^y09E|kTI*~n`T*<-q#!z{I zByE``CmTpnbISSyES^PF84Y3u2JrqcCXz^of;#zO3}Y#0icHUi+zF75h^=%Y`rKBq zB9#+^I3y#Ao=xIN+^5mDJA{NiXgRF79Gi;X#nePg7`|wKf)GC?=tY{4?xh9uxtvjz*^l&=8vl6N4YP4id0>;ZU72RF*}jh zcD7+tlW>ACBKP9P@}|Z2WZsJ<56Dl4Suyq%0pq7d+eUGj2o(eA=`*+++u*)Q(h^Lw z(@_CW&r|i)HoB0yy*#OtODRfA9xR;6yiFqTi-OrB(7qv&I9I5Dh9X6>5+)^~w7)^x z+g}Mp$QX^gqCks& z;@m?DI+-U)4kHq~N3SR_#R;L?o;Si_#02-0i(lmTh$~cV?Mk+AE6|M`=G`D%v!Mpy zB>>Mgmop%-Aw?cxZ)ht4AedyDBuQJs0MHvuMk-eaM?mWW0>KL~O9yf*9WQ8w1~Vv) zpzdHwmv;M=)tx{FZ$DhVJPJZxz!|HZ!Bn42`lt947CNHAKznEg!%7C9xTC2EHnu*1 z=P0}*UFyh%C==jK+6ad4Wd&NQG?@QZV&K{447P-uu|-qCkv8?foX2uDfEWS{D}3@uf8wAtKFfYk~)82YHcWs@|2d76R9NZ1bwS}`zsC~@*HLh)97;v@r7iSrTUqjk@sk7 z)eR&o>h5(EPr8?J649kq5xN|CrO=AA?u~P3yrRBG=y~7 zXyKP2BC1nQ9tw7iZyc2fZi;e*ue8EFsX%9n6kgIi7N5$fobteF5tKc;5?l$ooyn7$ zBB`MUW@jsR@}W{vXF$6%DMtB+w4#kr3h{m*&qzW$m87N48IKv@X0@OdHKjH%wu~Dt zHpuekz?g*TYZGD%xFJAM(_jH2f_25bCL0kf4HT#las^ejwO=ine7_>5yr-Cb~&f;{XO$KJ(Oz}EJzD6WNB2Atj; zHhpe-?5VB{^Yb>N-Ud)M?ehTY6;rEAQb1k&Ml{i7|PX!F$SWlEcya|4jrW>&Csnf0_jjC*6aKx8f) zxw}|o?6|5s;o&^pJp#fWt}=Ep#V$(->?~YWFprRQt8$34=_q4iPzDpJk`2&-MR~|k zoFSzh>E(33P7*|NElWzZrV@X^Ft9jKZ`-}FIuTv(2mGp2RUOlcksR-g6N@_+q<|pX zz-FAe-pe`wTc-Pw+zb+HmREq48JjpOYg^g49G>E7meeK9ktk-23dvN^Q2N$)*ey5l z&-pIRy5J1!NZ?^6j_uR54rSRK--8IN++$e$Kw0d^AX{m^){6S}n57_KY{`P`o@-@| zm{G$-Lf7L4W_K1U+EV)m#2ZF6%!)iHWb`T;qm-u2{)Ju}mbJ+hMw1bgkZhW}m`B4= zAx_lH)tjP9)Pjs~maQeGrMGI--Mtj6vB;6MnOy?%o%0wExsoa{TQEzf?Vt&Yq+Dwosv3bz{W2-#T zHy690ZuLC5^T@bBDmh+L0*z`q+#GriE(PWJ7N#~P6GITw)Tp(Mc!5pE!y7`RZYOsV zT>%)3{BOeRQmGPEJseMX7Q=|>0s(stEO8`gkF{GIh8Y(jn;%=+jq+PqG1(?N2$7`5jMKh5BaFx$!*=;WHS%{4z4Q+!$xw27Yacsm_ zJ2BD58YFDItnk9%dopbv-d4q97Ow6dssafBC5{6@-{$$I2>(n7Fbi>3qzzqTbYfBq zttI>+s_a9IuS8zHWHGAUqQ?3_vp4x@dKjcVK|>^UpVw+kszBLg%%LHJ1oO!7_9+F9 zkP!(6H(NA`>jKHegvs|9DkyRYM>#eN1Az?!=rOiVF#UEEZ)V`YT`Zph{)@O}&Egz> zg*A!Gmrdfr@0AumUIY>0kosiIBwaUipVnX1cs}F-;U|W-0-h0Li;t2y(6%Wm@_oHS zUNAUNHculuU63ZRiq~;lv<>LUx=no1wUzIY5je;*GhSQuG-;NJZCPAz@c%QJ zm?YF0_jK&G3c5W?u@N>lXi~D&GF3kXP;p?zL`5o`)uQ~h37E)UVDM*U?%QKoVoNmO2!_@e0fCh?fa!)Mu|asGk}6H6 zm&l7+vxKvFzcb~SGDjuCZO3##Pfc?0`P>GoL+Z+mW6w>IM&MG0-#|jFL6ybw4oj~9 zlF^}^>NwI$+SL;(d+>eGy5B0DY;H!+w1l?zPeGVhA#0QA5O||BlVw@7Xo&8tAN;xP zYz$Ijih2d9J^D7xM!8GS8%lLMI7{4yqNCXW7#fE53U;a6g@}Bf178jXK11SL$=71q z^^5KdD$C4gyUD?*oAsE0F#O)S|5AO&JnAscK=@c9I}6fsLG;$Eyiw<7SRqA>o`8sb zr%9XO5#%w_vWetm;Is=Vfln(SGI9V#4=r{3M5E6@9|!fs;wlDja+)^?#Mg^67~Lsh zdd$OO#t8y~<2(1=-Iy9=GIc-A*sSVzh@L|U?edI94jtUHx@~;WuE3d(HpR3MR8veN zOFxiau+O-9=8b%!Bx~{mI0L$b1%U~G^y4%RA*@y#;2l%u?PwraN2}0vz??&-PBx;F z5BMpfX}#CSrMN^y)8-9Cmb1wpW^T2|Dik?D+-jhftgI4>K=|bNv9hS4`~XW#R)Wr_ zIu1s2M@|C-!8Yh6iDA&3%iOLs@Och=YlZf3nojQs#x4S_CXO;8gkDa< zY~U*uHXs$bs~Ky?*$NgqttD%iZ>32M=T7Q^hIn@r^8hX(@?Y=sN3d+trrzB>#>fCa zp=S6vfy_!sD9U^1ltI6AmGD?)q+jT;A4T&Qv5BG@{^VHl~9Zf#`hf`bGi zT6C865-=af`mW+6O9p zIu75mN5@TEOG;DtSj`r2DxV;QFcs_n^iRM0-us_E{rKwH?c;x0f84!z|9kKKV|3XL zFu*k6;k=^barXgVvGILyJ0ufjlo>xh$u9=9iWUkh>&I(JRJHO!cOu=eUJ|c92>5cr zn9ToM-S4eQBCU?B-j;tZ$`g?IQ-h7^cd2?8csvD#z9YB&b95d!Nvdq*@2{Ln3Pqo$ zCd_gFHjKIdU+&(->(b*Y@BLRClQ0>zYaX0jS{}fV8-ndX!n(*b-zYVfxn*@5vo;;AMgG^pOJVtTa z=%_+gGjsjedDD_@F|?|dAo>rvf*KwVNL(q*()Xq;2GUM?q#$537N!oU8K(oP|uf^*pp%Mis z0sF_$`TFBf^Psk(wpxwI^>;Ez5|GIAeii^xbGmvGft(LW^|sc7CYRa%c)eKuaPR=x zm9HO#xdaP?Da85q^$8IWQTE7dr(5;bxj`5Uu_-ZYS{t5Sts>GePHt-pIUkZHEoh|9 zi0Rx4k=p_5BRYt4kt zAm?E!XAuyBL()MZ@ME4IU+ZEBEKFj_=kwX6FvQRnIAffZs`MrGJJ|S*^C1c70lKCn zS^szad1wd|(1{M!o&#N3%u2enS7*t|VFOG}q`SUL5V=a%h2+xJV zF^6d%sf|F@wq4=3HMduAw==Rz=gEQ7&PK!5kra=?9DLZh%$!1b>Pkd8pB&Z$Uo^`G z+U_+wYS5&Lx@canE(%2qXAp|HH>XezLDUCELgxsAvj=&OamV$KJIY(ZyUf_bwFZOA z1uz5b!BubPT@W5)L!cS?4JHbG48=P1BfmCltD zfnwjjXvA~BXubDjB^wctQ;9^(eb3RsdgId4x7Tx~@&J(|K)~MWWlo()K&S?<*iD49 zKO(`{nnw1L>sqhU4U#g&|3TzC&j!X+mkvK2?(WxG zX2`s;s!ID=IgLxKy%Kjrpbc}1V6sn>W#8x;e=!}I1lO$)(&tgN3gZq z6wJe6#f&i7F@PY4N`5+cp?%Tyt%;6eUcyk!isGu?Si|geePTKs3JP$> z8f+eWsnfJzj1F2Z!O{Ndxdrl4c@Z1ku0LIv!=o-^uwa#6Q_iVkXcgCF_qpQQFv#y) zr~9e3aO?-zfd~}Nn!~+7jv__G!PAClDk54T$LcwIJCK^<=&PNXtI`P?|LS}^*92}o z8-n7PQgxSn$d3WfJ%DK2GHF~jf$wXZJomYD#kw;Fisx*|(lFxjiJdJQjcwFP86Pz& zIK+-MCWOog~{#?iXR`l;+Af#?JuCNQBOF5j6kCeh@2t8z?U-nWYlDd9LuF zv{R#i(#!4?My3_yIugutU1TKJXmr7PFXj3ez$PH;PE5YxPE-g^dG0Y`KdXr`If*(v zW*FC;JA&l5$&tIoQwzzE8@z-0j-70r#Y%KQsS={?j$I-@cDHbf!Gh%uxf)`w_GXon zz9MZcfS5gziTXR?2nUwmt=Gkn6S+8?(-KZiA!8koFNoY>+M*d6L?Y8f4}aQyS?P~$ zmT9LrnF@8gaRQm1pAH<=oK?htSWAb~Xi`~%{gZ1MyYAY|n-*$^51NOzZ9+matP0-` zY(N!0KhH5sSI_rpv|>5A!vMY$O(RC@*Y}^m#3=IDUE$aCYw>_7H4LXNCBM~)K*x|Y z;Kd|Y%Hg*n8-#J5vZRR)%MN-EW;W-#BU)}U)GfBVCz$n38I80|2b4kT&(lO}iRW6p zJ_ZRgpU!MF0r5O#s#1t-sWO*73mb%?%jKh-jk7lGo8kWZ`hDuaUS%(akPxCyw%X*s!n%%eKc8BQ>Wo8vvfKyC-ye;f)F%%(nB> zolxOOA#6`Ey3(-&h1Xm0=Z8{S6p&oyspTrnjW&dWC=WBg)2*OHgfxruE?xWCpp<0~ zuMXzx`Q34~bcAaah1<7cH5TXu@!~9%c-9Vc3M@__t|!+!X4W3@Ck#QYJrG%eRAo`Y z&KI5v{39+;x87=REEFqhfOMV{06=1VP~-vKRJU9y7$wwW!#H1Qtpt6Uj`@e(Lg)xQ z&IlP@iGddaTrH6et`$swqBbF@^hkif&78OFNH%;3(!h;$wR0uAxB$`M!QCf?tU#5x->QI7mp&ZA+Q z3KcnU(`J~Qg#^=@wAjIAH&l!dA(l{6W|$QLltBu-pSHpVa3O=Z^_2l}uQzYoTZB%M zTX)RXN#yPBOb_au=#T)N42WYW3-JbY_Z)QM%u2yJ6#W&g@{;=jMPnJsRXZrX zfX?jTLCH+5$U(NG{9HJvOv0NX|;c2o-~k!5C3=8T6jj1#|!tg}6kGhR;`$pqkAD zKEYZ4(xcgJG)T(QdlW78^WJY6YI!m!pJzk;=|Q{l{d#uSCE3$zC6KKlMs=iyN^WrV<^dOkP(!yrHro9)SZjeS1j*X;0rE{Rwt2zSnhR{%uf#Gtpf5{EMfw1xq1ns$QW=_4ar z4Sk0@l%*Ra&5!6AdpL@s0EsS!wtKdX3Q=;9_X0p;qip26@>-Rbj;#X;gp^DWqE&NH z@o86e02!7?*MR_~Q9?PGV`v};TSy+S=TdHzboFqE$80;n22?S!Pz_v>M2aY?8P3!L z^_M3q52}K>G%BDzRfs?5idW|tCK}DQR!^$4Ga$GmL9Tx*Gq5RRT2t>K|FcdN1B6wt3 ziwr{%>q{I@jXZ#u4ipxnmuA}|DGQstiD%XMg&YqgFA}r{%jFzDX*UoOjWh!$;BG2= zcSJv=h|;)FapN%NX01T_YVetHCs&pP9qk}yH1l?=cLEh7#Zu3(1Bxx94z@|e_lH_C z6oJl7T02W}r537ZQSQbxv=};KP0lvcLd?pik!jW%#>qb(L!_liJG8U(X*;xs!volu_)X zGrEDb8|3?Bj1a&I#7Asw{<>fTDxZ7KGOKwo&I+>ED7L#xPY>a?C@R?=y#Hhk5P3c{L+R*R^H{QlrH}897&ymdLc-Tpe zwu&msNDOCi2ipT}5GBq|I*cVlEfFv}gLi7}_}UAS+?RoHiXl+zl1!?7*|b%AFtRGo zEqh*%ogJNV^Y&0|F?czDo^kk>S1X1Q_Txp)$Le&g=v8_SjC_cTR^2z*jV2k9y#vcA zL_NzM<2>vfKH6V!H4&fuTpC>U$nKsfBc|OV6a{E9Q0y7lwku{Hp{z))^OW+#GTFfG z(SQ&Le()Y8YB7=r<98{K>13+`!0fO|9Cj+=Vwa#dB}geT!cI>59q9YibNxUGY%yec zCN=fFR4ZD@MT8Ho9@sW~o=<|9$h%;nSRsz-YQobo*i-|SbVsnyC2&;;Gj{A!C{0VZ z=_WNeuGE=qv*5ZGj2E)ZUWuJCI)HLwhP+IpUCXIv6BvQeK|{gu?UA_va%Tqy1w|2W z^sm^WZ&;zTyVJzmZi3E+DGdaYnjNcaNW83_YPgSrcq|i_F^pLUuE(AaGEVTXl}52@1O{X}%2-;i-n$yO~l8DqinX5MyopCB`1_1x9i zH8ATKnKBFS;EDW;B%gt-h* zy%Cit{ff9RaTs+6b^tU|VxwT8S_8b3+qwA7U3hHP2B-n(h4HS*z9bt7wxd z2=e{TCAAwh!nSbSfvHxFPti~?gb2-^1pGSb=r~yqM~>yuWW5j(IoEMM4o(C~s?(%o zbVK2=E2~=F#ceiVu8iD@@Wp9!pFk}zWw6CwI!dK6ReGJxjeY??OZ9xo`d8YHB57vx(4L0G ztGsEzAm{r-Ym~1E4|6eSc~X{iC}sHRoI9O>FvP2;o;#N@0o&t+g`s@0CtI23$V&K9 z&C0N#=q99$Jc8Xy*DN2@+2k)>94msHFC+=@i0nk?O{N%c$Y0OLl$kiGreNT%y{O9h zP_!$mH4ktmA&`vo9QiYn?keZ5C>n?@;I4c@GCF|aTwx$iA*-PoS2!dIg&A9o5#2SO zx}!wZHk+qFgoU&Uidcs|tPrjk{S#8UmaCm{@;k>(3`xlOW731xB_aN-?`Fj1Y!5Y{ z>+hK>9rPVa9Ar_24l8!cAcytft&2fTW&jm87ELD>+0ZwwER*viPI-6$UxD5rW;m7h zJp33q%8^Zlgl6pcs}Zo^xgw`gcJtZ%8l?#F?B&xmIzKs{WsucCIM$ICc1d8N)c^R;*KMSHHtWK{Cs5x*M~DO8aJ&YI-%de$Ia?DW*VTkOa|U*Z4{{nh!!2{ zp5@^Lp{!E(ovTRC#TifFh)talmIiFs;U-2$7_M??hBIt$&6Otmmr=^viHswtP$En? znB*DiK{8AvhDs^)jvhf>#mE`sL?b0hn6vg7m6v1KLxw*|gXTUW9q6QeaokGwtp!mW=MP7Ek>e!ij zxQg_79KmyOE)a z$v6}_hY%1wCp#o$FrnI%zhWM??gZjOn~gGWA_5dc>R`JuA=$mkQYD6AkSqEHW!V)x z`rR;?Au+K=Q0fe7iVCuadOowE&Vcb#(OC>I&vnVfibAQa%D=V2^u+&9HajvNa^$AY z-$UNCEzs^)QEc@N16{OUa;&kszbg$#g3@_-_)YXM!WI>S=$<(8ByqfnqEs20ff$ zj5deJn;B5o$j>Su_F|aKxYc!Qx-;=id(>^mA8JSSz<3pgbLWz46$?~zC!>LC|6l@a5*JS+NNx<|vbRlrq zD38tpTQ^27M%L< z(sd8##DNc1tRhmZ^x>@szMN?2PH)6qj}!^41~A?{101+%lqJaD4>P>T9ssa!^;2=0 z08XelDfPfY##(7=GL`8I8^wO9_HjN+sKYi-?0RV$Gaj(bk-*8~<*^Hjuj`k zAd-y{un@A*d}8Q7Y&wmx(%i8|-BuAi3=kK27mwsx4$QN5I_&DOtc04fE|Cn5THiov z#MYSEOE+wjSdwWFR44+`w0=0t()Yq`nx~$VWg0_kM#DBZO9XmXkto^k&+TZVZYVu6 zrL>M3Zyu@*PMv1#V-ZV;-D0E|t)0Q6NmPk6P1Z}pA}?>8(wr`5OQGQvlC*L@c$GOL z%KmT$&QTx~r$G%7P^wZSk(OyCM5fgWs8|aAYzCjSA!ZHnz++Yu2_eWPcT=`H zw$Wu-6GV;++vg@(kAV8H=BJwkGRH+)2a_#ZBN92BGuA|@dtjK|Q>ZPE@3o0Ja!ZjYd$#4Ie&vluh+;ERrJrAx-yLj*T4_c~4`X zI#`yDG{XN-;V@9LRf<(@o67P^HkE*HAb=AWxo+*)&_tcX1R`U%DHd?py0t@Q6z6_0 z>55f!H#Q~HH3KB`gaKAhhX9C00(23g6_@x5>1h{TKNNQ*$IhmQRc|E#MIw`5giF`G zx@%N{d&L0SDQ@=#S6GkBlq;ltQyk*)^X~f^pQXp#oEidSwposNf_4mgN#1Uiy9%05 z7(8Pvhv`ciM}dI~T5)|mYvu5zARu6pjI?mohfcUieiB06RJv7r1i#^^j2v~?Hoy+s zAPi!0YZx4!h;Vknk=#|}coESa2PP||0?}~LZUr3~xHWM-z_CoC)U$X2%AvONtB198 z@~nkNE=NU!6_J9-sf!3(!&in3xH5{X?7k3P&QwCxNW<#U0+}t!6A0%sYrG+~MFjw8 zYH9o;^2!m3V`pKVUKI;rGMq2diK$VW?7KOW@u)D{S(QCUTQ*unSFoPg*p1$26hlTj zf#p$OsTpYBM8d;)Vs%o%%?)#6=LRBl^WD~DaDyu&OnPhGyB22RkPdccMjR-43(V~d zl}$>BfnbG?uJwNdepixZ1v;eW`eiwItxw97%}p&}*5ITHL(KLraCl6!C$WQ0M@?2Y zs!GPER!+sBpjtFUr`YG@rMjf$VgzK+kOZp8Z?at>y@FG!8X-61E53n|)q)sz z4TlD*CJe%j3;@HAC@qp>TDQg{VHdKw)~U1lE+#$Fu*NqGq1Fs^W|W7n8iZpBV! zfqFs4Z&?#(r?~_0X;NNyr{ch!jLuxsq~XS!6Y~KXEIG9^rnU&>2nH<46W~FIJGT1k zb~-4a!%~o@{=hQpr?G1UArl%n>Q(arxlxwObB8LK6Ggnn>Q+yY<71P1&`60q4F`ie zM*oN?v%^9tY|sRv$DRf?W8GgkZ7HBja`q(3bH|1%nR-^nRWq4R}7-a=I2pgT6G z05V#Gq<}uqmLz~8r6?oMXw(x_HS7qeK9jR1ShtAUw<=;XItYmgkov1`fFOGcb{-6{ zh_SF4A%ugBi*K23%E30F>A4KYjqXDSDe7-gBC@gAhB6D0uJvg}H|t2uwza`jL&b$5 zKu z8nAx_z!hj!**bf&aP?s5NH=s9vWzB;@TzPVqpe-{5L3WB>y>DjIjz|DNXK*YA|(wm zNaFn+xd|#dR=j9oZN!b}W^F&5L+gs#lRA~qLA9KY-2~fA5kL*esI}@Sv{VsOl$W(R zo2H(X!rUS&5H&hAd}QRH*t65^fUeTylHEq7Z}3&5|HmkD$oa{@3P$TeF8S=|)*W{e zI><4>|Ho!bXli8=NGh_mwo^jSsl22<4Xwg*jCpDpGF)^zX@HEOgfY7a3GA*a)+rcg zTsFQ_xHXP>4o<*(6vRf{Iya)n*%+y~@{AoXfX5ZvBSJ-u3k6Gs>4?N2*-@e3p(;CL zIFk~_1?Y$6jOjU2kY?7ELI_j6qetxGKLjFRi{U{qD&<^2s)G}kQ7xvN4lL6oN=scaWU*<_uW_oJIg9 zdyW|afM#TkgzVeW!-5C88Ssa~E>Zy5LV|XV1A7#A(Ya!gRwg-nFV$+a1Z!TcCU%-`@`P3V|W!86@TwsN`lqGlk ztBSx-zH~Fsfn*%zA4SFJL$emFxA*hZ?t3((mASolzBC?KGnG6x|&aQH@RmOMwna2WfG1GbTV_ zYSHzeImwayT}f=&6@&9KDl*ZUXepC{i!=b5kVID+6O&5V+WJPYU<}dFe#e-Wqs)}q zPe(}c^D8FC8^N{O4Y40Nt?Se>BpH7Vm^k1) zDH}zc(?vS3Dw?}tI#>}yQ5q818)t+e9fm`=_!*8WHU_&jh@@FtLV6Tu6&6dfbDRWK zxs^h&v;&B!hA3hPA_6;sVPoNGJ(aOwsCH(4g<7}GV_rwE#-;(Y3CzPtbJ$}iMZgt% zz8dsHdH#T4T4#Xxikbze_Ud#X2TvlM1M=FG?X<@1PboxQ?o!Tjj)4>&$*lBq{NVYU z3HHjclHU>7f*$Y@`)p1G2!f0Y0S~pAM3H999U}a$Sl@`&lW&SR%~|#!46Q^5VW#bD z69u@72EO)4_h^84aPT2RS!Zr`goiGJQ;Y?IYuC~8^Ht$g8`*;}3Mh=6h@pFRmR#uj z&c`vc1pHT!4dCQyk`>}u0DkD3oe;cY4w~SZp|=>eG;n@ma5$jfCdaOjZSd*W{Z+0Ze2h|?h} z5VL5TIL!*1Ae=l#K{=V(4xd<4Xj+nD zeHvCf`0N+@u4t8Fos!kzWCoA2O!ze7VTS-p!Jyai| z`GziI2u~NKwkZw!JWMe1qnu)S{DMJ|D1IpLp=kR3p>E6hp-Ap&0w{X*?RZ&@?Z1u( zY&-$NiJx;ES;gtnt-GNF3mVURh5JOCRYlqB1FHc*+bAFL98Dz3i8RbQ06sW_9T~9L z&4Yx3(Ic|_Y@%6k=dRFz=u~mI@R8qvt5{Q{H>Y_4PE=EsLwfr!{kb+-dUz(#G3+&Y-|8itjT;b|;(zM~r+a#E8o zuJN;BsLL6p3PdwDD`2WqeKoB{vR_=DJ0`UL|>W`y1=ywVsBR8$~f(ymwGnkX&7X7@XY%OBw@l)=f$r zVL@4*PL$XrN8=Y)=RU`Uf$0cb(arH288IUws+3^QUm>1crq=N2vNM~-zg_k)&VH|M zA+5sSvMc|Kh6{KZ%H-2NJ40Wh^q6KD4Gv55B4a|aa!&b=%_jOfv;j7^hmKQmkp>lW zDjXipjxt%nzR7uvqu-U$$8oNxKkbAYMxzHW3NzsY$OgU3NZW>!8`aRc=lAE~R2CCj6 z<4UtGsj^bCr-L>`YePqZ1lcs`NxBGZD^qvg1k7gfTEtcK{IHVvn!rX%#d8_6yt0!( zjqXL0l?tybI4+yvF3FoDO(}8ZObMI#ces44Ya0#7ge0z30Y#$|YBJ}cRaD5xaP5nx6 z_p%n^P9a{TIUH3BOB4?Di0x{1!GG&#(Wp{O$_ECkpL47mIOeMJr zYHwDjt83=mv95zQe~H4K)`l4Si-oY~oZo$I|MPfTQFFm0O^Y-e@{SZ^bo zf{WS*4Qx^dqQz5WO%{SYS4TB|3~`pqClqXD^KqOrJN)$XT;7-i! zngDc0n1>;S>uwN&XTMZsA(|TufFE)&yxi@|kn*W8pml{?%1*OJtWv?Nw z193`f!Jk18j>*vg@wNJL!oh}%7%leA={1sESJd;Azl{?hPX*eLw*|q^ z98<`q!F7Pn(fB$b~wrMl~FV_9=h?4cuvX_Mut5*n*bn8+8G8% z)oC*@w^~QI)cp3Kv-Yu1!Ya@<%`%h%g`JF&ZES8Kxf6%as{Ut`?wU;pL$^E))PW z8z6I==^V|mt|w5DWH2-H#TA5U84=2(%;Toda0LGhnF{RFy9 zCzEZ2%)`p62Y(*Kjsr8I9Z7poGB+d@?Ndh%jA|~bTQoFkuc7?4d z1(m1{_9S7Fywlx#vbLV$7^^1@=CdcVZaN`2&9IFplLZb>pem2W@qdOGLs0=M1`Z?z zp5f5eoxV+39oTWR&sdk+)}Ba}BuVe}@tW`fVK6h+3wz+C6_c6I1t_GLj=I_dX&yWK zI%FJB0Y4fR3eK#fIw1*qFsg$Q&9x(I(^TAb(!*;$By?uTHs#7=U#Tgdb#rhH?i!Tk zs}R7fE4u>|?VGwj0*VZaz-bNlTzn>qxs@r4N)do!7r8_NOcfDIV7zGr;wpb4aoDB{ zfr3TGGsaO>oZ~yAtVBL;hLL7AV;IOV6p(Seaa@ad>{PgHrB)jxJafAGb7v|HqZp-d z5kO)FK81=)mIwGSI4KQfrcC{Wte(;f8WkbBeAW{vQJ)B=i|X8ro{ImjnE0^5trdqz zhAxDWWOgopl&X+onL@>Ga{~Gucr%%1Ia=k9RTCK>(m7i@t90A4ZS@>m6Aw zY+xLcnE+-5aG4Xg^kX0?IM7>xMIFh2uCWqqX0_Hbep%8wW~VJ79zzqierU+39}t~E z!U8!r@BOeN=J}r0oFWqz{sbQa5=VnrqvCM_V$__RKU!l#i}&A@f3)>%)U8Z-;A0mD zkt>msSVHEr)v>nW!}c+~ewadetxfO4kidup~?VqF7Dm2pYrKL~d$T=jP7DNd6n}G%U~yV1k2`exy4- z7zI`)$q22xwMVoZ1Re7QhTloIK(z~co<1drNeB*eIP6-{%!&ryfKOtY(=PcxtYPd4 z%^71TRY-PM#ZmF-neZ4Py|B9^2)I!=+z#HQ*xM6!LUSJE%x#qb2o)ZTY^T9$TRMLZ z&H!DhxZ@E^7)G#=54%m!0LG$BuZyBNkA9WRRAl48#QRWo7Tj6-9r#8Vfp!CpG%=jo zSKZc$7PL|;BTh3$)atPKD=ysOJ{JfQe9@>>&uPcuz{9r4jj&oPE}SAYDfp5c5{(L3 z<(>`7R*EaQ(dytyE&FnVjev#<$bTzmjMQM?XTsFP?TC)F17(GH;MSS}TX>*g{l};$ z7Y>}Hqks6 zNC~G>lkj751oJBjGBJ2Ni*Zhj6zoVNUnoP0TuGi< z1G36+Yb%L=21C)A^+IWdCO>86Zod;Hyi}FJV;o8>mKqS6jA@A?DSgB%0H3&#wuoJ@ z)A_9?z*K z>qFwaLiK3p6>(GIUsb#>j8Me@fDcVPwhQiIOF+*|Y>Pqlb&r{d_Lzp$!Wjl^Bk5st zL=3^50GJ4Fq57F`2pNSucRg2QWWdoz)dQ2ZJLpwT5GWF-MJPc_gO~|4F$~v7!r5h{ zK2m=t{h=sNEja_aA}FrzFk)##eGldZA9r(%Dxh&_Vb0d9QXM>vxtADL@x!$8~^IRfN* zaZ=%`pcbT^Seg!#9;(d7-@ICs3Tz&P*M$#gyBvgNW;ilyPevt*7+S~{ZUz_U0aQ_^ zvS&)RDK49sqM5+yjd`ZIF2X)+V3JX$8gm3vRT=!55fZ*F6$LB!VRV=sMp%SkHnT(~ zQDZbkrY0yI_JHDZ&7_OuzU$&2%HAoPCq29*Sq*uCb3y}Q15(7@h*Xu3WGffnOuB3^ z^yCm;CMNiW<2)Cf{v0*~@=X*=PRfKSq)J(I68zO@*nR2bv<1>S~o?-DHiJ95NF@QS12gDJedU ztr3XZ;oGPKF~Tfo)HgH$DnX(*3-uW|l+$1i<&qfK#CnC~wzj)qq$uQCL3Yg;TOA{q zPH&gxvCy%sFv|ms>M1%U4X)?0Wg>{YNx1|k!UU!-gc>w>&1VCkv6|c8#oFN2jtQ~C zz#+RS_)@=qR=LHcMeIBrVK4z# z0b4iuBslLWTCXk_Mg#1a;((^0(THXR5tR-m+TAGTdDG#b(SPrb_3V};#5bW~shQ8l z5Vx4wtIT>FoU*P6rWw-vIy*hcQ*x$cJ2p6+Ob;qZoc)3cm}MFVgrk5-Bd8GoL%1yP zu|sV|^h3-!5Pn$ioD~v>4!mOl?qcuY%~m~7<})=2&lUlI(#s4zrIn@BpJ|#Gim(fa zFEoxOCZ}Lwu0#VEU0!aIs|WWw9sC%{pZEyd5i>ez-=tEO05eBt0zj6l+V$iyf~moB z+=AX|c^V*!F!P3j4$kh%Cyw$wA_DAe{}`SjXhI;_jXaWlgMd#vNq`M~ptQ&|&IZqn` zX{ULP1=A;#nMhY{2ShCfHM|>kzP38NdlB(O?*k~&B+NI|mv-RF9y9KEh7+%W7ZBfK z=t(IKd{j;_dty_l(0La&oI>()na=QP)d*O@@O%!(X&}-N(K7ZL7LK$kks@2`EH$Oe zhM^*?N)ep~1WM^5|0T9q*Q_4>vf$HJo~hV#>)2CR8561Kd7|vy4)q zFX=1Js*C(yMd@^ZRPd;g`GuCoBp_=A(u&|T6`hbo2}c;NC>6!Mdl1_KP0#R>5e7hb z7cujA$1Z>pX~^Bx**0gL60n4eA7T=Np>&h%boo4F7&LJatO%2rG4{0ueX8*ffRBj( z1DBGJ)D93J2{J%6E9;JShUkd7ER?BJ`-0cVaook>HH6(A18POecH<(gBa9p_w71<5 zF1ZCl@I@dPqOBngIGm7!w{&GfF_G3{zh;4?^wad~a3SVMmMe~t#RN&Wa0BRa853jf z51jd9P}9~Oz|x(ADcbG{`)xw@KzkOQjE2ZGM&8t%*NZZrNLt37;N6U|BYScVIV>B_ zDwi8Ne)kP!j~P)6sx^oM8t__c$|l4#gQ(zUlT$S3h3*6nXtQrB%_JUcz%#u$Lzh6Z z3pE~`YHJ<|Lu5vXt|?lS*pxdbXU;sra~eXR$+}Dbl&>Q^ELSkYuG*d)F@fg+8Z)7B z&Nr2ns{cUz=ZI`VQq*V3AoMB2SM)z77}ZkE3N*&IG8j zQ77Oz&taNJF;lRmpVzzEAzKOjMcr6jf*YzBnrNs##smftVCoGy%Q#`+4T{X#)V|>y zLFOJ#(+k=Ci#&BOVt9t5<2`|XM4A96nXmg46@6&t>K)hD)}x5UJY88#hrc!g4!i24 zkR)M%#4=ckP8$aQ7ySqVIqQs)jHJO}3!)sCkT{Sqav+UnD>5snn8nmBoxaEoBqI-- zdKAtEmc;E>=RM3DOAsH5LNs5dtyyi4y6UacvAVS=y0_uOnB9b!v`ebTsh|aOnO3YU zj8}yy8KsddKJxwGn)<#NxRx!my<p)CZipEPX=*<;CEq#}xS7I(EV+-42)P_*C@|~O) z9;Ax3FVCU)Dl>OSv5oSBv%nJM*AfwXn^8{~Ms7zs94Pd0?m142RhU-Q8%3rL&QKcP zX_<@_7gmRrprQ>W7|ISYmD(+W7#eGbP;R2w$~$KgYe(BUSKZlx0)P@^kH1iN;|zH5 z9FIjUtdqcPXD4t$<2{kzk_dMRMlGc+L^6!}SSh&4cn0eN#w}X!wy}{&GX(xX-OJ7g zd@4-`1Da{`4CSoj;tNl(`LzgLiLwqsrZcbuM!_oxI{@Q-0J%t{gZ*x@dl5y<0|+2) z$inpK!uOCB0N5C+rR_;JslM6yTcQ(^q_k>`VNV_c+a_OqdTpP*R{|G3EAGh?*B~3B z63EUW1XpjmpKXj}$Oal#oJ?p2hSLpBuq6a4QwONkTExYv@yc>Fx9A&i9kv3D+gxkU z(+>ZMO0yIfzD9}|M0j+i)|gPoa|fE-q^tws3n^D{4CiXm5KbV6B*V&Rx6~dY%+bbV z7bUhHb{@<)9Nt~XO$hQR9j2UQCN2$*xGpZ82u8rHpaIUElu%A0jvyA)W=I7!d_Xx% z7EyJPwGDV)X&DTD46FnQfFHL(RlDf=?2Iw`bi8z~>{J{F`?w9z$rgc2%?+^-ILSi* zVCJSl+SUel2&@UYutA>K3>KnudK|WXag&w^yHO63YqmtX7xD^^-U6MBN{Y{Aw4$I`hpNYnRzerh$(FX6@i7g z$VD7vOR{ArexEyuS{`98q`Fcd!}0-XtWF<^G==$5pB4m2r3vj?Eq-&RwsQ24T`tQy zTGC9B)>sAw!2(klG0=*k;X9`U5R695Y?6l?N6-aw5LP^xhI31TEwXAV5`eq0Ng0J8 zkc6XEs_SRKe3ZT(&Y?eXWTxn2@)vfkIXBusfyH1cjBJ`rf*Xn~E}`F4vs{2FHDoh} zBU5#bdU8S{fNq_9Mz9lhl*nV&326HOR1K7wY=qAC2Z|vhHH?33HPRJEELN1a;}lJb zXqdX#gJBZiGvT}H5-f)hMdDSEPM?yG_DoLF z6I9D>B2{s7$WMYf!wapI(IOtr4*H7nXc6l>o>OgCLsZ}}xUQXM{++8+88X}rB#Tq#SYnh;s3ycGZt);4= zbwUl~Q8&ombRqLA$;7XMZSm00HzK5HRq;Vr3{xh%G!S z4GWFaFL}}gK4?;UKjQU zt|dEDB#7R*VoGj>%e^^yA^;4^|0O`j6~0O$Z}-8X3aZ?9nsor zD0C6XofVJU4~^sMc!KvPLUut}2(vUe9wfCp)&@Y@PN}4+KKf^tlu_{&d;@BSjH_g4 zO+%r)o{)^;olzB6E7H||;`Y^DCMb3cjNgIbouLXtEJk@FyX}02?YA1Bces&PCX6=H z0}cbH*=%!^@z}wGm0N($MO2nqo$vSBA(UQ1=+1BqnZjI_K!Xshg04x)j))qR`)KA0 zLHXOATzVJhf;cpC@Kv?RHnO1@#xW5srEBRRfhjZuZJ^z|)ts%1&fqM||J-T_9P!(R zS&FG!p>aT)B?4kbJ1;YjDoCCHO6wPh{$evEOD>(YgpvYec0I1MTQD5OIJ_TbC3JEo ze<%3|2wkHXL$iyRD%b@udl(L)C0ti3*%_?5CL%KdJ2#n58midzcrd?-+3EjfUxIS7 zIWPz@(hdzSba%rKvLP`sQEx7%5MMPGs0{y`lZHsKtaL;3qky!Hoe24|!Jph<*F#ib zCX6l>0FE~PJKf1KFuy0!3<+Lz(b1KdEQ*GrT(52#dNw_p5%_Qpnu#RB!xO-a$WBle zdx05(S^Rhh>X!R8e%^J6|7g6ZA|A71(HTHzXZ^fRE`a&$l{u^4kdg^S;IQtQvl}Fu zeRQ}V2QXEjp62Y%qMIZf%6BlSXk;2tv~?+fI=%T=!b<{I_NaSifFS@db`6uw#(CuK zkXgy1PSHP6$xNBRqcP6;THZme%EHaXzqV&eZn0oaAnk^)k_t=Wn-$eb4QNs5EFqcZ zP>Nep0O=>!wz@L`_6nsMl-X(1N~&kMr$lf-JLnh#l7&MUm}9BJyQ5Bg;GXS6?qBqEa6B|D=Tyg)8TCV(xnn@BKG0xkpiv`PiDKA5KvHQhuN zhEX3<7^7UVvjSTcs6&JgCuhM=uUfIan@R+**%VADlBS`=+1X8Z5}7})?^+FwVty!z z54LwP`Lyh+PopgIX5@25hYjo*%B>r6B^eDvFfHEFo0^?G?%@h5D6(kip-02dJ>xb$ z@Su`K@RJWsKn`gLjsPT#f&k3NQG$4f5rQ$()&=7Q_)fahLNR6&w*~&!@Nt3#2_C}c zFCB|ce2#Q$(KpQNc#bj3RB!>)Qa>j}qlJwWMwiatp<>AgMwNU3QY6?{%^78o89OC~ zSZIi`-m$vjA0EEC*UgxDgZcZaeiRmd9#qJ;5=H}S*Z`TUH1U^JZ|K@8-X+SZzS!-D z@`c?ZSG~;W=Kj#P6gSw1BUEBxF_@h|5RFc|uk<4xnC8GQj5W!Ri^N>n6C;i_hO~h- zXE014`60{nc~$sU8M3J%j)pH_`gJDIw9uOToj;fLi^dm#;4!-+5e`>?o>GDZHz}bk zfu2!l8VIvP*CdK`?y~FT1)-%Hh{1hwkU0Ud`}{Du5qxQE3JygkvfxN7$H)U3ySw>d zh!q-e3(hl5x(00934TcSJr&JS2sA2Ah?(RF2A6}p1CuF3(q?GXchrR4{|1hUR~9_{ z;;ggbwUhU(pm%HHWEp_5YL3=_ax2i01u8BZ?yj{??6pP+*C6P%UefP!(^p*y4%gR^!h ztlX|+G0b_}($GyMm@5v4gR9HEq5!ni`XFa-v;(kWY%nDnFEcWZ>T*&v*Yc|97yG#O zw{sWX$~1t6ffu5ic7V}W!IPob#EuY*ri|lEev7jPt7zvzt4cGGIol__Atuufzi%wh zb?(au;#lOdmlRqWj;j(4cJ6S-xd#0CaMJ53Je&Dxo34!HAjI35MPoQJtpx!Ej5iE- zW4aAPDJ#y(a2T9>yG%?lXtJe2c|D9>U_rz+GgIW~~It z2LgbJq%3Z{2TWN=AC>cqxhIgLOgovIId(vEn5xULGc3)uoQD%iMD zpSjL1sTUz9JDU_1;*l)tmDgp|&?ps314`;+I+77gmsN1^#9M0EXgE>T_3>NlF2D&@ zECCBtoYUGVbSObC*e#6GoyRU#A(Ckktm-^*IP?pHU%D!1oQ7g{pq{3NubyTIdlB+n za`%sedHbbsDmwZ$*M^Oge>>ansi2irG*^>AGHId!fjAw}g4&fN&Qp+V3pqO{VCkd@ z>tIZ0@--ME8M;-gW@26`A;mFzaM>Ihxfih$j5W+!9r{D^6gE#fwXAF*BTKw{$~PYqPEU>m34GI9?Wu%2 zcF+-w*+KgRj1x%Rt#-)EXmAEWrarY8BRNLAVN<#7kO$f33z7C|W;YeB!-O!ylV%Mm zLAhvfFhT0Wp*BtSb5Y*%)h$U_Q_S9>OGe|d$^1*UW>oHm+jPw8s6;v%6&v%d1*~uu z25;#EI5jvQ_}ozmmFRFy!S#@Hlah&?g&@)INv671>O#srMsO@IN z2RofX60!=zs32NVGC_>%#!fFdGNERKQ|HuUY|KJl;jvht8(d4#d*!}_ z;$&a+$MCy5u_{Iq8hmWVMY-5v6XoNMr6BIX&UQkPGK3OSe%eSMlM2Ol6JlldyI80S z`$B(idl(*23aMn8ZUPqx3MBc$Imw1CQwCO?OwytlVW25+9+1Ed@4#*$#46$>Pp@N# zlOr>`I2;BXH~L2x;7#7Kbx;x?$yQDgmf-+kNh;%G=M@i&3qlDBH`nh;?-0<6&R{%{ z#7zdLTURdGF@Y*Ok@!$|(9JmzIX<8kXY7Q`nHfV8MOrbTsxEj5iH{WLL_V3|Cdivf zSsp%WjXeT#ALk(;w^^G&p28C)zYEukOs}|-I`v`A}-dxC3 z7h|4iys+`=xwYb8EbE?A0lLTwD0Mj2$pOKLBOM1+@NL$n>jWY`xImsbdRxs1!yt*^ z0_WdV3)~HO>Fcl??#C6jjq&fgQPl;A(p;Q?UV%hHmEFgmvWoscCC1_C8g0@uDp62xcphsPR zVVnUU$nb#oDG!}=8UJB9Y=Y9p(;}cxUCWW5PR$`@Bt4KUsJP|gWPnVArV`ieNSr}t zPH3VnC4QnLMJ$Qx{Nmd<_fV50) zz_lU@!Z4$W0Y%G2WTDrWV_kn(jn6~0FVe>*$HVEuIu(u>72j*(X*?aF;i3r)&Hx+pd5Y;CT*e1O zX(EA8l@&8t)Tj?uGPp%?Q2==0a}@CjoxM0fJ(?hV>W)J-dU%xf)WE>2Hg=Uxl`!!d zvWZGj8&SJJms{7;jur5ujq?qcQVyY2H60F&&NniPS2lIjMdP`F{i)amPd2Yg)rC+@ zF?M&#e27^bwyZh#Os)%!-dFMM){-!hX*AGMSQ+ab;Rb{1brKnQUR0d*3=^#^skEc) zq+5D5dP=eWz zC&c7f32^h(#iVKVkF@4kc4zsKTQU?qnZk>w#{*=Ks8|*A-b7T4@Bu@@UV^PfLROH8 zm9D{@jc^zt2<5_#$xL>GvKWDYqm!U8R+MZ<#$!H-NX{l?(uL--mC?O%lQCM&W=uny zM|I60JQ_(npm(jvLJLS(_EJ+$4ScY2^o<82DN-oXXig`AvoV|>7@*Txi~&6$S-@1Y z7?X4@HYiZ|1+Wtus!7UM)&rbuBwxWKk{l^xG5%Vk6Wjit)hWNpHllv04E_x$qVpzz z5Te&`M!>-FU0skYO-#_J{8K~`q%OLkK_WdVqUFcuLYHZ2c-~R;nqUrMDeP-*l*Q&k z|ATxI$rl(-7l*8DZ50Z1HHx?TmAWQjW0<_flTiueGSDulaXYs=BAVo*>91DXh?m-ACwax zO?0t=4{{RHQX8BNAAyFYN*BLBC&O`Iq8Hg1MG95JiKNe<5Mad=7acf>`$LeI%5c#8 z*o;#sT1JagMI{p*$xf}g$Ce4K8uCap!7!$yNx@C{-yZc-BU2;`uMa!eZf+b0uVJ2g zV1N6rFc)C&D8|@T69D6+Js~A)&b>b?E)|pouu6;}9p~J&9XvIqOE;qgFn5Ug-&Jl~WWJ)@oxmcZ&kV-Ovt#US7MFxr@NNTp@ zzKQ`krjvlY7&hB^thr8uvVhw0>~x(X7#BkY$2*(&z3s>fak%GhfZo9}p@X%#8S2bL zq0y|Rg=EX!?9`OgId#g8Gowc3d@oK`mSpC`V^<6d10c)lpW3u$tEnK%(s(&kgIrGh zuD9IEX;<+SfYHj-*Jz8Gpi<0qA$+^sE;O_NK#goX*FLNb4VORCVVc{ff<{8h?6PyW zlYqz^WoiII8RR)lS;NFo53a=MYJ=`#14m( zmVp{tga=lXI{^`rRT03)x!X+%<1jCS0=t}P83kR}zUJ+b8DS%Q?GjKG$IhZg4>BK` zHuBeQFy9U-D** z7+k+&=Li~?nJjgi*W(fi-4h#`l5K;Ncc; z!(k$j8=F06DGjHnDHd>;)fG@&LIcvtUyN3%5G*oO#pP~}ro>8+GnEk}JE#c4mCP>@ z@icD$!$hTGoZKxcKrz{}KLZ7^p{DFs)=FSTXUEqR4<8A&Ohz2tzXbW{kX3IAKQuYu zaDcF(IT};kN+{~I=%mrm_A69dIrW(Z5*;Gn9_nc7W=F@=lDI^aW@hF?=d0#6lZpVr&$u@y)LZ@zCaleumC5!=nE=k+fY0qx9K$o4L3$w7~X)46Xo79sPd)56r79bB?lhawZ~m#P-^wC#(GXz6At zV98*!Z4JY&09l=H<-n@F*)iB|XH?UPn*p6GIu0$&P$oZJtd|sPcAle9liHZuA?JWk zgA<7?=whB`3`ode=@95h#j|OZFn`+L2rCE{T51J0B5!3htqkKQ@mY z!WL@-crcOoWbrcW;JjGT4b&aYF%{r~1Fxe?auI)B_7W8zix~`;z_rtjW*h9lz8jc($G=&YssxX0UAQ9{ z0!4E_AZN?x-N?8>*4(5EN^|ODNsa4>ger9hHm&L#t~G~7A3o=vZ^RSQjfj?HJ{7jJ zFDAj(Qp@ZG(mjIz;)Hxt!FphV%_Rl03=r5;x^!nxE_-B1%KSgo!O;{kuvvRdj;^Al zBqHD;NthKB{22PRY*M+V{9R_Z&`VQuf`cM64eU&!Gj^laK=~le-9Aekk2~R2QW*b0 zF`BMYUZ07%Fr}MKv-%E(LY=9z+g%@a9~7#gS#nOrJPU*VZTsd>1fq@zcoS;w{4g>9 zmoc{wyuc*26JQuYbh{{vUnWor%{0#3Dl#pTLJjdq_yJnu%HBtw7~^+-txt`gIFZY_ zGMnN4vh* zf*-LaDN^tvp0Z&q87>?zJPnR72AQ0UX8h~3dps!W+a7K!DCRmsqhDyWfLP{?E2Dbt z4IC*%-<0m8Q|reH#~JxCC77ul_Zd}O0NpfmsDW2_(2%y{Mr(Ksh%_l#GR?7r5zih% z!Vq`awnA5?sz`}*VK}4fZC0VcTkCDHRY|fwFkAGs^dPqoWr69CLqQ;#WSMcGj&Ms7 z(%o8OWM~{B`+#bSPK-Hc%K4}Tdy#SzPtxYZf5=}MEJA-f7FJP&u1r;r#@s2t3hc2d z7KX`e8E7h=i@-{Tm8J*7+iF5IfPZ3C2po#A{{j0qkeTsk5zVw2%M|f)7Z_O}Y#3)L z%W@1Bge+LCMv+Q+%0xWbM}&BZGFvmy=)$ED0ieoCJc7wpl66P24ndyh*D^83WQz{Q z8wnx(V8#*vi?d5^lY2i!TJjmyUGsRDXjF}k+8~>2ByQ-Q?MxX&6G0+^w{7|ogx$!m zG!(FBn~fIH2FY&a#Da|{vJpaA7Ou3gdEvJv1>HgYh^9zoRw0iWbfY$$;s{`^<8S7$ zV*%XRW&2(MrbS%8}lYD59i1z9-{KF((i+$yFd2gh0$5Y{W>mON|^2Kb*v zMJ6);?9j6vjXu;XSb2zBTcRoeZckSBr>rihAr^ETm({FCvD7+fusxL+l_H%b=VZ8y zVy1^xcq`*eV=l?vQGPEe*tbO!!`I}|Eb~0AG|seG+*#)rAV{oZoUJn7e3J39sLF*g zYeAU83}@srL-d>xViKCsBy0BQ0w@TIsU!r3+MTCgXdfi)Wc2Lb6AJ9MdQ9O$=X^;)mD$ znJzVs8bPB>*@Ll-NSn)+9%s7B(@S-nTo1s( zPX$86X>BG6#V!Rm1fr~(JD+P&YQ?&S^pO^;*|0w2`?C2u`3)CKc`kIi2D{4t29KO% z9RtqYL)86q8zd>qsymdr;bWcT<>JF4H{-B$XHQW@QlLBrW9oBQ992Ig2hxI`jjU;!tmsTf>|s-a_jmqfBCghQ*%}sAr31Fu}06#DbPwe&rCMI zb*-{gKhc!o08R)_nLxB?f(CdQNqQ8{JqryW!x9@D*1Q=oS4QBONe(Wf#^)!{3$IEg zx!7=1LT@-LOvul6N)kHc2D&}wBk4M<$6*%tE&QgkHLHXzCNS9{n@#(SC)omVtRkf3>iKVq&SYx4&W7Q zw1_j?Q#|xgj0T3ReTo_=+fvIG2SFEzh6eyBB!u?3nO1Y60CrZ`PtZWjIXy)^OZbRq zXD~8w$?csq7lhNpu?yE;zD~roc${ETo@g1X9JX!8^AGZeR7`er*nF2b2$4N-V|3f6 z=)s8GFdTAbT0?CQyY7rGXcwvz0Lb)VCXt{=5IkHhQ+H1UvQU9P`$}3Be(Q6BpwUAVE}MSqCbyi95ceYb%h2RI^weVD`d~MtpgkTCj*K z1I|&a=Mlyob`~3)X9;wd!stW+Xs%unY`_A`FCUKFJw&I$eY}6lRlzzkW#jQacfq zq!1k=nnRVMFri&DJe_N@t`norI2{*{6ld^>F>9Yr(?gAqC#$By=6A#ATIZ)Kap%WK zpHmU0Q5huD9$~l3U`i&2hind!jJ%E@8{rm@U%Em%L6|3(H2?q7C8PX(N#JD!U_>24 z_;e()CQYv_dxW+4z*3SiY{0n`xYW}bBR83{$QBKXeX4Na1V_K7c_4pi%7GVL%6r!bDlK&OBBd541i4b&Ip-MSp|} zT8>yQzG%*@C`$#&Ik{*fpi2n^*?M@yj*|a&axK|0(6(X1V7~B#7lBgce^Q;b0jE24;xmy zqO~EW0fRp`OTcxKj0M)*u{LV>UvuWr#sg>B$=_rmGowbHcJveyex{5F>S?h#ubJpI zT3CbiA(!!okYL84p3-0u7JyrJVv6Hz!FeJDm*?(;6H(^qMPquS9}^*fy%zyXhE5Wn zvYreO+8o^pOH#Ekic}qGKFfWKalvq*cx0s^T?2UU&ZtqT?oF$Dcm;8gu(nVem<*1< zBc36YrqLPK^A@mLdls*!d9EkQ6()DR`g*=ACmyC-FU2wBi6~K+9wTs<|E9b|23CET z;t>-`Y$n%3G!H5aicB9#2o^JJZ>~ge3$s&HX-+>2Rc_+fl8a@qbL3U1A!vD-5EeH! zl8+Yx>tu@&o2r^LmB`U<}m73c z%U;KVL1lZiPK*lk&j56y{(45rg({Z_Uz3uUe<98XUecd@zA5U8?=VRQ^k2qSMd}ly33TNavs20<*#>~XQH=9J&<*Ip zHAY?~Q-LQIvco7^@vo?GO*W#dh&o~(vU`(=2>1{4#FMyH)eK_X!C3DA$r3JwE$%-G z00eBf?=pX2UogJHkl8z8`sCBHsQ($a$$Q2?L>1$V*dZ8N$|J?!g<_dakFxghfrAay zpW!U{3%=5#z7@no&OD6K_15Sb^Q{tZoK(8EGE7}C6cUOnOFIOw_5?*#6@QLNh3RWh zsi9j+!$i#!V=rJhj5gEuUYeK4t%CALsZhZL;Baf%51^&k7K994JhmY_LerG4Rgf85 znsJ(NoJJ!?rxcsHnW*{_kpxzRJtT=KDdQ(d88%gB29fkt$x<=_JOu`OdrR+xVzF-4 z@ibKXIEPzi_PBlHc-5_XVm>n!{v;68SK^u+7px1tj!q7}jCF?RVvsbn?)8vQuBENw z@q$AHKG@Uw<*XUgjb5sUC$lUf@%e1fq5jTCR*w!1#v?El<<1#HF_Ugj^T!5La#Qvf#gV+lZ!(J zux?7Mxgx5co!Qu|X=q){n1s>_drH0ySxX2^308w1Q4a(SdJnnqE$f?jmY})r!Gj16Sit z94SMSW?MDrKlDVgaV;H?!CjHy!D{9HM6Rn;)@~k2L@8%Q)?l8&fK&1pDt;rHs$A&MM^ju{=$aHKiZt4Olf zZ?`6zBNvZFBtsVU#ng#jK0-nWL(pLL)+Rl{>K%B!KF~w$Z*2O@`F)6$~I%j7OrX z&E9MpN;5icqU@5@Ug#h(xJBU}-8>d@9Tk;dYBnbl+VkN1VH;Sw1}t&Ja4gzb-`_z zEJ0DMn*W-@P%tvwSW0Ja2gMc_6shRYFQl$0Ja)W=$;rSRv^Uh@EDtJ^Qn;$?^16U4 z{74n=k*hKT9fZcgswBE%b9ebD_3cJdoMcere8|>N=7$f)E4$U79+HpTHAn!QG2K`i zDA*DF>6vjs&lrH0Z5CoB9d3agGu+UF101?+HdVW}XzHJlc6ydv23(3F4nKq337{aG zc96`Sw!FQ#r&-|-_>aTANG2Kt*w1)i!E4xM?N5LDC*Szyr>|bVd3gTfS^B}VpZwMT z_`x^7^NqjfkKa81xL_rP1nn zih5bp>!My5^~$K1M!hy|7Ww{tdTr8clU|$j+N9Sey*BB!Nw3X2G*hq5`@`zBS+C7{ zZPshEUYqsWtk;%}a|6$EqZOyYl~i6^xCS|R=u|BwN$Oda|0!oqFxmYo}g2_1dY|PQ7;OwNtO1dud$O#$Fs@ zua2;nN7(Bl>;)3`3JH6OguO<>UL;|^%XNwEceyOG%PPApv&%ZWEVRo?yDYW6*5Y1l zF<4z4qc>d~vV7xR+nt z>o4vF821W{dkMyJd2aT*>{S@|GK_m2#=Q{ZUWsup#kkjE+>0^p)fnsL@!RjR7i8Qk zGVUcA_nM4*QO3P0<6f3=ugka>W^9+Ic)!bDn{hABxL0S~%QNov8TSH>dxgfmMB`qg zv0pZW{Vsc%#=TDCUZ`=e)VP;w+-o)N#Txf&jeEJqaoJP$yX+Mk_mYi!&BncG<6gCK zFWb1+ZQKht?v)$oWox?ZO?z|NYd7t+oA%mGd+nyZcGF(FX|LV1*KXQtH^s{qx8G&2 z-L%(k+G{uMwVU?ZO?&O8y>`=OpS*08*PU{|%Vn>;Y?hbZ^0HlC_RGtLxz}#mYd7t+ zoAPDD-S4v3ZrW=%?X{cs+D&`yroDF4Ub|_p-L%(kDwlnGzsp{`X|LV1*KXQtH|@2X zSi3*{^{?0Q^ZL!rt2fU$gw_G{FW-Fj?C#^6*RP*FTmSXV`=?>v9~?C$OBH!nZ=&ibo2uiieaKYafB<>fuUNhkka$nS;tH(`FK@V`%Frcl1g z2Y7gK|L^$gYxvH3@N$J(t6%@@*+0L%`S``nCzrSW(K@AH-o5*p_D@OdJ?}%eSxY9`m zzWSHFcav^!XNotu-QV2bKgMz^u2Wv0{bqfO`)^)W^P7k7{)RXI{zng=Jioj7_Q4U$J=x>)+dL&Xm9qtuYR*~k3T~1_bENz=7)6ixGL9j z_Ym@fY~l50`^J58^XaG0Uwrr*-t)ihd%TwMU+tfH?{0mNn|gb9e?xlw?dq{^`+Y4B z>-x6)Rk!MG_d0m?{C>Sh!Uvvc8rpKgXtT1Vkvd653hl1Nwq9k6X1y%%J}=+CIrr#) z$9t|ncyagnNBT!EUqAolk8a+4^!bPC)AgqxKfnFLN1DIq=f3^slTZ1-`bmnP{Nl5> z-+ukk&8vs|Xa5`4>a&|S>wbRv=2`gm>*t@m{aD}T$@(6;fj6(;JiGho;qGUjzI^`T z07*c$zYQP7{_DxzM?d3}vjseRc)j+XwRH6LKe_pE{n^`l{%ajBatdt`RT)p$4CA7YklT- zKF+g;SFc{a`rL(4K%7xy=>Ufq25`9<~O?I)jp_JyDK{>^8fYJ+|J_~p%; zbsq81e{`ci`}-K_&Tc<@^P7I*{^QU6y4$zUKYsK4#p^HpjV!4b??rz0gSP~l>7U`t z{`nZ5?*Hee=s(k+@UN&pdMHozAN3Xgy1jX+f93c1*L{Eb_xyX_eemjg`r-8d=&zGr z^Po4lfBX-x_1=%}pKZTs(vRKn&%5|f{A(2Zsd0ImWIy|O`^W#ZKllIp^uPQ-AL3!? zBRqch@1MT>m%IP?UA;d4gHZQ5T5?! zAAF#f-|3Rx-hZI=b+>Qj{d}X>>xEPIYTo={>!iSr}?Yz>yN+2)!K_oKdmQlf4e!gKF3~z4^Q_;(la&o zEA>t9^nqr%T;NaM{@^F}O&|0lx8vzA_3!!k!@t%a-|yf5^zom5QD4{>x?bL47vbjq zkN)unI?_2iYE|>gE7ur`}En6_5JbSQZa9IHR!`(OA@8aDDAMDTlkNS!IB_HPA z{`QaTn;U+uPt>0Nnf|VS@Z#lu|G0O>eRb|X+1GI2@$#4YyZCf}{P>aHuU)>Hz4y)2 zpYKic=O4Vj4qyGme!+_S z>MQqm`&`(Y&A)!|;j}Chn_Q5yrzIx;Pf=Bo(*WVXh&izBbuzME!pV{Vfw{QI8@`+NEBy<*>%{r%^!zx`kLbG#4rw*R>8CvgAx?_Hh&{fdYE_kU>x z$bOFZ``z@*t=(7Jy<+SyZ_oGTboio^-%AG-a|d82A*jJ-ad z{&<%z_PE~drS;Bj8QGS1+}D5n>V5s$-BWw2;{H8tf0X%CS8v^47T@c?_=#P+^{w}Y zxu22v$F>;n_Jzt{dc=OMv*U}lyx-yx{>pXtwJzd*PUijhzvp&@JNE-Kx|*^5p*^!< z@5qn)ZNj(qq4$e@-alR*yXSwptIECgj`q>sKc3n>+spp8eEYxK=6ZAY)rHE}JAA+L z2>C5{!!LV|zj8f(^`+dOt^V2H{>$E<+zzt7-kbmFin9mXobRp+Bcxyc)UNh*q3jP7 z?VH|CSBzwLQhmMZ@2iWMFFlvP-huL!o8nhK++VaLzuv{XA3yrlukBvnmrE)1NA6W~ zU%C(NyI+c_hr9Z|o~Qe0>+@1aeP!+ZrB&c>S=fBNWBKcyFJH7-e%T}b8_Q$5%IZ z;CTODuu=H8hmGcqE$J`%=k0v@b$|43KF#`{3)t{=Uw6NM#h1fZ`=1ZN{fdYD^ilt# z|7c(P_dQIvsqd}pi~jj=_q9LTW4M4y*Ol~xAHMz^NcuanaQ4o4DNgRJI2rxN`ThTR z*)P8N4+iPo+=Tb{zimr-*zfXq^ZVibU+fcR7lq;Rz3~43+I7U<*neUt^626HPcQq~ zZaioI*YJM$VYM@2?sKN>-}xARwHw|KUhOJ!eU?ABtz_&yYoFJT5BoLuH?~Xes@4NM z@gcsy@AbbWynS6q_rJ>vss8vM|N8OX0cDpnVQ=C$R$P z`l7%8w}0_^SI_$p2zwPgvvs)7tlRGPxqarvN5jS+%a6Xbe{3IWyPA$a_`Ykv%LP%f z@^Dpa`@G#p=|0z16MAn&^8NTdE5SFscqs$-M;+6uxSN|FTC=&T$$$IRcRd4tM;G5; zZct_4w_og9aisV6^ZtL^@o+)K>d*Gyt=0L)YKfa&ljZfoS5JQZ*Y-#2M!s(q^Zj0s z!{!{je7TKrZ%~i6*X@$+aqM@l@%}yy1a4(ME|J8o4pSiv4(mOx=`K!PC=(2HkT4+OIct_WrG^_-D`GTv!I&dbo+3JuNeWSv4vC z=XI-}-|nA!_vbfH3WCGO&+l%YJ^b?Sqnj5WK0Jfzv3~vYyS<)%H)qZ1Yt_Pk^j$kb zJ=S+wo#kn<>hM1Qf!mKS?ZBmQ+)L%*_pQ0Uw%`|V>+OCzLU>|_{}oQz9pA&>{M|q7 zk++|WJh;j6?Kk!^->dxfQ^Vf8O(FYFlKeedn#v;RJq09_J62(&3F_4^t;iY0@z0v@#p{bAAFSi&%ggGA8gd0<37IcU->o9f6lM@ zh0px&f8#U#4S(!U_wT=#^>=Mt8Drm$-8c&NIhZ>C+5h#=|L%YO4?jWR@qq6C z(En60-k-+J^oOrhuE+9+KmPvbD*5|=cv1>}>2>_-gZ@pA@YmPHZ(5eW;pzP+>+(-O z0gu18hJU`uIWPQK2exnWI0gt+j{foYV;uba$NzmaUH{DwSaj5rzfivi-Jq@sByIe({<9 z)gATx@vr>4U)-So$mP`^)?eSoebxV^8=(s0->_%?>UW)kra?Q~{>T4%^hSUCV_Nh7 z?^t^N_#2OfUpm-+VITgbRrL$c=&yf?e(?OuUw(>z+J5o*{ZC(c^QS+)AFBF=U;1`` z^}*L{=NC8pub%n8dQkmm?7!cz!hZc3{e{p~yRKW8ab?e|+wvOoRt_ka29djHZ7p3Z-PYWG*qgkQQjf9+QN zrCZ}So^1bcQ~u&A{B6(fuWpGyA9KF^?|ZZRr~l{w{da%(H~;Dn|Mn06=0E=XfBgxW zsJVYixrfH*{n3-t2jyGs>+r&60CPRDba>YKC|tT^H+l+p8~*lJBEFZ{){c0#dtG8Eot)?V>}JM! zGHTk{W*p;Z5BmZxfNb`M19$)QvJQ6+4%hf*iHThMS$i_!%yqg$Y$%bK+-S_qCZ(L`@xdx-ou^uVi2`Mo1Z|Edylq@#54P<6uhl`s)k|WyOvtfv znD_S+MX~@7yJ7mn0Fa4~D3x&{oO@Cc&zPJ~`}-z>iMzSDs&yK>Ey2b&>0gS6~|93}zBzrpvd<>B0;=nZSMRjRV^O{KQm7Ud~=VQ2bvn z0Ve`P^0Q@gF>*8R&*iBFAdmW5`U`i{jF-(^oXsDD;eC6?3u5o%j25F_Tb7Z`GqN%J z5*SbPIa-pX_C#Tg^)4F1c!}5Gy{u!AIi2EyDY@yYGp1P({g~<}!#H?Pp{MS=RiM&X z@Rvg)t}sGKYG3eI>I;?)(Y043xSuuw2U%*&QsEmlK# z`&!>h7>d)$892_9D@NwB{RF~|J>fm@lIwR~+?vLskZ^YHe%Lp1{u%e*CWKuE^jLOo ztDQ_%;M!f!$m!hIBU`Y0oF~FW`WLevPR>gCB#f5?i5!P$#(Hd$=b4`I5*m+Je)QEJ z&eE^9dPZU{g)%)YN98sGM6Lv;von6`Z3(P?oMZjpT;spIm&h$$*z5Ql{c=9YFBcA3 z>o`qMhqUO2n-ykV;(}>eQJtc6sw9Z{Yd_|h{5<1u+PL*D{4B4lcAeY}UZDN4i$bei zxAD#4rkbFg;Xp6y)>0DEF_v|LXt+Z00jhdMDhV_8ejn5WX1~m`FZqc_9b$3SXEw&* zdfG=iY&c$@(mV3uB-c1k_-EE*@)&mpc+QEVZR0S#*>AF*>}&s-w~phEJL18qaFkB{ zg4kn-XTzwb&V7E9PFiTY*Gf6Ll;JSW!ige?SFMCZfpepCr=_eygcbI9y9lnkstf4@@$~NhfgHK9 zf>*Yu=gwzysrTSjO4oc&d4uC&F6>Wx1?bGF57*yKigo^x4$d4yaX&q*een(BT6drc zgp>AijK3iomV@CL+q|(#Rbsu{OErYJmQ)$Lf(x;t{hC8`X)sR<6^TKZR2lgOVt_-W=Bj)fsVre9XQ z%4Dp!rYcg4iDwc1wk)%V-=xZj_*kkxk*n3DRrd>4+cr*_jXj~HclXn19vsDTV*8rv zH_$n}Ts2UsDr~;G+53^ELbH^|CF7;`m#;VKC-+s=8246YhP;qRO?Af4l@;lF&H`QZ z@p@i1mXyI!p{9=Ldsnf+kV^P-VWL`{oQ99}+OBaK#qA@s$TsD)S8FQy+Zw;c zO+M22m}|=_KEID5%|u#^cXr)#>$2foWNa_h88Xjqip|5j+v>C2F%#`YX@6AvQ(cR5 zo!cV^w{iB~7B(La)~@5*##mG)p4NBNB22sYl$3%H$HHKzDj=4(`?*&E!RoM|+{_xc zt=a%~tZeX{5qq_ASr)A4nhrcK$fd*d8`o@dpW@~;H7wh;|FjthA;&Yqayjg``O%@S*M@!4Yq zH1{8m+q18+@rd_0RNrbBUK!(@)?aNp23P#}Vk`gF!(H|~_iR7hrCb(QQFG%7uj0e&*c2%ioX|Uc0RIqlrN*Y-F7% zz$Fu5<7(M2gi!|xf>QL}O1P@q04U7)AKu~P&uVuM-#|ZljWCG5FKuS2%GFlg;w!b< z(n6Q20|c`+4T$<;{JTpoOd^V{geo)?zpHQ%)zDehS3jEUY`@rvC$J=`e8Rh&*0n#+ zq#%k%Shl(v&NyuIpLFC_&3B0}X>zsh(SGlTbP*TfbJZm(`=(#s{WLn2mDQq1h!p63 z9!#{~>>sOCW7_yP^GmHGT_@RZ9B1+}c5_nh#clRqzEwNi3qSFFlKkW!T~$nM2?^Wd z)p$GRuVf`Vh#7~D@_G|Q&QJZglMFa;huU?H5d1ENb##f3pxLIG*pp*Ex;lA|^7c*+{NoLXS9Re$@-gpxk2JN~ z(tdSF4B7HVZ$~nNcGbt&zScY&YS@^J+FYHDeQ_*s`$s?O6jrawWbFTE-Nb^x`t9Yu z^2kPQr^h>7vB_v0dmn1p3#(nHbd2gD>4P$lWpj)wyX8*gLM`2|?tgz2k_HypYZE4t z;L+^%wLD6%QwCDH1*l~H+a(d7FrgEaHR^a^P`@M;ersWt3EEuxcG`POGyYQ-3-eYE z&w@M#@S?>&+5ew~&m8@FRlh%~w{sL|(r-=&cYje@pDZdb!g<5xw2I2Us8$i_?5X`H z<3(!X-JAV6-*wRU9jz>qy0+IlYoMO~SiqL9^J?u6F3o4++Y3_Y%W+pk^95hVd)yUq zVz#9@)rqisBwW6&%3~Cj5G5s0BESlw1e!947SFTf`0tC^(cKT-LsD4X*j6Hk?tVM3 zdv#<9sM*AbD=r6;!3Jr`~6$IE6xxlB>MB<<$w^yIt4JQGE*3{^r9g zp=cZ{Pass$L-%a7go8pJ??)$N? ztGn*C-4FoARUMhNmJo@j9~e*dkNrQ3`!KiViW-+glZYwNA>(Kh87)ef(fKGtU{GgJ z?S8@Dt!{rG;L-sd1@~_N0kw|rUaiO-qzr;2$6`D;$M8DL$i*stxG?;xzuC4IkkJcT z?H<4Q>mD2I@Dd*&;3b!^YSX^0b}*Y4G5%XHJj?zZdj;7wHT)Sv?OHg!9$Cg?EmA@*26LoV{xwVRyVY$8> z$>kbHA1A>6S1vgcXZ8E`mfE5}6p-?`Uy@~?7G8kosQml=*I#pkuJ)5}9BI7Q<>}tm zUV*|YHJ#WMC;f5n_QxTw75ZiZQDe7T>$E1dsTz~rKuMs_bx+`__X}Rb6%6>QtMdxv z$ZNYF+4~q2$`V|1uV2;JI+x^yISck${)S<1J&ovkE|Zj5#B znTk%R31G2{`;ZVf{!{lR7%1oN*cZCmC+Y1bkF_xxQ2a^q^HKGLTLj>{>oLbP0*dvF zc6guATYqZ1;N?@>H4ZT*dXGrm{Nr(&?0I8j&Nv|u<>lJoHs@X4Uq4m)Yw>_HF{tUG z46ojm^}TrRqwtao#dW=x3)?JFo1X7cB|T$>Es}2^hnA;2uHnt;au_Ls){oj0iG5u> zhNA|ma0Ts(1nPbY%qXl~r_%4`r%ocK@}8XEcvcl>>8?g2eP8d@O8ei1#tKkgYf`o< zRussUL~`G|Y$98ysW1I}5~SHdX#uv25hEoBcWzJy}oPb3Z}JWf=PHAN8P3Q5fP%(*(!oK z>A?L=19wj#x4#CdYlrt(rH{scO&UjA>hx6poTW42Y$kS{LJ0NCjWq)48*baXEn-ypc&gFjKjbJ0jm_tcqSti+r;k z-Ef5%T=Gq14;d!sfa+(fa!+lr&ew6IZ?I}XlGTw1Zvh{W1f`h{ZJ zl_bP79l42twYP(y`t7qq z@5U7^6Ta^Z4b(c`hJ_AE1T;fNM-gnHcIHTCCxx1*>AONxLbi;n&ox95_Y z+#f_u^CqN&dW_ERbZyyee z0I_2StS}Xc>YDd^^r27kpPPB^vJ~pC#YE?*Gvw6;^uziUr>R)+W_b38a zC!qM9{ugFh1f`Q#CENCz#i#g_C8_0g-6e`E$#DHFoM~&FCWqqw$+s@;Dxtv z*mbnRyJQ#SH-XP%qxOX+L`rFSo>kH*D2{|X_Ksi~k1Rx$2Q8;R{$;>vP#Ci+!SLhv z1;*2?OTIbZv%di&B>6E8KXZO5z})bCK_-?cUnIT7Atc$LL9v-3c6Be6Q;m%s#hfe@gy}LE?aoFDYRTT*vG+KA=;h_a$~xKV!!75;WGY`II-U zK1<4hT{zEX?lpGFC5)#(sRyN|)l8AC!h=S&9D_b2f@{|(Z+$Np2OtO`C|>e-b^k%v zacKH;ZQDEXJwWHq_GNrzrx1+X*x$iZOLpTo_}}yY`5M}#?kmF3lwe#_ilxBNoNoL z4JoFov+(@TQ4U8!v9`=QP%iw5=O>TL~lKv0Bts zLu3Ys7uJ>V{=Yx6<{_dx_&w)z`=yW6N|@}S3>mrASpTSNUU&P%`0q$-(B zpW{g(3njg-8S5AHVk3m*2|Qo6@sx2w1z4N7uSKlLa5kxqy@D9)O#ZOk6giJGv3?1|%{jk51 zWwa;>6_z{OU&W-~<*>xaY?-RH@#%U2t(({Y@#7Qszwa5ZP^x9GLj-ZO|G=>Itoe-g zb`M6ck1D?CCZJQj1Q)uhQs_&z7h|?muRlfy*^l;S`O81OTH%Z*@>p8`rO+ocCjs7~ zz}9-<$$Es;YurXfF0(L!!PaR_Bm4G23p5I{gResU{Zp*8UZNBTe29pXB8v=LPr29g zSAsM5$!AtVOK=UFtp*EFq5cg-bft|L{HUA_m#e!U&|}FjdhdRiX~XNO2<<)ckYW8} zmB}dYPdT9HwbiOcBiSA|$Un@YTuf9=7gU1Vm+QVf~3&?^SFU^1+Kg?UJ3qJpQ|1byecG+v; z8nYc?2##hzSF}n<>|K;W-&hyUTO1ER8cj~`(FNh92;sakIAjH&HQSOpllAQ<@YZuIY07-Q9P`i&Y_i z3A>ZLsnQu=&4|b7Ri{^&2};zq1tTn^>IxcpVKUvvM8Z=~3fR3vIB|>YAxZToaR7goI7XLca^HQP875UO5KuIlF}@H&BE3w2eNc2^>Fua%ELVB{qL%`1H15D z*`VrP7)V(!>W^v;gw!wRr3OIw@ENc9KiHtJ`) zD{A~RM+p#{A6H^^8)e|EynnQqfbJ*Jkeplk!>_lDN^j zeKFOLmNE9nhBB@e#k!j)jMH5cyQrdYNrzk`0rx^;SAbiq3Dn`h={=mr?AhuVvk?0_ zhtA)1zu_N4^6_xbn#(|1<(eB#j8&&XTU^3>Zm%NUZ;-DC(qDJV1AXepOoC3!8{Y=V`< zZ?WNbwc-BXEoD{AXc9sX#s=x9(K_}@uQqj%dOKj=UQE1xT;GauL7(H3Q1;*%+JpB* zbuTv~THD=fgPsIR{Y4Tk48Bzh<{H7zvQA=1#5M^9Kf{^QZ}+<3jvS${?yB()C^vb| zrh}XiL0^U;fF31wS_lPn0{w)USXCS2ZCaFBZX21vc^V|UIKlnm!_w-TZ#5(yx2$Q4 zK_LLE;GzEcH;9BDaa(FwcY~&TC4h)_jhB52t@R{TGh_3F0%nGevG~-wTf>bzaVp=k z>c)=4+|if}YWIok60J;MPU~0z#+DY;4%B(Zi_Jg?m2gwzuZ5;s?Q+NaXJ2bx0f0Ee z;6$n&pHH>E0o-w5>j3G$xnFt`VfO++={Y2-W9UE2=(5%ExPPwOzBbls&5XZ$Ve0m; zZPB0Tx~3d^&tuXIBgOva3Y+f*idh6Ua^JFBHH65*K89OlU>P2A-zZp;>$%R#y1%bm zDmiUW0Zjd_-XY5Jk++XeV<6uDb=~)i4z8c$AygKsPbJ~i0}PI0*XdlbSkU{AqE`T> z$pSU;{UE45VhLX1+dkt7qez4gfgxC;kOTb%{|9a98Bd)+VKX4ZLs{#D_WvWY^rv5Q zjlf4J=W8y|V^)v`qDI!oTAZNs2|&@Cm9rX}zFqqvm>*`8S%5v9V2kXn%6_8+)R|=P zE~R$>_GxzgzIwBZwEzply5L$T1Hy zVWf)(<~kZAET(=a)6bn%YV-%HCQHZB*YEg!09K>c5U|$k2f%iiT=%!DB<#1uxP{2~ zG^|87RfmE=yiO+~9%|7lnh|RMPJ8k9S86Mbq8fP}*Wqq}X2b~%S|>N;2w9}pG0^&b zPJ)EB`B!V& z&w5k+cjdF)mQCUB_O66%yq33^=x+)U{V8!FWR3WxuML3RR_jPF<~|7lwO}MY7dl=4 z-bYosPsr|ap0C<)pl56Ih)kqTgTCP?)t>No>e~{8toST@{&m6h1g8zW(guXC&oSZ4 zkarniUD}=vw7wd_X80QB0VBAfo zIT5NB^nU!^-mpAZpzf=lN>hPkVt39yo4&B$!N=&1#y-EzBTZM$IP2)wBJ{H44rjc& z_Wo-ufZJ*;a?p;a(t<*+Q|K6I7JLdx*k78%sW*Srv3GI7R}bOlV+NMR8Kq3O${C=J10)v%D6Db0?Itk;MJ`eSlq4yx_evhCx|x~C4( zwEmIJtj6oR8Y`}-!dmF+g$_n)Kqd9mpkjmN z*?DAS&!W2Y!<33` zH4DFr6351ytd{0|RASJd2N?yWaG5PCtzut4ru(yN+nq=k)bOY_xW5TZ@ICC_XMPWG zy*ay=F~!oX42<%y@9MRkU}>|7&BW;}3Fwn?0MTj<9Tmu1h#2d376+td=s0_5g>x8b%*16ut6k5O${3f4Fsgr=@v~lak?i($ zMM^w1e`q%s3}GD&^F~jcVMKp?MLpC@;occ)T?N1)3y>>---#R><>F{H$mn+U0$+hrv7AZayqpEajMy{3#1=D^Ds9-3h*%`AXNp75 zw2zoFk#m%C%|Oo-e{=kg3G*2N<^f7N&smFQT9~G8Y_w?akihyEKF3ON_AGetdzkRW zyrJ4P8KsvWLyX(h@d}cWX>M_)D3kieg*uPZG0F$j_&vhnx-rr0xMM8$R6Ji#;LLTb zlX}}ukKXvLAiQJZk{B_2#^SVP#WFDF?4M?X61}QRrHpZ8bhTS?Pr*g~R9g?_Q@uv13VRQv+?Mn>P+q3q zZ!@{+`|K?3*zJH`6xKI*zVt+Wr?2|Tj9E)Nv>G^^OjVv~=K|07Pqs0Yel_&{_*#L> zP90ZrZ^Y5yk{3PJ2>vUDc8p8LGw%mh|KVbq?2ptAt5|4kk zMq`NcTH^+?NZ0C3M0*r_Qtv?mvF9Kk6NrA+ra-*UPix=n&R%6}c&|AzI85+2@GgjJ zo5Bm-fLi4m`UJoLbhZ3%2u8oK-CDicn7ma0 zg9dQhOEOMPmeIBmW7W*P`8-@S9F3}bJ3|%jch44}Yp>wnQX| zeJS!W-$rx3JlJP_^QqJ(ap;LB39PELdBd1WMS>`^*C0}{W6wOslr`ysmtIH|&!m?1 z)kH-{pN_Oy5ES|tnQQ+?ZJK0R5dB@6JWQt_3mY7E&^DZtfavDw-}#u@{Ka{EUFn9_ z;$M)r-lkC-J$Oy6PXl>8z4+FQa|M*7u@9&hMvpTJLI}+|O7_H>Ov`Cdy%fzA8+#2V z(vHIZcZ-|MmkHOXn2$L6gnilTk)uHE%*@OgQ6$GBF+=Rdpn<|#vP+P(10H~P4G6=Y zYmouL*b7-xe{xT|!++j>C_#;P`xUon?$D@8R6@E*i7@?BFp*$*!1no%C?Mm2Ww2J_ zZ`Utmht{#CWoR>wJ=IVNAjoTtzx&?jE>$w@7V8_5wrzXo7%Gl1_@5#w_+J4As}W2! zRLThcKaW>Z&udp_+<@|Yi2|r;V-`=Wcf%72Ysy7xRJ~w94PoIr#&$EBT5G*vXAZ>p z;hG~F?3GAXhPy%BJo;PjwteBndVP`Fc$GX-~8c5eng2N&$ z`O!`UZRPhzu$ON|KmE6?`hM=71@UoJ=_?#6I(Uk=Vfl>-V(hp+B|pNdhlQ2MBFupu zvm)HB**Ju{=;Uit1PAi%U*$3Cts5*%((MN36UUF)Ackn_WB}+nb@%<6>VI{MEyL(x z%x>L#+fpr->4AY)-+yrJZSK=Wjd^D0jW@A6+{h>TMk>IJ{fY4w_S`y5HIM`FvoSyU z1{NbKqAd#IWuK^d@5PbKH9TjTvcv}k@J;1>PS>AhU>C79DGNPpKbvZ+b3gOWoGcpw z)!4_hc{D|L*!5NFm~@``jb~wK&b@OOT$GuawrOwS6g(3yB8=!OQ>AyFwVduV%Y4uI zx>YZQKsrQe)>&Cu5kMc25!7+60Mn>h5g?pVoL-@xG>Z7GP^Dlhi^^fYswRZ`X^G9^ zt}18*@bu;i1xMJ69&)mzE0y}gtf1ff zZ8|ZK*A4S91A)@5)7jq<6mJD4tW;ck)UxYv&|6|2V!o#2IEoT)R;*P)iktQv&2caY z_i<7xbkxp#1|g>C=|RQmPW;w_8qJj5ns@kRMcUk`<#0v0cB~U?S7oCbt_1|{+9+fL z74JD&BzuOI{TsIRC4d`d;vAfHo74OC)|U|e*yiXBOp2p=69Wy32X@_Zq!4=w9i~J) zagEHFY7~OYgjv8d>N;9kejZjjCyu4)|8~*iKZjYAb-_u}lqwQ|W0v)bFJn71xx2_F z{hp1caxM5B#(Krv3a6!d?p+g_Ng?V&RTdYN0H(-?>L@k?dpbN7WRJoP`hQ1?*fjeC z230yZ<@?ejvMB2r6upyTWUckTX8^3K!c7}n$L9i`sza~4n4^sxB5EEM5G5(-%j+Br z>}$HOdU@ap{M5a~Vp{|oN50`yXsspymi%hUG7$JH$dd|XEnyt>)|2ioCX&S&Go~d` zrZ=w+u-DuMSG2avyia@yRWKedyC#%JhwPb(2DxP!eDk@PkN zev}=LDFw-LnF_h*F^BcXVIa29@OsUpb19QMC zPnie(wXdajRuyi}VC6vwpxbSX zZClEzv4w=&yITUcs$|WeTl`?AdvS~3C33fcQ=JtpWHZY;Y@PZN1Pa8Vy(p`H7o$2) zobS^;hq^KARgbhf%Ogr8OW_xFtmWTo)D-;!ZF$vWYuNH&#-|jh+Y$cJ5-YHYvsv3) zTJ&Nwm~NA~+s*58POnmMC3Z6^{9SV?4{Fnk8NQAysy}&P#JIP{89*-H;&3uy*DI%x z+xVRKqTPTB8++XH)^#60x!;af<{lf#7r)Qw!)o-ab$>9i@A6MF+t7=wQKpZ3EGHOI z?7dIH(@to81BOveN<@FQJ+%l4N@+-;Gn67k`DLrZ+Qaoh^5ygxXLbuUBgQA?W?(_4 zeFTz}m<4vWhQG!;cN!io(E)-mj=f}M3r;N=%}bHGH@(* zFD&``dD&`mwFJ{yur7B~{n8YK=S^U_j323D!$D+;yS;-oKv!I*)*Ak%&~BkM47aez zew@tNF; zIq8}NUrdAU;!<0|K8M@kncJ>k5Y)14Cku}@lEsa?TBu}g4)vL@AYko9Fc|dn{y<%t zb8Z86<(b@HFC}#u)$xbeV<)*6O_n>rl3K_p0h2`Q5;BT?$V*!(V0Y)CNV)<9o2_Ar8khrCVbNdfMVkbqClbXV?z7j^j(H z793^yozs=iSrPLh{m(i%mm1KUS$5q~yTZe8+Elw>e-w1olLw?)fLb6utu_nGqVXbc z8eOR%mKxr%SS6J~YTHWE6@66bYK)C$xowb#{{Bq&;~2h~GxZPCFWsW2=TKp7+&^^b zC*0*c9W`vvoFdUwn8WVe3b&$@uC(M!P_?oT)f!Xq0T~Kg81C9w0_vn%7Dto}0R64H z_ZHTy4aAFb{7fZAj|*;S%TR&bRQ`%k!;7-Oj*-+g-13k5XGkoUXA*+nbj0ben%@$n zyg3-A~PmMz{t zjNQ5KKkS4067Am-`L$#&#;7_^GL%A@^h|_E{^rw)l}!u=okw(B1}E8kn$Fp<*qu7M zk%L;*2@uDrvQ(suRb}7P198=g^T*w%H3VGijONA3S&jGbK)>)4uJQovTLZr#ZZXpw z0c~4x7J&X7imek4!w9c$U^Jo8-JTi<@Wwt&o|jmwrgyj{5M1@{bkyTRKBXTwI!4UB z!y7Bjdy845W;BS=c*^8Aaf0JcvQF^>=e(V~r0mxEzuFH}E6MbR%|#7{ z!1;ZBf=s6OHP#!~KE-pLC2M?3_a!5Rk}>ah1~e`SuPha1H6V87g(@q->6U{xc_8 zs=<38TuaOh_?6X&*R2+OXDjwQnG%5W;eEBdvd3$TQ3=%xM78#Suxy^H>FXCNBSR;C zYo*_Xiy%krVcgCGThRpxxv!a=fR@xSg3cL!L01yF>m;1hnk%x^-^)<%GTUu21u@}k zrDhE&k|YtfzgB~9YSe~Z`uO@;M*Jaghf3espN-B7RC%7IV`HCoWgeX|-K$tJB4)03 z6pC)HjwifkYCb-)@4A_>gw{U3t{PkL1|R?Ec*3Q}7Kzwp8zUe*vL}#qFIJcB8PbW1 z0S_SYDsUhcqS(ksbnT@?i6_g!mv`UlXND_JCK}7o-FO+z4m-+RqkgtUPoTnR)j;7p zp`|shXwT%UQ6Z3NKQY)H8<|lvXR7_4-XBca6oLBkNk_%Q+v4dsY&?b(pF_wUrTbIi zIZyrz^rTf6UIwOG;j1jps|4r^`iZea2&Xk#mOF#z-tXe@(3Uu!ojG>pm6vvL3j+i zuR(Nsf5s#Wig3k{Yy1gXGd!0x`(+BlIw1tT{VCM~a=at8@?FNy;!U-LP;uX-KT{fB zX`3cZJCAX}P$l#ni}Vf-Cb}jQk*(n5+mAAcFSVV~dTePE$1{8-sLg3KR;0pqP4zDI zaU8goqLZ*YUlQ))zF2Co)mJGGIck)er!bAv*k+ii=&^SYTBcV%-}10 ztw*(WLU8)8qOaTwRo8*A`^Q2rl!2~Lhog!g4y+k8-tm3y%a8kC9mBBEBCCD_fGE=jg84)nIr~IBkfzt=a!cj-2?>ad^yaP zv7rjX)K+)>?Jp}aj`ckE`F%N0;1D{nIa-ddtlhMeTGFO0a(p?0ZFU#OXW1C zo!*Zd$DkIXSABzJSf1LZ+g=5RCMx*xbB}s#A!E?`yVHgc?C&yj9h< z<)?!ukuVHGn-VAQ$^3H5qji)4N$jiJnA4T31b5aWF(N5QtC(`mKXGW-rw;YxqJKxX zQYBFW(s)okv!`wFD)#%gBdlgu35q(aL(!|ldGJV=k~+3~1p1(9Vu@Gi7-X+s zx_4@^B2t_Qh(c=10qnrU$KU~x;%&gicn_#sh)e)8K+M0>UKzXG2_C=s8s6Jo96wT$ z)wXD^pU_^n(RwWL)-vAPxWC@x?`j0iz0AZl1_PzL$I!&6Y#oOWfW&gyEu~{7r7f8( zdIhx}%O42G+A}D#1eJ;WASK`NCgmxWxD(zr(LZx7Ul}Hr^8)m-A4Edc&eGw@)w5^y zH=~?9Ii$rMxcQ?sG9!_L#`z!hDl%jMHn#1kiZyW?KtPTZPbrbH@32zoZ%f{GjWgSL za;ctjNf)djVlJ~z&d3piIq5J3zH?btjIwfAmzpGpV`p)08G?^JMQ0Z+E(pK=8A>{5 zv-=pA${EsxKI2mooUv@UlTT81P3%ZEs>%CxfQL9N*&5)Pl0d25wkxgtRVgkl3JR>Q zWKe8{7q|qqP*`g<(UmTx)nWHM8Cu}VEmiGt7ASKvCjnohNWIZn)Mfna&q0OE$;d1h z$ca&P&Yu6upel`T)w-vq2-zRvHF|kayV4nQS%dC2=+flO6)2dlC6qXBA?sbA*;Lki z;Hd^>ICL#c^Ky+=%FLd%>Q=?no<5(tmq~B+m@3Qv5;=Aq;GD@e*&zX(lp;l=HRd64*=oJa$jHB0qklYcj)Xw5>NTrPMx0H^ayf6&# zwI3hTX9bwczbg#*LTBJ-tqB9Jhy4rz=B?=#(vfvwzJ)mDAh|Ztl{yQ4c{&1^LfUj zlD_!Fqg=>E6#$BKQx_Benu0U@ZJFU*!wYyZppB)ecIbGDZg_^Y4#-C=E%z7b-xWc) zVx~$FDRhlxI?2`mA}BK*rsf9|oWK+~}}X`7IG_w(IR-Ji&*lAn(<-Ux8en- zUJV_vmD+O?d>EC^I^({!$hhDT{W_l7e`bPb^5eEPE_H-xr$1BJ$_H|NnNQsjYLCf| zQHt99i4?!O2cK+9(v8t);b}%6h755fEnT!BY6zG&gP&3E@emLe7xhim>_=j2*cx0F zF^3&#-e+I6vbT~O+_J?8vkFf#Q^SwNNG`$%a%sG2ks-&0mB2bk{Z{v8v?t8x5+PR` zs--f~4|5EvjBe1#t3%mN@JSX6?Csn$8HH@ABk|FLakCh>&OvChP1NQ=u5Nq+PmQ2~zl z*H->PL;9!;;C1p!`YwQ9teB_0D2RuXUdtg+qh>O_W?*s;AAk`Z2JC|$F6D$K7B*w1 zh!e_Vog=tWbAmf(%(N07gAv-cRca~Y1LevS1D0u1wu~GnhgltPS4>MxZlAK;J4e)S z#-5`>GYm;)#$h6KJfp~n>;4%L0u}O80R{07?OTdOZq1R4Qr60GVnC8& zbJyvQ#BmtMKto-Sm1ZGYbGP;!Kg!TCJhI2j8wH%~-iVPx>44G6GO}{e*H%a#DW<*f z&zk)pDB~u;G|M&uxlk<}C-w31m_jBV*lAOY2&=^URcU%>R*9Rf7#$?WO&gV!_ zb?|}nLxXNHnmlLl`C~2*si<&}knWE&m%&|i*Ar{4D^3wlnBn8`8Db8jsBI21?~je?Mwiit)h^#kGa;&{tW}xtpD~i%%e^037vww~ouZKD3WEUc`-~O||7VYk zZYlHI8*_mIb&bo^IB!f|mpOc`vJmbS2VSm7<5JU<@M)HITpGgRoy_rkm2*GV2TAW{3M{00r-#y#PZ&)?}p z+ab9Z3ox)w-|bL{sypGFWMTcrLs7bi9gVDS<8#xfnJ=;fh;-Dz)spGi-DVRS$LS}S zfCnJS(dt{|Vm8Ix861~lQh00nj7j^Qj;YG5I$xsYM@ONG@$)-Fy1Vr*}jmaO%73dG$(~A7G_JC%J9}oDHit2#0kU@ zECc{&ZPET1ASC!9$9+wS84ceW^Va9C>NX>b789Ut+RQQQBaS5s+G`|ndq9{Zq^%X< z2%|SbS(4y*l$kycTtgSb`~97Z-tk~ur%3{qEjE1yU9-{R`bZha+^yET3Ma7(=ekv& zB}j9NSrdq64C(v+hkGZ)Z5@+bBUkElb{CqXGJDk<&Y_e!!c~&K$I?0hO?DvR&#|HLg_h07V1nf{@W$SXHnb-tIC};Fhw* zy>HuRcIFzqi&;k7+w`*!Szo(p_l<&W6M-xmGr`~ESb-x1mTYI+>k;6M9xC$do+kW( z1%y^ffgP;YNZ3}IM`vp>(r_CPn`DO6dXzDf04{fDQO;2mRXR5)aO@0}lqKCDM$cA0 zCg$3Hr-eQPxQ>KZ`jr(t8lP5o2>a*CBh#gxX|Al6p?|<7mpyhaleg#WJwh8-!J ze!^Y|Fe|mW#jS=HSgT*}6C%hCiT+i#g`7G1g-3VPulXM++a3|A&wM7kcWfAJ8_+bF zOR19+adam{^{Rwu8{j#U?fR5FTM?Ih(0!D4gNmCWX@HG$rggh;GCd|Vo(b-EY!IWz zEN#OT!_A9x-P+1~B-w_9J6SEI8Fojd*4ldOsI=)~&QQMOW(|>B%U5ogQpwE-3#tanLhpgLue$ zJ!d92fTl43IwKk#VRlbO_&ldyJ8|`{3rokiZPPfKeN;-(WJy(ktC}9-7&dyL21z{Z z*Veku&>Qm2%{+adwE)C}iS!x6)wyZcs4$%PMOi0lH@t#{v>JzzT_xT!D$^ps%avsZ z1|evrP+KShABEUVp+6#7%5--25i+BMr?v!l1BK0WN@?8JFc?--zn14PA0B{RD2YYb4k?d;ie48{JH)LopR z5HPX5dQG#;w%ttg90~0AB-_~jQ0F5({}8y@4R$gf*!(EZ;G6eP!n$udmPan3^Q>aP zc(g@Ao>{=mxkW5_qD-$t9+5t=Icnv-zM4Glylo*I)|5gmi+EbtVP=36y)}i$oUE(Onj~6LJ*@{e=7wQBgK=0%bW;fCX>4@%H+~*riD!)MEbDvJ`*JOiK>CS&!Ed)f*^@?|Y=f1KgXY?)zs z4ZC}}6Xz*|?z22@7{Nf`C_0tc6`ie#*nkQptv`p>GzJD0-*TiZjY})9$}&J);&+I` z*2>(J<|Y|u`oTK$&p7(6N%d{(INvyV$u~!Fn^Q0rw{~OT#?SrAj?Inl_=`<_Jl9PBB@eRqm5Kp`!2^cV^!ll$bjuGfGW0U zM`Gk>0GVWR7n1mcsw+`LW%w-OV`o&x5f;0sjHi6!bEl;^O2m4FiAxNO7Fg|D3tKh9 zxJ9L2mG!n{)_<(AN2rey?|9P9n6XZ0F;cU^ciifZqVHSk0Q0r4$n#uBOI^mRlvYAP zq@5}X@qg}!m5*8v+AhuzCNIFP8pCY4@Qq6L!g3kzFc@A91WINZc^(OY@oRBV*LXz7 zNwJzEH6q%BLujwrGl0=40b*#iUC~()FM0Rf!C4coh(~fnKT%<03aXX>zIC4wYzv}T zR0qzDR>nw4cvcLG_88YwaJ(xwUh)0*0w!WC(_ZsABqTtBA;$Q!mIFe4qCWR(8B7it z_Ty{TJS4Grad-Db>)V1`piqWb*Nly}S50D%>8HC4lVtv)>&$grjflAK_>h%jLSmWZ zx4r~Q$Ur37+XQ@QG_CPOLFLJZJC!>773{sr7Qc0hCk{I-rDAFq7B9|o@eM?$=l{pvI>=vr$U3P!5RmUTN zyG~&@?~@}${Wp-yT;hnEh1;MU8t+(?BqH@Xr*9Hl6MV&c+97KzEqhaiN(=0NoZff| zkXG7xdSR4g)1&HFj$HWC7lM)&@}N56JHrd%nI=H}gc8PwIxsf_UQ*EUWtonwBAJO8 zc^}v5tvhxV4BBO-6yFIbJ;!Nq)w%4hV!&|E+A4NqOOhV$>G-)B8u!Dr#d@wzeg)?f zjdgM#bG(o}NwaXw4@a=w^(ZhS6~MT@ksu$A-q>q(d=u4~;IJRPiVw>vl(U ztSuLvn91Al>m4O$(&4cZLrAGY$LC+}%t6Ye1YJ>NLQHd}y$!|&5zw_wUo-xq=Fsd& zOO1w$pCm6)vDNM}B<}we3q7t(Rp?O@?IDJgE8I4s*+E5fmx-v8R&XZDt+s`lOWN>Pzp0ChXxD)DRw{dr!K2 zIcLWb%aq01nUHo1`$=vOu}2;Lke&#(u>Gp8=a2TF&0TGF6TInLKrUCv*vs)e(%7L- z*KinxOLjMpfjW+W;D}q2TbBwK$4E2p+xO5UC)~OdBT8B5FYbpR^v+GDltaR;(1v7M zYzX5CJg?wY^d|Eo6pjJ6JsI%b5pb6~O@HV>9d;A?=&=H^Q*i~2K8mHIPg>%c;2;}M zOr4n2UOonUd29+h6`&1ssq$c<>8I*jfoH`^W)Rib%@T>#;fH64Z_2m0OfAl+IuLk5 zLoIqEZhaep-_@s`Et?RVZY%ExU59aonqygB42yfCvh%z95y)=5i|7UNzT=U z8e8rUYpB6+f4ZU~w6^Fa)pKjxiX9yPaTJa7CFP!XrV6HJ&t19%sOfNcJEryWCSFqN zpzf&HMq#e9GCh;*RcTNg`~AiQW#Tm>MPN<(@-hYIRO<*2SN^|>srLKHh@v?hhlzy7 z0L41xA{NT!Pcz0Kf-^K~6svK!w^9VZ=slHMBD>EvlSQze>DcWB3C7dr= zBxaI@_j24s)p@jV+ss>st>t(WHE{0u`tzhXy80o&r;rPL8>X7+3r-{D;>BR zn>4ddor`qU3GLRa*UD3o+$v(!?9jgg4%ClIbD@B-&p4Pce6BV}N zAlUQdljA;`JMqsgHH3MJ)EP{Gatn>+cO1@=9WjkG^7gZA$4cBXB+;R1=1Q%PvuKKr zkXOPr;T)`zrUUxCV*zSq3rbsQRs1|9lq=^t(HH8VW!0xk} zDrTADyYUp9yrg|Z?XXjmi#|$owFQC`_DS^|Y|gJ(w@~TMbEw6TJmQqn>s?+VP!TEq zeDfeTs(k+NB_rCig5y?p)fB>a#uoBe_uBqp7qT2%SyGp?*A@Tb`1fAhgpwX5g|!W| zDmEQNkFLRxd+rIvx`nLKF4rw(k$zJwLqWPNjz!@?Br>}uTR0v`=(x*LDytvf2*5oC zG>=K=q`4zOS!yV(gHNF!s?_XR=~nhYIOk`C%alPS{>(frt3rXg8D-59093FTN-q^P zy@RRP6G5iV8DpDFClbnahG#kIcH-@Ppd!iR2#xUfN!7W+`N*)jmCYJH%jW%)+|`sI znO^aBINna=jvr$rD!yb<67J-g$8Gjp)=9*6$}Az$lqQtGOSkeQt**cqen26=KqT;BM`(zD;Y-a@zbwkQVk)qc zP!*fRm7&NQcM6~dWUw;wkpGPZdA`(zw}}TBD`TDYZDNAL9bmvzy%o5XLZun96*kmY z#L273WVjNKDap{Jl)X#ltAfP!misw{r z_fz%?J4f?6jk9^a>D})tACi8yfMg~YCuXL6Z#`UXW;oQ5)$;7j_Aqw$UFXCZ_?#U9+o0NTs zd+dp~)w4|Mw-!BKQEi(JL@oLx?kX%_ z>7h7-+*i)7!vaUrlYZ*;Xq5q1<~Y+{wK2BBNQ6QaEWAgeAe+9$398(}Oqk7vxO^4S z6zzD6Da%^UmyYKri9k`Sl+&@v6cd>PaleiRObw`CNGSDfA0U3qeymu%$AOoj-9F>O zm&_A%GS^-6!&ELCfAI5-Au=Zjw2FmL60a@h>4wT8Q_z(bn0Sva)@H&~bNSU3Qcap5abL5=jxR&i}|t?BC_mt4^ut z5rT}=l)B`pH?P15;bjyaLUVbfqq!%yehsxQf}C~c<^b`my=eEVQ-kl|n!CETrCc}A z=7#?4Z0C#NB+j9-LdM!=y|%?#uuLfUtL$qOCB}=TQC@hAsQs^Z=S6-bZ%{tbRS*ty z`8e|Lg(BMUR3UU}PtmHkWV8!9VAb&afYr7rQ5@^Y&yxj+&n^+3z;GO!gpMvWft590 z!_j^M{!;I0S_?Ke4VxH)-f~UGZzgR;cj{NODx3(n zViJL){X%0YhTD7(*8!KsQ0$#GT^`ic6_Cg}YI?WOP#D90Q4lC%kBXDJqFkNxkQc{zxvpvR%gE<0B+G4-GjkUu& zI~LH17APk0nt;e&bc`}KAH`*raNdUD`n;*u&=~UE8kLUx#Xh#`W zp%l_U$z!z#OrPMumCy)Th)CkR*PyoVC?Zv7yFxt|@Xf3o+r{&%118kQmA= zu1ZI+Fw01;AJz40lKM`$_%;_ynf)obyMx*dKW0=&%V{5}h=-2_OGM?H(6(VaLbc1k z%mH-V#c5~PnB+{L;Q$-Jdr?oABu-E2W@@wgS!Fud;a4~&I+|MdiY zGiP{zlGJDHJsGY(z1!P8TXrvZjz1)ANKn~U2785NUn9OSIJM6~RVXyAMt$yl4V8Gw zqx9XZD0KnZW@SHM3KY#8M%z->1*%7-Uk1SI8{>q+Er#G3zfZSzCTXf)DwX$sM6?(s zMKu0n?lUV$0#4idqj@lM?mB=RrMfthgpbIbq~gewT5IJjMbyuQRUFX%g5k(Pvfoyvduiz6*&NlrS#9yO5HL4+f^FI*Qf0Oz z@Q#Mujxz9W`HV}2PPCC`iJWmye!!CG52P6Dvi$kvsIXtY(>K?ZQ{x$6Woa zS>!{<1XVyAcXt3uwjkRgj;cqD$nT@J8yl&F{1o{&PToA1!FWJ8NFFgkTnqdVEvKBIZZ5xCnBcm7(jVyg|*bd`lopR!^)}`if}41 zwx-PGMR200VofVw_t(A#L=IzkuG0N1VczWQaBFUkVvK6IC)2L@&5p&Jo&_v$m48Cq z{rTpfO9>y=c)%xQds*-DJBlj`T7YZeoY~eGQ`?gC-|`7?by|&2!sccYsnbL}HUlskUrfu-7tJm(y4+Mh~1i+GKf3b_Si{Y1ci&9~%kL#v3LO zNdUW;32FY$rwk;FT18|Czc_-i<=y=r?1$s9JWp%SvDh`6jgweIX#{1g z*K{#}3%7v%Y&L6zt62-XW9;?gHIIKqj-hSmL)S-eQb zW8glMbaf^u;%>2CF2IOaI|(Wzz0Sq$8rUGAW!=(&caqt|d9lhvl!-g+!?j?)4AEJX z`hO_MjFA^9mzd*em8EEZVp@vHJyp5!$CjCHHqokxfFIq0>-qsxJB_6O@wv~*_XOw0 z=a^%|Bqt-y8Q;X`qpSlMKt(Hf-IFJ_$6l+&OY<&J>JGj9h5+&>n1Ux{5)6|P@48cy zt>sQ$w@HG%*+Hh>yvvRi3o#ih_jG$;d$tYN+E6dXB4d9qy344s$n>d$-4_7t85X0m z7VRA+(=@RryAZE+O~anhK6KMUh-Zy+1i9A2Z56a@y+!-O&Z@H3kFZ88pI+0n^QZ0; zS}TB!+U73qGS66)DSj9g;RwuPs|I#W#!1BUOZs+1zasR?jfj4Ci%VK{N(zE>1QLzL zkTV21g-^0s3bQrbGfG{CJ7^4g5)b{f5R9kidvxL%f$TG>h1q9h{ahr<0@{Fy(uz1{ zedY*W`9=hDmUfnZZ@j?nC?bnnfgQv91O>A^G=U{!^yjGDN^fV<7$B$;CezRnH?wN1 zheB=4Pk`@5#d>Pi^gDz!^-PooM_|z;g}%ZiEZ@HMp(N!h)z^?PET3XuCiNNV{mvou zw$$t{1YI2wsg%e6I;eOo<7%dzjM^+eM@dZ{xDh%vaM~&WE~J@Qi>N)SpkTCM8o0N; zr1GcVhd~RGy5^)7V$!6!m}&P6MCDXsp`xPOo{yu%tZHtzs8bZl1Hk3j|ypgbru< zjB61SUyamSOcER+cFhc$ri;p!!1^U5;qqxS>)YXALQ#u#$q?PXC*sm-`+8+T+k% z3L4py&!?lpL|Nwc!M9u25#cdq!Y%7HDW6BBdnSe)k48~dbJuIKbBS{Qf8 zZeq$doobYh=z%KGr<6nocSVGNY3|h$b5f;6qbi)G-D>*A};xb%t6m z@}>IWAvkU;_kDCt3lu}MSTc9g`j&i|qL>kIV3>>FoFOSEh{xMmi1SNMPi?qKr~_zS zQ+Cl<`3{PBjUQ3>m)Izm!GNvVtCEL1@m*9$HHyZRC(^LDh>*2^YfL! zwpwNMWUf3+A-7sj3X3&qC{=d%C+QN|;iJ@*^{R7olN0kkD} zc`Hm-Ji!?NjV!$o=uR$<6s$Fvvf=OoZ$>vYRu$R0Qo~S0J~I!U^PXtnYx+&1k2CrdWHY^hXdaf# zF3sJFy|IOqky3iC#E2Lgvc1H%s0%qU)Zh-$mIeJEamfgW{H!x;8yR`IKkh=2ZC>nY zb~e+*f{k_zv3C~wWe>zPf{q=eTNn-Plc6KlkuIeyRs*9!>b7Dp9Ak9*L|4C74RKx> z#^#nQ;x%&ZNx{IWsFkTPxpufdNvJF`4IIX#Uhh>b!#$&F=D4c88VF#C>?paEE#=8_ zi$+i@eGshHzN%8+X}Sw;FoGO{}*f6Oo}dxnz+6)$z7vQqq7>6?#g6 zd9vlveb#^+!gKMIjg4hFnq0{PSmRVtG{7+tHjN0aq`Fkrdw1-l&l!0@P=cR)1xgr` zLX%T$8N!(9NFf_5)ZS+;lVnoFBeA|?l3E&!*c>V6pVA$&hkP>F!)|(}VK8&sM*!Pd z9tB6tL_rY#XQhp0N8; zH%jl79K$fbxNKLl-A3_$SBT`vSKf0*B8=4vActLsuk@t4h@U@l;yzQV;+RZ^Q2>&u1Jh|(2YyRMEd*do zd~2K|ifEYdsXNrTHUpuvqKwp!=|tti=!HROnny{p9Nfqx5Qt)tZ%f?g3`6_&DzcA3 zBgY+7k(r*eMSd6B)lpOL-N6$oV`3kRCbA|)ki&R|p{-nOO(r5@tym2V|K1gBp-j{~ zT6Dt`{nXl~K@7@7|Nm7RbHGc)q}AD5Jd?-0gnXPt@pGyE`xx<^uD|Op^@jU`2Uyaq zj`{IIOcExsA9ZBLrH{oSCeZ9zTeL6EUv#i@S~8SV$uT87_F>?R!Sb);HWwc=UmE^V zn{UZbQmiJ8wo=UFeBmtYpS&1fx;*I%aw1LNk2$$fc8C8e3}CB4n|%o%?_4*d;{ zWywKseQ;uMj4P}oHG!AeA7uFu0iJhkOPP>u8a4Ob&h>Yh43RT2n;>c^Te(;TB@Vu_ z?PX@yUj#RH(bU{|W_Doaw1-Ji`Av&Gj;R_dTfJQ1`kEjTe21Dm1!rKE`^@R1+mQ3i z76ap1Vlr_H<1m>_M#49)0=}ZU%HE_1ta@kq1PsT!%zo}j=^*v@U9PnbETo%cSr$WI zeh*ebGjlKL^&X{?;Hw+2r8-UDjpGsb-LZZ^l1pUOJUgeGCKJ~?jD>ZW$I_zn5?@)7 z)JUH(Fk5zDK!2bA;$QsJzxgNs=3oDxfB3h5{#XB@|Bt`@cmL$S|C@g{Ft;7Vy)$o$ zr2X9f_P-{oeJ zr~~Gik8(}+;3l*x!+>l4Ywju9rWW7SbD#e_3lNflY4U9KYo6Z?_X5KhSlclkqpo@BJSsB#A!(ijh6134|q?K&l;Ag=#%fkAI4ZQq_KJClY!HtXqy)Hg7SU7NBlB2 zJy2hM^Itdexrm5aA-_L<9Qy>q?(n{?<{w4%21aM)gm{^ee ze26J{L!1u>Wc>K*XMli_m>u{1J|9(loDj!Q_4fOH;7n6(Hj0q%k)RY}D0*qyn)yk# zq$ox?quhVjBD#ni9T+Y8{rva%D~Zpc58vOfxeB&ux%c|3YVU6eas6uX!`SB+4KUj9 zZ%@Ix{kWU|o@UJT6Hm*&Oy?6Y`n+K~_(Rt=AHf|Q_K1(jHdn6CP8xXP4x1FtKZ>^k zcad0XJ)0XNjwQqF(!v_wL3WXoeypbcv7W{PB^nEnF5t+;?py%o%3n zbm+C#{O5MW6FCDV!n;RZLB8o3*Yx#$ef;HtEN5Ic+xxaLHn$M%TKs_L`!HTRg81qY z-(SzRITD8$6t3ScCGs%s(c2&M=bLSJ#^1#lsP|CsulE2&(X^xr&My_etJFEy`9Ahg z1Iu&wf7wbDi|fY-v4JFr8bJ5Kq)nZzbg4 z@0q(1OuX9z9%0sFfU<4xmkQ}VdX=a~-iNS7V*@71lxekUR?ERf?UbDH`y~o+;ecz!o zFR2~c@m{q)If9_T3`6U8#Ty34s)>5+JyA|G%@z`~@z?vyjnWzPN?~i)cNJ1v35FxJ z8fDmBPz`4WP{aA7_6G$bVi09ZYJLA|EHzPmSLUSOrxBBrTZP&7cN?s^!XG4zyD#GJ znB)w+MdD@p@g9>L>@s4{8UG#~ln1fZB$m7PZzsU=(Rkyo^HnEs>!DVW@AY*S2ne`) z5bO7?!fcE~olmTL-s96?5JQtjq@OSE6TbBd4IIv?^?YxeTkk`_A3yweclFI@5by0?nq`6!u@8>Vb%ZG#6=kJzcZzrOZZ ztGH%LzJ5&&Zk3cTO2+ki-}V`~M5H^|_1~je6H5z=FmC)d+L0hfvXNx-}AZvjh7NCYEx&3^n??;FXY zSd90U+l+eG#3-Mq+wpzAH%rQvYXyw@epQCXs0T(b%U6$~Xd6FcvmtkUrPLNNG2rH{ z>V~4r1O=D!Y+rM1oRLub0Xx0tlAGHkAv2M-euZaTn4LYn@AwYM;cl&n^EBrJrcc2y z;<&tiEp|gO0HQ%Um-p<9N|nypi2wd7X0;yY9pP>tM1-PyX*@T^@;@$lw2m-BcP6*r zak8rQ0}{8dY239{>xem1ufGrdlwggOA$0HCdP#s;2OoHOD;?(s1{l=>n)g>pu8eyi zpEQ8mx4@3k{xQV<4jFktTY79y{oAOk&3-w2V_y8eQote}m#w#a4csMm@dyj4wI2oM z#kMo~NTzddnQCqqmUgV=zwQ7XleaJJ_x_%z#x1cn$9uZoRmb>dT{2H(LHB+Z9wAy= zeb08Z^+$yXG-DDfb@=@~Yepu~G9GVPw6ibEEQ+Jk`Q56LIPL*xUhhYD6)@43S70A~ zprW`43xlTrD??6pGGu2~$9HA|%1I;T&2lf_)rK7*xIE+2Z+DTmsy7MF ze5?0!Qy;oe6GQ%$ZGB(FQH8S)^3#tVTcio+m-wE)Kj$`o`H;5M*YNS*VoK4Hmhe@1 z#!yF$-g~vTyw!shE(IO8rF{^C=0~G;Atqnn<6`JC*z};n)}1`7LU$qZ#OLKaPCUDV`%J!27q| zF>^)HLR$78+g)R~CoGLw(FYPSY#^96#NZeH9vkQ#7Af`c*QnI%{^83~?^+b^%eDS!d$zF?fN68X9?6^x!$O40_2R$!=?s>`{dr$*Zt>y! zhjH~@xA+*x6Yr<5xT=`00bf6}CODZE%6vlC<|g^|eJaK@Yrxg7trmTYkBA-deoefC zD=V8p?0Zcf$+n{Xci(&fXtJVVbA0zE7=H->K7+=q_P?(gmZ4Jr7Q(nsFelBMs3?=V z<(w0Mr`CxVC)-b((KFAJ* zVa*(r_1g7<#YoX%CbEuuj}DuWSu!7ueu=!_lW%9pzL8&kE7FHR3#UMMf4gHW!z?Dh z$g=N9!;m`cQghtpU0R!PHidv`&<8Fj$85L~k-5HqG*(=)j|Fy4`~m!Z*ptd*UG@779=1`GVACip%&KuH*)oTBegQa-XoPc?LY#|gd@KL(ns@0p8N zgat{%+}8JRQpg}eA)Ni#Eig<_MT=o)K;;zyx{HqHM~a^V1VJ}{=kGY5^oN4NhE#eT;lND5qht06>UV7%>A|G=lcCo5iQ(N z`=$7^2_Z7p886@E&Z3J5n0o4XzmFm15>Fndvs(6>EX8gT^%ma$j)fwEaa<;_>U)g- z?F}RqXMBAi*%>FS%!v2v{7y8}nTUX&?L(^&mW^*SlDf{X)V@MKHL}($eK*(^WD=*! zxcLJf*7(Y5q@_u{PljU%rHAa}uU}^Dsw)8loIh^&Yl(lC`PJIaAICBJ+!;!`ZsF@K zj~Id9M6q9A9O0zhK@9Mm_qUG-+6l6W7u$DVE{`SS7F@nIH|ZeCRUOT?y-+Dx8L38M zwzJrCd?#@2j-3a$zxTO=%yq<28XS31abJzYlYSflt>S`oRZl zTpW#2B-v?v$h|c7g}t#^_4EDQ!Ntmv9OT|V1pLkwOT^D2vmM{=U{jKSMcT#>Boj4# zGXz|YAGlv4ehf_+j(vBSkXS-ID_i35x0~34`?iK;KZ*=^k{bxS>-l!PSQLgUH`wyM zW!4aBGQ(#lK94r2@19X8?#cb#CU|>`z`kW(J+93qxea=^?fmXLgUHlo9BG&L?U~$% ztGt3neqa~S_G=Hjv>o4o8N#v%!3neNgHKgbAkJixe_tP;1ic#S{_U}wLm#4BV6fwZ z&BE7VTX1uQ{f_q4V998QsPWx1p%z}l74CU|i|iECzhrKIod}EKL`O@t+S<2Vo)Dc; z?Ay0ms8$WY^+o%>e%+jW6crCn!uo+}r;<#G!N2(1rZP;mGUAP%_o_-6N>RqAV}Glu zm?+2iQmEQGwsV6d#H`M!kGAzV*WtGlQfkN zb8kO3fUU)C#E9egN;MEGOj@zMZ{Kj7=$YrD7VJlrDQHj|noa$6L+ED1R5_pW!!MKD z4gq7XaQ-+0HO%CwabuZ326Ulx@^Lx8>vVAng??_cen{<|RndR7$>uk@a%uA4ac3XL zdwdHQzm5#>C>`G)CnC*AKEk;dhghzdMfLT;T=oYlKu)*C;pKxg6Ofp^^`Ko}w;6Q8 zWDKvpeOU6Iq^8r)zgE_mBJPJ5=H53NhUhDq5fj__0j#?;O-^3{(7ytS<>{7wXO%(o zaa)4uEOFJ|eoiT6jN;$zhO+M|6-OX=Y0E`B7M?>56(T|EcU!+wm}0!eo&xvzqf}iX zWSoxP7pdL{6@L#qdjBzbMcO2ywpflAOEzT21JQ0R$*cIvczXXpIEHc`QX9R%mbfMC zyZ0ER9l4g_+x@=OWsFSQ?$f`&17SO1rY58FqnsmzF(eDX|NA}7!r&Hsq^@tGvL%L3 zn~0OpzU{bhdm?Y=iu5+?M{isZ}Kn5`R>&J3FqQBu+uiW|3l>;DE z{?GMe!~(bOPmp}>UViX&7FZC0hjp>8?~1!JkRx1&@7zD;b?GwrDD$X!FK;|moZvOa zz%O5s$BdUObbGnKg8>jvxB?;nCI;v@T6pxhQ$%h1=WMA#$z>GW~ z`YQ5f@f4N;a^XV}1->8&Oim%>nMT?4SPI1oY~q4N9wy*f-MK2+Gta*{cK1Hbx@93G zqcB=%%hL4j-ZMx0KujMLmBYw~)*OQQfKL5%phRdATVQJJAW4)`8COCV^@=sA{ZE^Z z$+LG=5b7fFf?9TX;V(64bcF^&C!WF&iGrfSKqvpo>|pi_;|S1dhQk!$W_s`vp&|_}1exiX96I%brh4gJ3Fk%pORWbfjsX zbjd%|xDpgOufvXB!0;aXtF=>iDvKl?O^Iup?XR?@gqo@PbalC^mAHyK?3 zcuwT2t=K+*bC5}}2eoOBZE6`(6{i2$8=w0V_n83r>Zl%JX{gmKSZ+zng9FnH)P5$j zd+$II7BF^(vgvJKbmL*1En`)BJT8(!r1jJ0)eYl~3zy7EH$1W5U$3Iqf{I%8#e!f5 zWd?!<%^??b@ph6*bq9aov6|}g^QP6va|@ejD0$y+g`|6Xt)ETxfQTfwSn3X*g3}^U z(SmZ018xT_N`Svb;%tsX6DM`akv99D#JIFVpBA>Eh1LST1Oos+K)}DETk@Go*JwAZ zi;%BmO8^|8{ZL`Z$2<^##@768!o9y9RuvGLp$A5Ee%e%6;OJ-#G<`i5(rrX0gy-c4 zc2)C+?g+{eeyS>Il1XD=p^i#(!w-w_!Fbh!Z# zTxA_kC=LsOz<`&th_nDBLzsul(p*O7t-0>H9NE;W7S z@txpv3Ge0nHU377u~#u!y`G z#2sT7EbDgxpJRd2c^Y8q+4jkgvWa*qp5PKY_hY3kY30LXyb5MIn0V{a^CX${RxoA@ z1hpo8hv^IfVt#>2NQCetl~#mOF%mNGKTlA-$U?Gp{JxYVJJu>cIKS&nW&vaiN7!O8 z$*VL+P@fOb{v~$+)E68=$cTVL9KDgk{)r$Ma!bZT?K*HKo|XVK?MY7LDcZCybJ3vb$cVYJ2Z$Z~ zl(<{hwX0y@BY9drUzWx*bA3>t-fT7B*&%JeUIQ*2{G60NY0f%)kn;hOMv?NAw;&K$ zQD%sQMJV}H>sMOi`>usf5z-P}-q6rv96Lm+gv|&5X@^9LR8BFy!*iJOh~y5?DoHHM z$BP{Ns4WYjckzOZ@Qml|JlcfgA_04LUXc(`L!ypD zjEkr1a6^QWv@qcN`hDp*Z~zZU+40y(P*$4o7$13Q1RsA&G9R&9=cvJN01b!pyYCsy z^t#maAS{HvftVP|YG9iK9GkA}O5#w3F;rn{BjuhE>Sp-%SSuni)g5=xq;9dFDBV)u zc0;{q>4nq8@CcTm#SXDV$bqlpL>9XO7aabWi&&dzcq2KM!_n4xa#eGs(5V230)hZu zleg@b#2$*=Gkw9q6ygX+l>I6=8eIdKOhZhlwRo$FS`^kUKKGulW`u!I4e06m@qTY6 z#aVLeGqk0#w1ZR03UEvIQfR!B0cc)P>k%ffXfk7gRFcOLdIU4Ob+Op4>i>t+;|AlSLDjM91bmula2`GT48q9L^zO? zvLof8xhOHk7%sGTk#QdyDj-*%10inGks=Mu*CK}%3%e0C8{iEuWa!X|CkNWPII<{9 z-9II%cP)x5;fFz0@$%h{yD4Nm;Lom)OW$=7-Qk#_29QBRjzLd4H6RPMlQ+QC8awo66gB~#I z-bB!21U`pR_6jm9r1*hs3QGX}Eij=@U6v8hKD(iqbl+Rp!kQ_CaFAEJ1DHeYeEJvu zok)w845>2KY!1;P5e61Th34&Kv`(3BB75p77C4`xO(f}*?rqQ^=pW?gSwf2n^b0yt zOx**c1q3cQX?g`dvcM%1UM|!g2c40@6(&1Colt4$;n3attIBSogpZXpJtjU4G_wOH zWz!}T?B=NyX&qy4qDDaIfo7w-N3*wHQ#>XI;u#dDQgMCYivi;x%IT|!Oo^i$3Wm4J zWW&w?2WP47aP@`h^&uC5y#hQdk^njECA8B#zr>EFsPoJ`*GFMHq)4p-9FF&Ir}V|q z4sOf4SyUwZ%~6M}fj|vRgb5xQC*{hx&T`0HgN6&z^QW4@U|30(N;ZQ||8-~Sopp1D z=wYB@qIhU1lI?jI!6F$M3Wk+ToW1u0zY`y75a{7Bm$ngkHkFWh`N$PmYcP z^Z`OI$$``l5&Y2*yTyq6Dm)l()7&z-g+~DFK*Ey>7M^Hd0X>5pExm14 zQD{UqF)0f|kfViDUeJ^U`_fr}2i)f={Xf>eUPRx zg5>$y2fGp^LmiraGAQ!!$n;FhiTEA}y*q}|L$|PxI7koy8V5-_!GkQ+6y_~dFy6jpfQYFCqB+%( zL4z3pURI|5?&Ac^1@TzR@Lzn|wj@Nw$g(@8V|+ltF28EV^j9)KOB|dQynH%%9+$GL z8W6r;kB@Xm2xa*hEurH|w1kY3s5{5O3djY4lI6pVPFMA@`;yxPl-;9wf=tV~X?lV; zu4h9$UAzJ*kW$DJ;DsS~kNIFJ-YSCoA-qMVl=tEbg$iU#g+aZ38&^j8DWK*u0tt*Z zJBs(o_AgpDH;98Lb14kJOqg(B9Vjou{yq;@0)$ZIHZ?D7z&4e~9` z;`+sU6o=AW}c9Z_+rl!M03+dPf^e zQ#g=GF0=r9WMJRBoE`^7l+1oGhXhpQDH!}2ysS{nbjU9fqn;j; zIs-D8s>}#Inrfmk#KqqmzC4OGU<9LeSCyyNK-7afLXEiJ-A@SF3TNrNgKzdChjEJF zbdVF9YtSsotN|-VCME3PB4KICH8{fxIy{>ZdDzhC1RjclE7YXn&=!bsz|i5Vj^tfe z#IJFUl$Yhazz%5HfuR60k{K3J}NgV^svxk*JQs+2J8vwip}*Af#OpaRz|b9FjZU0eDw~u7f7; zChVZZC5q^ztojuqz!BPI9C^jWP>a{^s7CU4ido0wYF%f2!ti}nk7L;0fX|ngiYFE_ zccF96C)R*MR7pOnzUSc8^xUhnP>)_eU zplF~2L;>qrNLuEZxj#I6FQsZ4K_fBY-0t0_UF}j}LyefBufYkCvYen+er+6t+E7EW z79ziYQ-QFt=;pZw;stSicBTBTX+)>{kXKWfR;V**Bl8_tXA0-4Tm*2sAwD06gRvEW z!5q@qbCywtN5CAI=}dq$@7rD2^MzAx}ux zjKi$9BpT0s20&k-SVI~VgtmFE>L;fjXj(=t=vIh6F9N;?ib?(iV z(ti#SzJ%AYa48}7L*(*tEImA6n^MYfnJIV&Na*qvT0rDe49V#L7a*_X0bY|_qg*^g zJFH6h7X*&Pldp?JW3DRfYo~a*1q6p53ViBN8Hn+=E$4p&dNLF`$SB%o7G5Fjl2^ zkoWoMs&b1?y11qkD&B+{d~?8?ta%5kA|Q6trm3);wIu zqR5*MXe%BVKA;ff>X2|Olw4n|z|b&=#gSk*lRvRH5Z?i6SUrIOJWpi}c&mCV6(j(L zIXXc+B2LUqsd?eitRU%9<#JE*$T4>W7%R&oE!jyp2b7sHy2MLN1ZPa519cqI3@ok) z0m%s(J_XbzmqzDJJ4fL5z|O!Ekc$D*)@BJzYIt5~`ax`3epFvFHzg@^Ro77SW7l1RJinP0?i4QW)p)S2P}UV#93k8G^y$O zkO6LOm1IE!{^=)lX|mmAi+IWpkeb=}UQHyJUbI8|z#A7m4o7T5;0F#OqGJo1nN?Eq z*kLhKZ{ZYLQX)r4Cv2r9FQ|mxwxV#Jfvn?jHskA1nxQLwt=-6as(zVS1L1UyY0(h$3P;Fyj><6#CTey)n>K*zQ-$@XJgqOr(v?yb69 zI$_Cew#(LYT-ZYv6RzlFSTs+)DIo5etc7tk^W@!aB(bJ-nSOWI_kdX|& zlr^Zx(ACHK2cCvQ-u`s%NWg#t2x*+CQ^b>Rk_~mW z;v&6*EFVuKQH#q%f^>j)C8a{Al8|=q3a=x8S7+eTE-nU}w>liJ0a0x#Ht<$Q)VdK{ zlK>x(pobA9fI=@1`E!_jHtiV*DF34KjinLHKU+1!Pb~&NQhb!@xZhBcJ+Q`@0f!sS z0Rov5UECSC2Pj7h>o2vO9s~d~n^;JD*Z;*gm>ynJug1B#xE_K`L`BI(QJU0R(biiR z@c@>rOj zFI)k2)QwBP98&$2?6e$oBe4-e=*4sLB5UKMnrL3lU53i6FSKGuRMcWdXyMA+8A;}v z9mq}cfC}l3nVRCsJOX*6%EU&4&Y&@wG{?nqc={*+qNR`)WE8#n7A}Z+u)h!@td8zQ zBnGnpx&*EWix?q2?F@G=Eq`!HLe7k{Boh@{wJ6^fEQZphHg_;C!aortiu2o>6x~@F zjBi^>f$)c|^a8RANV!KDUgWild^&uWLB(JWv#=RTK^}8I=}D}HS?yny{G617A>(>{ z_krjYX(Ub>^U4(Vq64VQSnLpu*2~_i);uw9UbBA2O+;^`IHtsoeFBI@$v$X#mp;Or zh)A%5$&?t52YuvIP;XRYI`)|{_CpA$L`Ad-C_)KpUitOPk*`V(K0|1O+7I2#p$jwx zc zaby$(`meI7otE45;DD*5!$IN=-3NG3%JY4{NL{6iTfjkE9mKo~X|Nbe8j!wPsDXDS zD*37&R84nakQhDmvG7d?3M6#@u$2$FFZeNe2(lwb)HK46VrlMGoy3POAt4Px&e*-B zeGSY(Qb!I*P@RXHG?LTVvkLksr$6m#K`LOeSx`|2lHrR0dM7HBSQ{UTXf65gjZpnmW2!FQ%qhbWOlMc(=96Wrc%EmGWmC5r?a^$1-6P`8}Ou&ce9K9K;z z94g~-PfT`0|_(4fV@8nH42pJoe5B}Eu=%d`TnuY~7Xl@=1h7S6y#v)YJNa6-Lr z3+5EWK2&M1Xu*r3VGU!ZA3+EWDm*6|HFgikCcwg+r0^)bc*nazU^tYZ0+Rt)zf{U; z9tB1OdOL%ZR0DbkWZ}jkDbYMUnlj;l`;V#0xYj9*1?pj*1$sXLr%WBd84b5Fl+k+3 zq}b>{1fI9>SoDlgI`DXxz#TEbN>sr?`+HZql~i!_6_f0J8H63^m&3sV2&U~_Ts6^- z5FQ$z__*hJHNVk=VQx%RuFhm;HjRx6jv2sUf}8<$AxkeMXDsX|gC;s{nn;dprnkC|guv$F_8G#`PFhi44qeM1^Srx3(!Bo6CF?z<-C~UM-G_#H zv`pgVtQmwIQH5aaPi^C{;Q(9()}(x`rc+NBo^``C%hHuMLyjRV zki8}s>zkk~nV3Nc3RrCf@tM!8Dz}rcUW$aO5w~=a7Ia0B&?yo$@NobMGZ-Kl$aYt5 zplM2Wff@F4^Cid?0w~ZHDIOZ>DP-Zvkal%eJ%TWRhlOQxM))BOor1x{3gg}cN(pGx z5onQzWVk6DFsg{o0x8SPYvcf1VRNBx397C*1!+P{1Um?oCjh{3XFzjVQrE+|q)hEZ z#jBWm`Zqm5&j5E#z`X)s4B~hKYr2oC8CX?-Y~|}pYu-C!6Ah6f3{gL!nf9tJjy>SrWojbfS z&*fBEs8z{9JH($<0RKOX|E@@YzhXzBm$YP+tDCrgJppY)?DL90P>*#>@U1WFa@99*W>``*!$r<9K4pA5LURHAFBq!Iw(wu+R~>S z&9R*5>`V1~2D1`QFND;nAVWirlZvgh7%zIliY*;#cPJJmZX2svmqJ0 zrM~&``m7AI3j?Cf#c+;kd?^I;qk%kKb$l$q2v{LM9R{{s#PTOB#d`D%{ zA_E;J=79$p^nS2`WCjtf-%v?J{&G5;P)IeX#LNp(Gk55Ktb^%@V9M)xKLRR@YziJf z4%<%B6M~Tk%g%98cz<^xmyW}?7$aB2uIS>oAiWJ74RZ__T|tjR_7^13`=o6lU!wr0 zSs<)ayCUJfTo8>=x%ayCRf;n20kM~K&U+BTlSoEp1y3~4Fw%iLP^t7$FVqiI*};_r zydMZ+Qfm5zu2BLF6Wh0gR=!6hlE@BP0lIy(R6-sRPs%B&@c@l8o%RGe+?R!#XKk37 z+Xz$VC7n5R395s+X?S-jD5I1#9MVQzYSN{iXpn(3E|^JX9#hSFbFx7*IN*^>VuAN>0wq|+xU1VG>YjPL>IUMak;6Rui!&Sf= z-=`stRZ$mkd91y2JK1g*0@*dpGA0Q0b2zMAouexKBtHOZQ5;UpB&(q+muiUiRfXo`LX{>dy21=BC@~`KBB7^3 zLU!#+8jeJLrtSU}NFPx2QwKAcvcM28(k~&*1faAO8mLf5@RM0pGVUmkVpZG1lq+kD zMh)wu;SThRsVnM#4n@K$4jmG9VOv3+Y+|#^<)HuOXB&=Tph;_J4JD3sGzyvL28nc5 z!O>MMr4mG7@Yk!zj?ky^w;?N``LS}x5O+oi;RpkricBq;0a)D%L^tzk;T!1xGVGgH>g%B)s)fd&o#iu7oaY4vs& z0u43u|3NDPmJPNYI?lS63-CDzZ%cPBWPdmY!ZfG-ULKK?3^$qB0mCfgGMQxFjLlV1 zA?c#oU7~VTCcB{{hdLYIQPwL#ig8DgZnNmpR+w2$6i}KFGy(U^aCWeR!J7vKdx9XY zJC*t8YnCwtYKHrmLIlVMEvZv5q&wV!f)iC!4%g@p39OpD0u{KRGhPHX(s4UO4_}`3 zAj0X9hJF`X#EHC0)ES2if=UN=H^rQAGIh30wKih5*wHu{lH!-nepF4WA8F2X4=YF9Cu1nNm}PwkcRauesKzqJLjt>2*!D03GG>8laB^vt9 z;Ie|oAUB4X>*Nzc8=z+#T2nB+K}{(kG$a$WFw=$ko8@8ebLS$ZaG8~_kSPJL7)Y%q zHTlE4%PR~vLR~)PLBa@Cn}sx*1Em*}RURnX9z96f6=`!wAZ5;g)0Er#l%Zk}-9J!Z zVCpni7>a-~MmCeDJ;DKpWW`=FlS6Vk@eP*lq!^Ls%nE|25O)j2v{fQLjrJqM-r~L^ zP(Y`;BTb&%hqsS-`#gkMsX;7*Tn?S%MX&rinwXw3_`;E3tk4l&6O~cIR7=ejQ&0l| zhE5A3cvE#jjYvfHIdKFj0rWVV@Dc&xjU6mNIqvR`KfZxcOHljJ=`NBs8j_+w|JSjC zl_oP1cji43tp1vA4fYMeLJr)q^idClnY@Bq>^Od;4s$4`mu&8Us&cV@T*yMkaPT>e?r_H;3lhQRyrKOu`h-$!2%e#6Y#$s+=6LLl{J!BqCtr~LHI%D7<@+{Sj9U8pzy8f6`C^ipBqRysJ=do zl_fA9WGp*xOfplV?ZT9E&M>tQGZh7gRNIWgsTpTiDG50&6k#hY+teKx4thsX8F@m0 zw0>)o#2XcS@(f0~jAy#Y(4zJPs+P261paiK0;Ljryu(Wi}4*t^M%zyN(U{)$}nrp zqX*^ceH-AY77>9TBT}#0F&T>d9GAZ&6rU!$yrqcc?}B|f3fPBNwSZVi29t~%O6CUI+Up)G2+lW!6m)^;{`gbsXloWMg{h)EX+;vNv%#j+lD&5ghZ8FwKrSIS!LJHL z?9rO5q|!lz+=*+5&!hn$1H2l!;+8JxE? zCG`n1;TZ0a3Mw38AV(Qpe^1URO%714U_pYSghXV9EY$4BSEt*Y=az^h=rqq@j+UkA zvcHz&r8sa4x*T9sa?tg%9as=MywnjpRW`FEGOy)ge-Q8zcA`m(J_sOFo{<=2>_~li zP+AP^EKtkv#29*n*+?vROBVi#C?tvBBS;sMS^xzFEfW}pNjl2VAzM^KprzLJ?Im#L z{Wk2pk%&c)#ktE%_~55TUdDNr84Hje^L zRy0#uW_7!;gS415cYF zO+5t^Ue^Hxki67~bYx5sPbf*VHmIBkH=TIZYnrv_eB&Moa@5a$X3Fw-5Jk#?F~Eq4 zTC|0_E58B?fqY0&={19FNN{FN&<9rp>!Q?2=r5OM2M=j*&HKC}4*O()b{A?5q&5CE zNgp_u_&Z0o@3)5k%osTL70>-iUp33=xd!Jp@pdf3K0*^~YIs99k5~g}!bk#F@aVoR zdQo~;(TU9~SVAKUskELnCyKf!lPM+62X2+%b2F=L=}#X{D@6@8V|NpkhFCGQ6mTou z8^xNUBFG|2bXezpx(!V+8J%};ox<%R=&&e)`B7&yfrhw} zYZs%R(0-WdrPt&I7=}@#%SZfhx+~Ld&E{!$g{qbgHtU(uY3L9h@+g#}RgFd-SR*h6 z0{s-#PWS4DIf3G?7t%y{M9_w^q8?5Re7i_DRtl2>jXqFXQIZsV)bK7wm9i9Xz)~2} z*`c9^ICl?y9Z`e@o#-`qRW{B47$Si=XraY`um|G|M^%V`GXx8z?-|g0_&Wpi%o4A! zj?P(hc2m{K;jK_`dmP3HQ3uXbK}m#xIGy}RcElAhYuuHhaNvYh-lhBD`8j-W3MIL$ zxz-B7mK2e>;TWO*!$xdkeFFg}D2d5{2Fc1AO->qOeT*lR;dsUdX^M#x6H-3|^qUC8 z(NYQtF++Dc^8=k6zg4(qV^EKA)hOqfx_bgw&h zB2IBe+Xx=PG|h_g;TepKi7Pmlk#xHe#-w)%0UN?)3mr-1pBqC14imTcrDUxVZJq9? zLhdT!d7*C>?yb#}vPsAWfov%((o!!eSd1cOQLBXIXRx$m<^=>EW!gS{?UII}M>iOH zq#Qhi=)0owN>=7kjW!~_qm6vW0j9v+ju>>wG_N~q6*Lp`S5PB)ImZ`34zhSy5-(o>G84ws zC6TT>Y7rU`1n!FFg@;{I@}?1SD=`}5Bu&Z;emA(Ka4B-UB|C6h!;%BizW5d_M8Iuj z5toacvqLQb5eK$isX-(ia5q~N&-%kHW7RUD)eUFc`)9bE_0GTugsoNWJy z2JbK%SSq=@f*=#TiE7&L=9E+fr1aH|Al8EhdsDSbX&5pUV` zP)!Hsx}d5%qj`VOS=kIdyBcgt5m04}y|0jC$=?R#N>!P@AJgOSFU$zpwcLPtIGzmO z9(ZQh<+LMA47yAfh@r`)M4ReAzAretu%cC??CPuJkamG2$mY3oJ?I1eA%wibbfC?k z)^nil2>W`#ee)hC@S*}XrR1;xpo|b1CBbt9+u1HFV*1)nf-z`P5yv}+I)V;U!8|Lu z&;{PI48jo(sOdOfLv$rbK%~+EKCYGv;QVz2k1diy{4eFl7notl4#?i5K+GQ>PQ6C*xB=ma@X^F?l- zAIOSAqC3nOFV&U3VnM45LbHy*7dy-m3gp0WN*Bp58Z-I~*9Jn}i)K9rn69S{NRSkQ zQnHmC2CqqKLTEk&PBsAhBvMK8b@hZvxt`AXM@ zzS_(Im(5JLQAx|=z(0~v86a00Bi^4HvNe+1P;;N1F61PzcMz(C?YfG+lY?5a@bO)m>3upFY=X3IR6lth9-V0^GK8&2fd%2IT@q7NCrD#Q zisV8mU1`W1Bq9<`4i+egl5zw^DvT%OHqcHHq|n4(G9JTUiLQYcfq({>EHS@zA}CvC zin4%hi=ReFl0zNNnmg8Dvp(Q)W3{n@dKhg~M56XX$N`E67F)8Vl1A~95xBw*r5yQJ zUyp@7#odE$k$hoO8D?~&1_x4*NnlsVt`}-@hz&IBmvpWB@U9`6Dn(+AKsgahO|cpHj)=`d$SuKA!LCC_C7?$3?ADM zxS1I9sJ-P)iMix!DAY&u>X3d~kro;d{2UZ^z-5CuWZLVU!$pe)1p&cWM=+Kl+JVrb zW#wCn6I?p?Gg|pTm|)C>HfNnK1&$gFL?!Z=928DT_cDrZ_)7a(%3zH?CheLP*f_wJ z)Zmm7OuwQvToe(I=@vgmYl)1*uAr?JTmdrH1S0mWRNSyz?Kg=qP^q|R0ptvnzoZNi zeSKcKqGYox%xv4FnHzB zI5CNvg%;6HYdS6&YN`Kh$FbRJ`0kY}0A*snTI0p|~it?|N7WQxr+iFGl&xO7?;XjNpOW6p~}3s3fSt? zrY`XZaOh&Z`D&liMp<&+O=0Gbk|aV;U4ukV;;4YuG&sx%2wnitAc^zzUM_AVC=k=E zTs;6)yR@RP7$J=c3Z!^N0V6~&Kf1RJF#(g(>Y&hzC=~nU$e}I=dW^f6$Q9lVq>zA@ z3LNGN$~&u=sL4^P0mhrYH&FGWjSDg%2M4GI!UeclZZ6A?R?5DL3Z^kTO*mnm0AK~4 z`*8m+dIk*R@xV%+D(uYxrvxdXaX2h8q1*%k5o|{B!kk`D7jK)aBZZa{tZWz?bpCvu zPH9dy2-7q4=8{xEK?60BlzjZj(iNHt(ZFVVOj|dK~z$Za-()aO&mn2yTZ>9Jw ztEiI+2dv3Fr#p6$VHQ7U(3QNcNYjI9EOYSc-XlwZ@oq-H(Fss&ZtgJ2YXI*t`C)Z2 zzHny~t{v0Y9d8*%90a`e`pE?m61JJcdDPM6DEIJa6O;$VJScJuah}RMWwFMu+0E58G#j+OEVe$YC#g>CXvifXxt7-%SSV92&fX& z4HDRBdBdS2+if9_u6TDagJJd_B(RZkV1JMo2Uw#79RT#9;Nh+>==IWvORvRVQtOsP ziUt@Ljop9|LQP0s5)L^4Vco)c8YNAzHONB^?bpEi@?ocqP?viz!4Rp|xptQmP@6mD zlQ-#AZyk8=@Sx%4u2A68sLLWU=ir}$r`b+Xc5py!E)cGyLP|xw@Vs+%D8#852qobb zgA(vz7AlgMa@Jv%2&F{%_qpNSMqEshF~hWXI;fQxNH~J-xZ=ElF?1z>^~>xUs8s_P z--Lwmxzd^f`R>z*YjoUD6S$-|QFmI*h5>;{(X_{`pRol2ocykZf)1yV#qhGfW&tT0 zJPqyXgGe<2MUn3TDk|r5{5a9xY9k=ZaC4zr#(%=|Oxk*#%%-B6ADmXP1;8vM^N^09 z$Gc4amlWL1jo=9q)=>djBbaeZn;bYj1!xc{I*?&q(zkZovKlZDDBV%13>LAJ?o!+-fkq1`Xz|7z z_+pKo^U^l8o=*Amv^qf5kq;M8ydS6g(KNNTDN$>47~x zH0ftqdRK@Fn!s2^p}#^GS-}*jvSruOQVPHq1=BG+v#XL^qA4y1;Q)LPrb^}G?jtXGFe7j5tMrfXuuop?B zh(`t8nP5@C8~{=t4?83&a6UQWP2kb7kTCN=6*G2E9$sil1JX$vuSdaTW{Tn$PnU}# z20&7gUZLZ;u}$*JW*R|k!%EXf0*R6>IejCdCLye@|dAOtei8Y5@+ zlN)$6;MNW?!3U@Si>hS&InccSBQYEZY^iyYM&*UVDE@UJcPD+Mf6&_DqRx7x9t11WrTN$b|C`g7Z?CjIcmd!?p48uOy$ARNTr_m}YIGogJcT9*#l)9Xcc$!IY~6A_WFg6yAtvctB$-KtrAaq=hytu^ugm7+Ww~ zsq}~&o#`4l!H`rm<(on13|UNB$N?PgPU}LJ=g4|Yq!TSDQ6tMLh<1?i$R;z1Y{E6L zmAHpQmxNjOpg4=8@g=^P>^_(<0M|xdyfGSb72}N8 z21a_hge+h(4JYNJA~_tthCDN7C%DR7f%4PEJ;HGXV;~ z4AVzT1&#|New)+!;Ckl|bWnzr>Y`3NhX`WOwEu$b<>X|Ma)jr`@G(0(rlM!+co`%d zQDp?I6}sTEcN80NMB*#jCWGlWCWS3%PmqeDxyFzp%+}FAKg4<~VV}ZAT!^|e=dRIB zNSf|o;-Q-LI7Wma#WdhGf*?g(eJ~ z52l7e&bu<~z&w27QXWPp2h-P;k!14jM1>6uc#5C4px%SrFnGekJkf#f03E2HC#kz{ z1bQ+_ZmDvD2@TLB-bIE3;6-(kt^xiOeFo|YU?QzRzEO2Nk$A6FTGVu7yue%R5EwfU zmRv@hJ&h(J^+i|;QV}MB0JPCO!E30KAac&Aw&oy{1{9PB9;BDJ8t_iA@(VUy4w2ye zq&-Lk4Op|1CGZD{c=-Z^6Fprh1b2r86aw7%BO%YE9IrXKo_u(jMHF32@9{0|y91e@3xI_> z1e$kEYDC8h$ufG&as&bRv_gd4r-gb`2=({S&C!S-@%ml@)DSq0;#QIp&lLn{L;?Fy z(GmSO+fdFn*p4fxdUt6}BKF;)wg-MoC}o0Xbv82oO31I^b?$IW0M4LH0pXO91Mmm0 zbQ703AFDRfB76Ou;Ie5AIa&c_F;hMHYdP0&iS`S^;td6$t(O5Sb2Em59B zNM#E_{aW-w27DrA7@mhz}sITdu|Mw$qoP`JHnTN zF6e2>cA1S zlE7vj4y+j3$xI9~o!)@%rCGW|#sc2zS!2=E97tdfvUt>`>RSw1FVx`ORX|e}6>Y&} z11=NCT9XOMrBF7gXO~$qxB3`hhhR8^p4BmX;PXg38NZAX92|gKi4va$&-BIGO zMrf#EsZy7hRJ$+%2o6yAGTVmod;%&Ho#M;13ipJ@K!;)13N~jG7<|J$Xa*&$eM7TM zHz)@@ca@MR>TIFID+dTVXM*4zD}|g6_9ISe6z^UIK%arfBLDQjk^vdZ@G~{dDX%k% zM5;;SUK(;PyR&5OxbMsy?hFAFrm@G#(~5)B3Zr|`x+z3`YIR5fiIn7sYCJ{a=JK=a z#a;UF^cDI5=2cWyC*JOITXx$c%*@#hq;CYI5}+Mj`v|xhz#pHNz*u9?;CP!{6?q@J zQ@UgsK|W{almGOoemM^DZYV62y_6oYdN+6DnBR0RZ|P&C0MJbWS>gqB~lC3|^a>IX!5gR9etWbm9^Ku|TBD)F8=E z*vfFSs~5$H;0P4W-lY1vqw*1(cc7SvNO|_4+`*DQ)LBAJFqj5XlA|JmhPz9hBhf?G z0b%bH=#>mny7WiMWqqbeGmp|dxoMz0d;r=)BO!qfY*aEaGIIbVL2h-LC68EBLB~sS zS0oHFobepySYkp;QnJK$XBAY8K)C!n(Hd1(f1lJPG<+-B5h|WS@P}3g!%K;nzO^E#!TjL;y*NJ@OL0SMwrtn8=(>sV%%w}OR!i?2YjMI z&zTbdx0xESo&<(0Kl^cbGoJ7*xII{??6h`hFI1%rqNOR23{~ZbOmbF+6*?848Z6=QOnmrLqQ2N`k=_zbABK7=9*o`$xzO zcglrpcwHl`{3bidg_=TuM2nv)CNgH%v7feZ>4aHYgZmiW>aN(dD;_6X9CW?+l1PZp zdZ!9sO6F(4Q}=V5(R3Rx&LW?N*h^~SW>w*)vSY3#SBS+Q^P+sI)(Bb+FKBvj#jO`f zC}E!Hlm)S?B(`A0w~~%RZ-E1iDX>p;DGEjZWd-CQ06=1FqEl1{BM6yHqq>sydz+2K zb(pRUr>x_hpiU+!!p}f{0CmuD^f_`+!7_6haI)0!>0%ZXZVT0<+_Su0X_nFq7v@S~ zqANkGgaJrUC#B*t0aC^;J#K%VlP2sl6uX0IPnCfDD2_Wq0I;cY6>QN-p}_G(bf3xf ziBMZ&lslBvtRUwj4PmrpT6X=mg&89*y-=5`o4QblHyNCpC~UqosEj~p zL7*Deez_Xj9KS8A>O)p}M(A=;Vla>9V{-{6;FAOuBye45;SA=4J-Kq6EMHM!>QZ1{Eew>8&T+}!=m zJwQ}-yfZ2EOCP;KG{u_~1ij_WFiu3JIdWs7iW$lVf;16n5jrAC|I{8`J_ie8`lqA! zPV&$~L^O%C_MhKf=G;H(AC0Qv&rge6h#=EeoW@=W4ke;8Ajy`#2z;3CO{W}Pn+dsX z9QhHe4w$z3yV@Nogo~)=~T!qG^YN zK0~w2S{&ku7kM+iZzD4PK-bi4d-QN{{25>Jt_63e#GIcc_f02S>vb(FFQqjyDi5!= z(G}sv+d?zicVBB#Lj^q8{z!K5S;&4~-$T7-b3tFNk#^T;MigitNp7I=M@?5##iKEw zLhmh@do`6|fPg-KcZdbi z1guRa5u^m)Xr(zdu-g+QMn@${r>5zE*w*3JbwYR2bf!UH7@k~{@)jz&vP{;o()5Dq zKK3Q3g)XM)+xooSF0qbE0f>-s4x?BiS68D7QKh#?#0-e;H~+CjqPf zv7#Q*NG4GH-~q$&R2U)JRvmyCxMg$t;-#oDm2^p39}# zMBxzaGDBs|pkl(hj_!M&WWIbTb%uGm$Svg}Tk3}v@${#K;DS+OPn0lFz zF-ed0;-}>yN-`n&zW6f`2T!r91v{nBVNo0K;E?R#{odI)xInFMQ)EBk=oQRYE%T1W zyGGv1PjN61#WwZ1ZQlK~XFs6_vM(cZQ(IjJGdBAbJfO(cWR+;&*bM;=jv|z%7(4p* zQk}?C_sJ4MN=w9WJvJ1~7jm^`X^wEW)R|n-{YN|h3KMWnCtDK`tcm7O)yNb0c!c*Z z2?;f-m#H{k^irO8j9kY^HXWE@y$irM4m4J1i`dJ`3FGudXFfT#fRYGkV+fl)Jj&;W zC$cv6?u5O`D}S$k%@_?ZozEaI60@JOM@60C{1fQ>4#^1d&6SEoi&M2AS&wJ$XP>P= z_HS!*+Z#rUjE?_CYaZqD+Bt6OLfu@J_RE@o<|j@1jAKz8A@&IUgq7Pw`OgZ?w38WM zcOqKR7uLF2BDp09v-N{?t*9*iR(?$TAl$T%`O}NJOh3WuZJF9bE9b;_OZ=q63sKb{ zYKa9=6AH(RD%5l_!HjWb3tY>*Gsk8%J9;QQ#JqQFV3rcEJZlhsq!6>9j;H+~}t7t<#uIl}cf*O694NFEGNnG&L7(8a6{;n(oRVPHU zb!s_C$Ou)BL-I2=AcLtMaiyc9YjPv;&z`+~pLVKgh$vQyEMthcLKso--p`s3h6b!kf&hu+2-aAY zrj}28u&_JozX)uY1j>P2)+TOb^Sl}AF|g$ZrJblQ+Dd5>`y~~$Beogi;;83V<-?aR zVfEp?hg!Pn9Fz?s_D}rKJg%p(d=Y83qhc%psi!{Pk>5V3T`95(8BY2C7%z$P%c9bWX9ef7A>xNp0(hJ6ZjH6q1fo)QJkgf<+v_lL=to(-*KM?{d zy~H)$SGvN!&&2B#tZORgTDV0pv7*7=O074^O)n)A2DK#6eN{lklN5QF#v*c2DnE9U z)AzweC<&|aI6+EHVF^Ohw42rk>)_~WorPsC-r}<{P#Ju~n$DJ2LwAToxJJJ!68Ryz zNV1~${pmx@OjH6T=&KegO4+wB$uvnkIPb}}H>6%DL4q?RG(V!*2BTQt%ZYGoA$qY+ zIU>f27&+LO&o1?YVITl<3N<&%h;*gn z=P2DJkuDJ{Fp7ev#^E8pg?M3es--%aH1z$CBlqugfPm?=KFE`(e`rh>S0jtDT=%%` zyDjOD2p8t;n z`!+&i%Dd5ojf!;#9}4JNMAOH{u-LE#5yF2S<8oHW}%X zKFIGLFdNd*--iaU_PKocHnKf{r%y>fVr@+x{`;Mqd%>c)2z`X*=Siu=<|EPtKI#6F zq&#Y*vN<_7LTSY6vt1Vh2g_UtI6|RI6c9dF)ZKfZ^{;f8Hhn$a$5Y=)E`L8OPsb=M1 zM97C`S+v-Ih--mM3&SELL=4s-X|>M{Nj_Ger;ZyUdQo)JOcL>ig5}d}5!yt2Q3?&b z&y(!a-&CGnV7bJUDG;_t;17vg!x3 zd16lfsjQ*QrbUT7nTyxho0rcZh8YAKS~ZnwPUR5t({InlZ-N&8z1)SYF|B;p3rG4s z?NiB-u@f^%X@gjh5JwlC$UdQGsFNot2_D80!*pm5=qa_V->qM@7XN> zKu_1pY0h4Pv-fx@k%rZi$~{Qwh1*$;(bKjRg!hI?>aPaRF?&n>z@9oL**9ik`-U4D zwqv{neXOUZ)^^c*qe8CSH!{*`F{6=2MdQY(R4_!G9x_#XnF9~mzO~_~BWFYxlUS%i zF5&{+sEu3b_{q;BZWJ6ww-)0Z7QiB%s9HYMHhIIT`7~npaXRtH#T3D9_u5=PBhu6W zDUbeU3xk<}R<6sq-dNdiCHwtvejy~rtdq5t?QWwz(Kl%^yFGJOs24g>a=1d|rV&3v z@s_ab5=j|nRfsjm^t{B0gt(MC?ft}JkAgE%Xt<2Itj^}HOb5vAa4-a;Ns@=qe@DHW zCPaI!5amR6@T;`MQ+Xay%{SkCBjFkHTP3BR;f(5MAy?1af1N|=PZg9lFbGBCJL^JO z{aViPpxe5eH-@bz7VBWjM!136w+^#lo3=WR=Q16-?vS5qVve=2z_YRlLTvdm`PC zl-fMYlzuM(Et6u*Ut0hvJ8gQ2mCm8NOp$hk)mMT`_I);-W6~+JcPr)>M__~SqS}=r zjC)UMnwlH#cu1kEBuU;imCC^uKHdU)#*v|NA&j1%`vOt*w&RY!{hRjJS;W7hYY0zV z_;Oj-lkoK^txtJHX^JGJVk6g9<5e~yqEOn%Eb^?B;c=6pAn*h(|AYHOb_!n!%e9sj zVxFFvW>{iZp=W0=ojv8h>(G|aoDigJP>XrjX>Qj ziMd2jGmIf?H9yp<+5lW>a_P1us9n@1tmH=(*k={oYZY%Zzc&$6q@H?sFdz^zpBnt- zhN6drN3l&5Or4={jk}##U1+dp7^-}#Otu{P(x)0>qi9Bn+&^Nv^>ENi@fp$2oSr*B zv9Zuo&XqD&$Y!wZjSi8lP827H^TB!v-n<>!n;=RXE4uf;Fu9$CtEZ9EU4l?Yy#`}@ zl)`ZHFZ*-Z&ZIHe$2JNN>CZj~X7zj-KS4ku!#9IiGulO_T{+qzk5-PQXVf%Ut9NFU2wRM4*P&t4IQpAt*X zFlt1G&{Y(W%TX&MP~n3z7V;T7PAY)Poo?^6z(!WHvJJ9lLwU_ccwLA%k3P)D1FiS9 z3fIUduTMR)4q^W2{ER^|)IQXEfZZ1o+F5~^BN>VAB=v`i#Xu>Xl#bBJLA)rlm*nYl zgv!r%h{B3OVwv4a_dlJD2rxbxl%-C9FO`cbFt>I|XcD#<=d7-Nvktf$*U6O@OPV1yr_BqWH0x@8>=$gS4fSqUtCloDz)Yq#L-5V2<)(U_y^sBzZT5du z*^&ncc-m}G2l26Q0hj!bf)p-M`~3gJBjI*cyiV}uUqa;rnYN8EM8}dVr|Dz0iAhWi=3_EUCOP4-@JdT^?>I{I~lg%bE^eZPa zK!N1i1amBg70e$%iR9WO^B9Je4uMZ!e1Ewnd#=#aUgCZ`+yzW7qIF$khqb3$gcX!y(?^`V>x&n_`Z>S)XtU@Tr>`3 zZDb#_JLLrTjSsiQKn(2cE%kC%Gy%ZYhYNG zeW|5h)ykp`a4^fh*;4Pfcixx5jX_O2AY-=ugr#27il#l#BHMo5Qm=JI)4>p6_1!E5 zg;}p-aM9sUvz{G<_`kA@`E-*T|4UIimttPf)fd&(j+{SN*y(^Kf&P6mj;Wv4wk^x6 z>_87WLFC_{nA&yI|EK>uJ3HBG5B=|=dXK=HF{AU{!~N=PlDa$>bO`cMe%I5@@wzlG zhmnwf`|HgDEL#ijDI4yqlppYX3_qk1^t*KIx_#cC>zFe7S9RAB((QA*a~%QiKjaV= z+`O)aZ8tlXJjJC3CN8$DJ}(=W zZ9Z|_vA~bl%W2@Z%u`1GuiN{FG@z%Q;n#<203MB>$IH&xX}8cO#|#ntYe2KpG6{$hXz|Hti~nM%eG_V&`DtrL~U1;9tOcz~%nt?t1>H%frnzi6>ry;}tw2;ZE=M zY+EV78MgBX#}k5|%_RuIFFAJf0z*`+h2sG&T!LWhmvzV|?DOG%@hrUK0Dx%c@wJi= z?0G@XzjWBQRM;>_t2A<2EM>y*yZ!^KymD`R-W}fjbaSu}ke3hg$?AH%dK}IVEOET< zeWCXG`ETk8f?%Wl>q^3~n@8rlR>IdgG*9727A-||ucr%_;XIJr&F#Ojy?j|YynnXN z|6=?X?`be3=r5m9UpD;aQ9A$p>+NyOA)b=3uh$>wgtTM4qi;~BPZgf;<;CtHhY@Jn z1AfyOp_C^$H)V8^Wz>;Vj>lo>_q2mWk~SU6rtsB2MP~aF>a0PtxzXwMbR3^bv+h@B z>(eHDa1i#%N^6PGS~5lKGaco-se2^Y%7k>jaOd+*!_(f&!C*|bk)N}Q|9@Hp--%oD zeLW$`XXz1w9nEEHNvk0|kH!Vu-G5WdU4PkG_~l;!f54*gggss-7z)1b?NbORG2V{y za(2VE4_O2|W`y%WkbJzUyd$HKz>PJ3ud!|@$!q5vM@db$qf$Ug_smpTz+KI2a-aqL zDBvFj_{AQS1d@5x_k8+snBTHLI_z_75_p{!ILprfi(|g441`g^|12M#kW>lXPIIg& z!JGXf(BOlh0Eb@z&nX~`(oMk z)SrK{?vVF9t(j%m6>#C%O_4qkBAA}-xFVhvSlL~zh`{{@Haa92YXm=%MNsmQG_n|;Z58v`rh2;Kbp2THv8?i zoSwf#IZXQ0PZUvI7TC5bY)hRFNCvi#z4msE`&@zQABOQ9yDY(nqeiQPCr{zf=7@_; z-ZARxl^!_s*L6o zN3}8cnZd8l4*b+8{F-Gj2>vzWyH4#3Y<$@@!Fqh1-Mn=Lyk2zs9f+Q5p6)O16tmxf z!NQIBx1Kb!?mH-GcOy9c0N}IzU0ENiv@3EYmRlHoS2^AYu&)(;bjOU zrMsc{-<#{Df{E=tQ1CJTE_89p&i;9)6SDbO$=-dpUG4qJ8eZ^_wqEYU2h3l_@mC}C z?GNy)v$$LAa1okNIzxXg6TAf5n4CvBR(iwyJGkLTLp&`3)k5EjH(u|ifZDHo0!e}-*J{-uG57nB|dkyE> zf)}sod7e;^EW*|WJE~rZub5zf`v_l8OVt(yv_t#Ahes0XL<^E1&?osRj?Kq7P;;8Q zrKd;G@a6-vwueV}I_z2o2D$c{&;Lm8tFHnSaTMfeH2VxzV1o)6ZaNG2c`y5drTqKJ z{rc@;?KCu=kK(=wm-&5y&8=C{xw3&fczfGAUaiD_SG9uRll$XN=7g2XuFDYNI5e0B zR-ea1PW7N-(~E%-T{>+}+no=<;p)8jR*O$ygABgWFc=u7@pzu?zw3EoQkQwE4CLC1 z{hCfG*ywis0^7Vra~^5~3{N=1XK|sgFJTHM&OM7~b^*phjoEw~T-Pwuoh!DS9!Ea; zfWgSefb3`Z;<+C5nC9iTg?zV#G}wP6(=Ke;B6rNrE6pn~r~An_UBu4@BYn`eg9{LF zd(QA=;Jxi@&h5xovkBev2GlrO}rrvFs9B~Bi`dv*0 z{5;Ej_`CSDsh8uo0}$zg1spxCg*8@n?z5jm*gsNt!t?O}zwNBS6Hsqr*G+*V0d1eg zJb3TxP1?)H*Xi^Hz2AE#g5FnUEjQ~IPWKy2u#dS4rNe_a>%sw#VTz}YJ*$uH~B+E;n1`8*UK8!z~PP$>0vd`GfI;$?SOg@P(m(T_{y=laVQXWd*`(X z5C6oQpwjj#t%O<2mS_XJ}ZoU}!hWuJQ%?LE>emFajrpCJNH}doHeOZe>zrN^rEwm4(T6t!x z?by6K;)niYu;?aKAMt**0nM7puV3qcJ6v6yI z{;2ht)B!hZlT}Jnkrv{-z7>VO7VPM9AP4(oXD+dGySe6DSYAR7QPTpyH$1O4dJ2@{ z41DJ>wp6PQ6Gi{|jc4%i@wV#;Ml9J4doBNPCl|7?5%~N0`S@Ck87Zg~q3lv*_sVz` zHz}Xx>UA~Y<*^pNc-rkaBG`jRBiP;H{d9rnDcttBH;ktw8g!Nr7yvju<#oY@=dqll zZDQXZU$`!*0wV%GRR%ujjvz*OVxHHwnU){hKW;!@8kg!0^XgqZ;}c$Byr3pwtZTh& zw0s)B>wb=#A%{8Ubff3u{m>UMFFeX!&*61s3DOVI5f*;zhrT4d+Vwu27Vci3)%8Oa z4W=5my{qiiPT6vPIeXza&^?T+ocllf!OnPHAN=me@v>~3wfyG}w~9n*WHbS|}5&BAf70cR-@0k7E`hi9>R=)lns_mAguo8iFz z3I5;SD_`}-Mu>NQb*^~brMGzB=0Pt4S{~4V2}XVoqpuHjhY4LT<#n6YPIcW6#4V5a zx|AGuyn^A~m^ibZYlDruPvPaajKTph!F&jNx6^A~0QDin8*JbUTLN`>U-7?Q4BiX( zZ+1VoAAW<`w#~HgLcOYZhf|m#_Pj?qy4_Q_y1ThCPg=`=9Sx!!+j3iW&4#JgskiYw zhhcV^!=m@T>&qOEsn>Y0XB$T&FM#R&oxA7U1{bUUimdLwJ@^bxy!@+X^1dSq8d$vv zP{~nSgHd)hBj!;%ywW}g$LUPg6;zOfM?qhPpv@#N6L^_i0x#d`d+MALHrsToW@0T-_h`JJ0Fn}D(IC363Jcw^k1ucLM>v-;J<)UiNSU>M3{ z{O;vIZ|5OJmsapi8Q=n#aso#>(d+N&&^*v{JfY}P=_G3LGa}EOT_=Z<{GNyO;+4#?``|EA_JZ8LZ>?4e7E4}O|;1qFgW{q~T zt@uruAH$Ir>kjEao2Y^JPky`heupNGRAv19QJY?E9v$cB8?I5WKC-n&?2b=MG_TM< zy_@x{|L!J0-dt-m3E~&OI!d~IZy#f_6Y@6vT&`z0423+qh)Zrqw0T+QZt2Zx%ki9q z#Aqnk3m2Ydr>6CL<8X%e7s3gB_k%-q`O|CIuYIwOf$gf5s%fN5@xNH3t%)=W)#dR{ zL}E?5iTUROj+U&hGE@Hj4N_G_B{O=oTV(R~m}BetRERB6d1s{HU(@vM5-xR8p&k(4 z(H}G~@vFgQ20B4E_WkBbIfZMc*i3!i1pk(N*u>+UXwb0BNm^!Fo_IQ*xsOe@4o)g|0pBq=>2A-rx zCn-7SvG=iafns#-hgCOH(5h#DAi!8YwkB?!&F$__q{OoQ%DqBFNr+17F9eLb!B_Ha z&Nmtt4GqH`Mse0tdqEy*ttnj|I;2B13wyp7)zZ0SgZF>jve90Imt)@Rr)u)7e=)C) z7rMJ1fo0tU9G#B6p=w@o*QK3+n-N0(Z2zeXqB)o;G176yXq*yk7dZKxc3PZEty`;w zEi_Y0xZ*APFih(VS5<6=D1nsRW@_MdB%$H`Nl4_3h^|B4m~lKSK=nuE5&Xhc+#W3i^wN4ZgDj?v;r<0Ifis4kTH^zO z6`$SFUC&JSt>e*w=hhQw2A{s|Tm4Q0E`9H!kNl-MP~F^@lqbiBM$4>ge=Vyn@O9ut zi)BocA(+N8umh^t7$@w!#YR9$0}Q^i9UIszql1`I#Q557)IDUKm|Z?)XOtf>gvf%Y z->iUUBMHVnSwJg3?V9@)=?SHsez_8F;(}862GqpzQVbD~C{W#zohH(LtD0;+DQ%Ue zos7~lqA4Sd$s87ecbd`F7^N)Z%b^(kfRn$_N9Gdlz5W6bLhRMGF!`BQAOn916lh8k z4huc2tINV&81Z=1q)iHjkR*|;z)rPk! zzs8VoBAm+bom<$>_!h;cTwL`oF7>T!G-9#Oa4Ih>Fw-KV4!UhJQJO;Z&c>F5P}mLT zCnoE)W~eBiN7zyvt-%Nrtnon`JogLR0TtnlyquNXTPI!=Q5; zshi)X{zOzShc4r!CF_Yh(qz-~AMkCyr5-E-Xi~LZCweJ0Ud)}r&Osv@u8F4bQmY{O zm5r!na08M;rdvb~)eS+k;R@1H*Y8hXHg(OVtrE=W5@R$2L@8(9%b0vER*fVy`O1ty z)vREeGuHL*c(IX;5ym*sV88wsKSQgCI-h0=JbU8H?)>C2 zaVlBhlXzz`RfKsmpOH^FU=x#`{FR^Yw&6|E_%K$rIVJI11)&7*)DGO63S~2YeUh-y zpn|oL^0VX_A@FHfs979Wl?)bUgvBJ^yG=>^?rnqFw<8M;iBW2h5plZt|9Am5lPW?{ zq6O2)tSuQ<@M`k!4;rTxyrdxb3r+A^Nc?Y)5kMxPtlF2z=S@E`-? zv40wH0i7mhKN7U)#eE?WL~ zV3GdCgc{c2zXg7``Wo{E+WRda$K|)`=4Ljf^%@tJ&R;iTO>1AW{&thYj|=3V;(Xb;Sw64YmeS~4Sc%hK#EN|Qbi>%cZ6Yl90NU>8oQijd`lvdk zkY0!`1-{>Un>10}9&0v~$s;4-wB72od<*FoN_^NUar_QzKE)%PNTOKgx6IjaT;|z% zDW~7j_m_?4FC2B~eL+wR&!sHT4qU|dW2>R`YG24RmwNU!-HBX^-G>}~FNikrYv5g+?K#z#X2l&ea!yi-hL0M zp^z^8lJr)9%I0occf@tNvZYnuVtz1<-q28K@F;g1Kh@mE8n4#0Tx_zbWl5k|yq~;f zH*jGtlfEKjyXTXg1lGqgl1^7?Tu(M9C)yPwTu=1(8H~?INl+tePO~F`N>JCPVY9(# zGnE_P+110_DxioY27{<=lT199Ek{+QvgRaA-md&hhkBzD)}J|1uRtbdA8|J*A6B8k zhSRu7_Y6DA5GjdgOc?)3CO@PdGuvQ(`|j-*B~kuGvZ__|(|lj{vB79c+*dN+9beZS z*HDEInk-fN4+&A&@E)F@>zn@4eGzQ@KwOx9XgZqd zwr{QWMi>AGDcCp6^?8CzP<*<`jISRTD2~&A0ry&|eeB#*l=-2JM9|FsVf%QFsIL-a zFTIl;JSU~1-*ioWYa*Y&Tu~~3YI3tivHE?Gvj8;bDbrugW_D-VL721_JjP-Au>Xa4iVh4+283^nnjCQKVl~+%-(He&JV;*R38XoW;!Yst&EFM}JVRpCL?YZS(6xr=Lr~2m z?c9&6T&7FlTzBq}GHDO{SiqfHVU9t_&UYQV)at(*>NxD~Y(a@)WExXQY*OoQzpAjB zHrlOSH;Kv;Axh>w6_-=ObD};63Ub*wpetU&%+&rEfo%f<5m-o(1_7oCx?jHli4>cz zssBWN{$ZaGwSxQU#(!4q-RjP|xdEMGn=8Y>=2TT;*?>?W6Uw8tdt;*xqnPjM3h!5# z@Xh7DJ>MK<7xnF=*aiHLVyY40`w#rL-%;vsXGRwQ%C zDYKPRKVHB1jJ=mB$~u=!0_b)-mP@alfg)!7fFBz_txRi4wB0ub)CeN=;)bM)gv|5&wP%nqCNte*M*NVl>-CT{>QP z&k5n{(7>uOp!u0*B4iXpz=a7nFW((6*hgtZ=9BGT&(cnN6DqGQ32JWlNn1io4=+8D zbNlE&YWmrR|H>H{N4zr_xkHa|0(R&(O7yD>1L(3 zQ~+0DzvjCU(@Q)jW)E%<4*Nr)~aL`_r%|8Eidg5rz}*PcnfmTyMD=&1N$?MmkVV^ZcH$}E ziR<(ak7}LYk|0dH26Ne)kYy`1vd(0a0z0&d|Y$) z%>3GXVRf+mzTq{Tpj3AR{Pwly0}j{PY;C_%QvB_#VX_P)JLK;ML#7WVNAfN=!oPJs zR#$aVKO9L#T{h{JNx7IfjFDpzY$PY_D8gIL9!hfQu75HtAkAeQaw5 zfoTA{djp{Bx1?ccQJX zBOg@#5l$7M;nU zZB@}^f|Z_$?R*w}`P9#yo;0%CJ3Bf$vg{(L7mOD1o_TF!(#uB2B2w-4x6kL6Nx-}s zxMX)icEIm!T$zTMEF(WR)1S%wKq?LJEq-`ZNs_mli^a%aEM@(1STdKD-)Eh%Vi1wT$d+HacZ2#UZ7<7}DnkuM*Fmuz z_s*-%EEL1*tY!jsy9~T?E+0>x*w7QA-8)TdZje^A7JaOBCoVZ4uHumU~GX{El6L`b+UEOy-%_T`G? z_3VG#dx~}}52%)bjw4nnXf1V@;SnU9Vyg1!vRw?2pRm}WppSCuJ~=O@?|)Krp6FP% z2^@}u%MUNXxGw(+mY~osdJbjPuiSCBsMusgN0HeQS&rWmkFw~}WM2~TufMME`UT}y zQUarS()p{!1Mi;9YjylvhwGxjpsx8m%)5)g>JP-=HcJ;Xzm`1&jmhuid6XKUAw|*_ zvhCj(omH#r$iIV8HePJxF>>veR9kC4ZIqysYvii?irUltKFBP0g9a2M1QB(Na`092 zxe?X?3RX+W0Sn0(tI0nhSskq}*g{0v&7HWBC7lc8;7rL%0C7|^vF~|ihhS3xL}Xz} zm)EpRztOT`;cbi7$z^oD9%j987&d!DJos3TikbPrBQ$^1@#*5t;+<0bLvc5ToRYwB zF>=lMQo+1?GgkM90rxgn196IXzO%evv?t=a`Bvb zfyKTJ0tGn9hSB#@L+{aPm!AfmWV!|C(=$cyi5So7LFGUb3zHkkkNV=nv|8?v2?w@$ zBugrX6CsI(sh0QZqfVj?)g0zMfg3;4Ac1vMn)V=>LZS4Rg4Mz0-#%}d#qfMiJ7txL zGis2)CzfvSMMQB&Q$79pK&9Jq{6p1$)1Ffd3Y2{L4WXPk*jB0mH<~A364nZkbPuyy zjp(=JW)|cckZ+2kR(p(4^g$4f76SR!0-nNp9>@$6*U1N3`M$g@W2bpFE{Q?`sY&wV zj!7OB=Gub#p%=T3$FA~5R%RGNYDC? zE1=yFnxpzv`>ROSo^&PHP&rrKev@?!tl20ZQ(Q!Ub;OuWLQ+hnR_nMd=~3f}1ELih zYD-)`$#ebK3`KNgL6Yd$YPPFZFD$Ln{T2zS=z?6-($rMbO+)skpudF%0DR+`r8Vm) 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

UWb1G6S7cx<9hTIsOG}# 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 K4qj(oHw^&L#^cfe From 055039eda68ae96a395aeb747cf3130884f30b45 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 31 Dec 2025 03:35:37 +0000 Subject: [PATCH 59/91] Some initial changes --- .../openmm_rfe/_rfe_utils/multistate.py | 8 ++-- .../protocols/openmm_rfe/hybridtop_units.py | 38 ++++++++++++++----- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py b/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py index 299a846f6..d238737c5 100644 --- a/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py +++ b/openfe/protocols/openmm_rfe/_rfe_utils/multistate.py @@ -32,7 +32,7 @@ class HybridCompatibilityMixin: unsampled endpoints have a different number of degrees of freedom. """ - def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): + def __init__(self, *args, hybrid_system=None, hybrid_positions=None, **kwargs): self._hybrid_system = hybrid_system self._hybrid_positions = hybrid_positions super(HybridCompatibilityMixin, self).__init__(*args, **kwargs) @@ -167,7 +167,7 @@ class HybridRepexSampler(HybridCompatibilityMixin, number of positions """ - def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): + def __init__(self, *args, hybrid_system=None, hybrid_positions=None, **kwargs): super(HybridRepexSampler, self).__init__( *args, hybrid_system=hybrid_system, @@ -182,7 +182,7 @@ class HybridSAMSSampler(HybridCompatibilityMixin, sams.SAMSSampler): of positions """ - def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): + def __init__(self, *args, hybrid_system=None, hybrid_positions=None, **kwargs): super(HybridSAMSSampler, self).__init__( *args, hybrid_system=hybrid_system, @@ -197,7 +197,7 @@ class HybridMultiStateSampler(HybridCompatibilityMixin, MultiStateSampler that supports unsample end states with a different number of positions """ - def __init__(self, *args, hybrid_system, hybrid_positions, **kwargs): + def __init__(self, *args, hybrid_system=None, hybrid_positions=None, **kwargs): super(HybridMultiStateSampler, self).__init__( *args, hybrid_system=hybrid_system, diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 09843e303..23394df7b 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -992,10 +992,23 @@ def _get_sampler( sampler : multistate.MultiStateSampler The requested sampler. """ + # Get the real time analysis values to use rta_its, rta_min_its = settings_validation.convert_real_time_analysis_iterations( simulation_settings=simulation_settings, ) + # Get the number of production iterations to run for + steps_per_iteration = integrator.n_steps + timestep = from_openmm(integrator.timestep) + # TODO: this is bugged! + number_of_iterations = int( + settings_validation.get_simsteps( + sim_length=simulation_settings.production_length, + timestep=timestep, + mc_steps=steps_per_iteration, + ) / steps_per_iteration + ) + # convert early_termination_target_error from kcal/mol to kT early_termination_target_error = ( settings_validation.convert_target_error_from_kcal_per_mole_to_kT( @@ -1012,6 +1025,7 @@ def _get_sampler( online_analysis_interval=rta_its, online_analysis_target_error=early_termination_target_error, online_analysis_minimum_iterations=rta_min_its, + number_of_iterations=number_of_iterations, ) elif simulation_settings.sampler_method.lower() == "sams": @@ -1023,6 +1037,7 @@ def _get_sampler( online_analysis_minimum_iterations=rta_min_its, flatness_criteria=simulation_settings.sams_flatness_criteria, gamma0=simulation_settings.sams_gamma0, + number_of_iterations=number_of_iterations, ) elif simulation_settings.sampler_method.lower() == "independent": @@ -1033,6 +1048,7 @@ def _get_sampler( online_analysis_interval=rta_its, online_analysis_target_error=early_termination_target_error, online_analysis_minimum_iterations=rta_min_its, + number_of_iterations=number_of_iterations, ) else: @@ -1115,23 +1131,25 @@ def _run_simulation( ) if not dry: # pragma: no-cover - # minimize - if self.verbose: - self.logger.info("minimizing systems") + # No productions steps have been taken, so start from scratch + if sampler._iteration == 0: + # minimize + if self.verbose: + self.logger.info("minimizing systems") - sampler.minimize(max_iterations=simulation_settings.minimization_steps) + sampler.minimize(max_iterations=simulation_settings.minimization_steps) - # equilibrate - if self.verbose: - self.logger.info("equilibrating systems") + # equilibrate + if self.verbose: + self.logger.info("equilibrating systems") - sampler.equilibrate(int(equil_steps / mc_steps)) + sampler.equilibrate(int(equil_steps / mc_steps)) - # production + # At this point we are ready for production if self.verbose: self.logger.info("running production phase") - sampler.extend(int(prod_steps / mc_steps)) + sampler.run() if self.verbose: self.logger.info("production phase complete") From 2e0ab35566243919ba182fd44dc4303df9db32b7 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 31 Dec 2025 14:34:09 -0500 Subject: [PATCH 60/91] Add the ability to handle restarts in the MultiStateSimulationUnit --- devtools/data/gen_serialized_results.py | 2 + .../protocols/openmm_rfe/hybridtop_units.py | 253 +++++++++++------- 2 files changed, 151 insertions(+), 104 deletions(-) diff --git a/devtools/data/gen_serialized_results.py b/devtools/data/gen_serialized_results.py index 9cbe2ea1e..13e5cb893 100644 --- a/devtools/data/gen_serialized_results.py +++ b/devtools/data/gen_serialized_results.py @@ -103,6 +103,7 @@ def execute_and_serialize( logger.info(f"running {simname}") with tempfile.TemporaryDirectory() as tmpdir: workdir = pathlib.Path(tmpdir) + workdir = pathlib.Path('.') dagres = gufe.protocols.execute_DAG( dag, shared_basedir=workdir, @@ -237,6 +238,7 @@ def generate_rfe_settings(): settings = RelativeHybridTopologyProtocol.default_settings() settings.simulation_settings.equilibration_length = 10 * unit.picosecond settings.simulation_settings.production_length = 250 * unit.picosecond + settings.output_settings.checkpoint_interval = 10 * unit.picosecond settings.forcefield_settings.nonbonded_method = "nocutoff" return settings diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 23394df7b..758800a0f 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -836,62 +836,35 @@ def _execute( class HybridTopologyMultiStateSimulationUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): - def _get_reporter( - self, - selection_indices: npt.NDArray, - output_settings: MultiStateOutputSettings, - simulation_settings: MultiStateSimulationSettings, - ) -> multistate.MultiStateReporter: + + @staticmethod + def _check_restart( + settings: dict[str, SettingsBaseModel], + shared_path: pathlib.Path + ): """ - Get the multistate reporter. + Check if we are doing a restart. Parameters ---------- - selection_indices : npt.NDArray - The set of system indices to report positions & velocities for. - output_settings : MultiStateOutputSettings - Settings defining how outputs should be written. - simulation_settings : MultiStateSimulationSettings - Settings defining out the simulation should be run. - """ - nc = self.shared_basepath / output_settings.output_filename - # The checkpoint file in openmmtools is taken as a file relative - # to the location of the nc file, so you only want the filename - chk = output_settings.checkpoint_storage_filename - - if output_settings.positions_write_frequency is not None: - pos_interval = settings_validation.divmod_time_and_check( - numerator=output_settings.positions_write_frequency, - denominator=simulation_settings.time_per_iteration, - numerator_name="output settings' position_write_frequency", - denominator_name="simulation settings' time_per_iteration", - ) - else: - pos_interval = 0 - - if output_settings.velocities_write_frequency is not None: - vel_interval = settings_validation.divmod_time_and_check( - numerator=output_settings.velocities_write_frequency, - denominator=simulation_settings.time_per_iteration, - numerator_name="output settings' velocity_write_frequency", - denominator_name="sampler settings' time_per_iteration", - ) - else: - vel_interval = 0 + settings : dict[str, SettingsBaseModel] + The settings for this transformation + shared_path : pathlib.Path + The shared directory where we should be looking for existing files. - chk_intervals = settings_validation.convert_checkpoint_interval_to_iterations( - checkpoint_interval=output_settings.checkpoint_interval, - time_per_iteration=simulation_settings.time_per_iteration, - ) + Notes + ----- + For now this just checks if the netcdf files are present in the + shared directory but in the future this may expand depending on + how warehouse works. + """ + trajectory = shared_path / settings["output_settings"].output_filename + checkpoint = shared_path / settings["output_settings"].checkpoint_storage_filename - return multistate.MultiStateReporter( - storage=nc, - analysis_particle_indices=selection_indices, - checkpoint_interval=chk_intervals, - checkpoint_storage=chk, - position_interval=pos_interval, - velocity_interval=vel_interval, - ) + if trajectory.is_file() and checkpoint.is_file(): + return True + + return False @staticmethod def _get_integrator( @@ -948,6 +921,72 @@ def _get_integrator( return integrator + @staticmethod + def _get_reporter( + storage_path: pathlib.path, + selection_indices: npt.NDArray, + output_settings: MultiStateOutputSettings, + simulation_settings: MultiStateSimulationSettings, + ) -> multistate.MultiStateReporter: + """ + Get the multistate reporter. + + Parameters + ---------- + storage_path : pathlib.Path + Path to the directory where files should be written. + selection_indices : npt.NDArray + The set of system indices to report positions & velocities for. + output_settings : MultiStateOutputSettings + Settings defining how outputs should be written. + simulation_settings : MultiStateSimulationSettings + Settings defining out the simulation should be run. + + Notes + ----- + All this does is create the reporter, it works for both + new reporters and if we are doing a restart. + """ + # Define the trajectory & checkpoint files + nc = storage_path / output_settings.output_filename + # The checkpoint file in openmmtools is taken as a file relative + # to the location of the nc file, so you only want the filename + chk = output_settings.checkpoint_storage_filename + + if output_settings.positions_write_frequency is not None: + pos_interval = settings_validation.divmod_time_and_check( + numerator=output_settings.positions_write_frequency, + denominator=simulation_settings.time_per_iteration, + numerator_name="output settings' position_write_frequency", + denominator_name="simulation settings' time_per_iteration", + ) + else: + pos_interval = 0 + + if output_settings.velocities_write_frequency is not None: + vel_interval = settings_validation.divmod_time_and_check( + numerator=output_settings.velocities_write_frequency, + denominator=simulation_settings.time_per_iteration, + numerator_name="output settings' velocity_write_frequency", + denominator_name="sampler settings' time_per_iteration", + ) + else: + vel_interval = 0 + + chk_intervals = settings_validation.convert_checkpoint_interval_to_iterations( + checkpoint_interval=output_settings.checkpoint_interval, + time_per_iteration=simulation_settings.time_per_iteration, + ) + + return multistate.MultiStateReporter( + storage=nc, + analysis_particle_indices=selection_indices, + checkpoint_interval=chk_intervals, + checkpoint_storage=chk, + position_interval=pos_interval, + velocity_interval=vel_interval, + ) + @staticmethod def _get_sampler( system: openmm.System, @@ -959,6 +998,7 @@ def _get_sampler( thermo_settings: ThermoSettings, alchem_settings: AlchemicalSettings, platform: openmm.Platform, + restart: bool, dry: bool, ) -> multistate.MultiStateSampler: """ @@ -984,6 +1024,8 @@ def _get_sampler( The alchemical transformation settings. platform : openmm.Platform The compute platform to use. + restart : bool + ``True`` if we are doing a simulation restart. dry : bool Whether or not this is a dry run. @@ -992,6 +1034,14 @@ def _get_sampler( sampler : multistate.MultiStateSampler The requested sampler. """ + _SAMPLERS = { + "repex" : _rfe_utils.multistate.HybridRepexSampler, + "sams": _rfe_utils.multistate.HybridSAMSSampler, + "independent": _rfe_utils.multistate.HybridMultiStateSampler, + } + + sampler_method = simulation_settings.sampler_method.lower() + # Get the real time analysis values to use rta_its, rta_min_its = settings_validation.convert_real_time_analysis_iterations( simulation_settings=simulation_settings, @@ -1000,7 +1050,6 @@ def _get_sampler( # Get the number of production iterations to run for steps_per_iteration = integrator.n_steps timestep = from_openmm(integrator.timestep) - # TODO: this is bugged! number_of_iterations = int( settings_validation.get_simsteps( sim_length=simulation_settings.production_length, @@ -1017,54 +1066,44 @@ def _get_sampler( ) ) - if simulation_settings.sampler_method.lower() == "repex": - sampler = _rfe_utils.multistate.HybridRepexSampler( - mcmc_moves=integrator, - hybrid_system=system, - hybrid_positions=positions, - online_analysis_interval=rta_its, - online_analysis_target_error=early_termination_target_error, - online_analysis_minimum_iterations=rta_min_its, - number_of_iterations=number_of_iterations, - ) + sampler_kwargs = { + "mcmc_moves": integrator, + "hybrid_system": system, + "hybrid_positions": positions, + "online_analysis_interval": rta_its, + "online_analysis_target_error": early_termination_target_error, + "online_analysis_minimum_iterations": rta_min_its, + "number_of_iterations": number_of_iterations, + } - elif simulation_settings.sampler_method.lower() == "sams": - sampler = _rfe_utils.multistate.HybridSAMSSampler( - mcmc_moves=integrator, - hybrid_system=system, - hybrid_positions=positions, - online_analysis_interval=rta_its, - online_analysis_minimum_iterations=rta_min_its, - flatness_criteria=simulation_settings.sams_flatness_criteria, - gamma0=simulation_settings.sams_gamma0, - number_of_iterations=number_of_iterations, - ) + if sampler_method == "sams": + sampler_kwargs |= { + "flatness_criteria": simulation_settings.sams_flatness_criteria, + "gamma0": simulation_settings.sams_gamma0, + } - elif simulation_settings.sampler_method.lower() == "independent": - sampler = _rfe_utils.multistate.HybridMultiStateSampler( - mcmc_moves=integrator, - hybrid_system=system, - hybrid_positions=positions, - online_analysis_interval=rta_its, - online_analysis_target_error=early_termination_target_error, - online_analysis_minimum_iterations=rta_min_its, - number_of_iterations=number_of_iterations, - ) + if sampler_method == "repex": + sampler_kwargs |= { + "replica_mixing_scheme": "swap-all" + } + # Restarting doesn't need any setup, we just rebuild from storage. + if restart: + sampler = _SAMPLERS[sampler_method].from_storage(reporter) else: - raise AttributeError(f"Unknown sampler {simulation_settings.sampler_method}") + sampler = _SAMPLERS[sampler_method](**sampler_kwargs) - sampler.setup( - n_replicas=simulation_settings.n_replicas, - reporter=reporter, - lambda_protocol=lambdas, - temperature=to_openmm(thermo_settings.temperature), - endstates=alchem_settings.endstate_dispersion_correction, - minimization_platform=platform.getName(), - # Set minimization steps to None when running in dry mode - # otherwise do a very small one to avoid NaNs - minimization_steps=100 if not dry else None, - ) + sampler.setup( + n_replicas=simulation_settings.n_replicas, + reporter=reporter, + lambda_protocol=lambdas, + temperature=to_openmm(thermo_settings.temperature), + endstates=alchem_settings.endstate_dispersion_correction, + minimization_platform=platform.getName(), + # Set minimization steps to None when running in dry mode + # otherwise do a very small one to avoid NaNs + minimization_steps=100 if not dry else None, + ) # Get and set the context caches sampler.energy_context_cache = openmmtools.cache.ContextCache( @@ -1149,7 +1188,10 @@ def _run_simulation( if self.verbose: self.logger.info("running production phase") - sampler.run() + # We use `run` so that we're limited by the number of iterations + # we passed when we built the sampler. + # TODO: I'm being extra prudent by passing in n_iterations here - remove? + sampler.run(n_iterations=int(prod_steps / mc_steps)-sampler._iteration) if self.verbose: self.logger.info("production phase complete") @@ -1219,6 +1261,9 @@ def run( # Get the settings settings = self._get_settings(self._inputs["protocol"].settings) + # Check for a restart + self.restart = self._check_restart(settings, shared_basepath) + # Get the lambda schedule # TODO - this should be better exposed to users lambdas = _rfe_utils.lambdaprotocol.LambdaProtocol( @@ -1226,13 +1271,6 @@ def run( windows=settings["lambda_settings"].lambda_windows ) - # Get the reporter - reporter = self._get_reporter( - selection_indices=selection_indices, - output_settings=settings["output_settings"], - simulation_settings=settings["simulation_settings"], - ) - # Get the compute platform restrict_cpu = settings["forcefield_settings"].nonbonded_method.lower() == "nocutoff" platform = omm_compute.get_openmm_platform( @@ -1249,7 +1287,13 @@ def run( ) try: - # Get sampler + reporter = self._get_reporter( + storage_path=self.shared_basepath, + selection_indices=selection_indices, + output_settings=settings["output_settings"], + simulation_settings=settings["simulation_settings"], + ) + sampler = self._get_sampler( system=system, positions=positions, @@ -1260,6 +1304,7 @@ def run( thermo_settings=settings["thermo_settings"], alchem_settings=settings["alchemical_settings"], platform=platform, + restart=self.restart, dry=dry ) @@ -1301,7 +1346,7 @@ def run( return { "sampler": sampler, "integrator": integrator, - } + } def _execute( self, From 45eeed1ccdbaa89e2eef4b07a8e228e9aa9930bb Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 31 Dec 2025 14:46:31 -0500 Subject: [PATCH 61/91] fix path issues --- openfe/protocols/openmm_rfe/hybridtop_units.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 758800a0f..a66eddb74 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -923,7 +923,7 @@ def _get_integrator( @staticmethod def _get_reporter( - storage_path: pathlib.path, + storage_path: pathlib.Path, selection_indices: npt.NDArray, output_settings: MultiStateOutputSettings, simulation_settings: MultiStateSimulationSettings, @@ -1262,7 +1262,10 @@ def run( settings = self._get_settings(self._inputs["protocol"].settings) # Check for a restart - self.restart = self._check_restart(settings, shared_basepath) + self.restart = self._check_restart( + settings=settings, + shared_path=self.shared_basepath + ) # Get the lambda schedule # TODO - this should be better exposed to users From dfacc0692c9cbef80b5ebf00c0f907e38acd1b3e Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 13:43:40 -0500 Subject: [PATCH 62/91] remove duplicate oechem block for system generator creation --- .../protocols/openmm_rfe/hybridtop_units.py | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index cb307dd4f..5c7c0cada 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -285,35 +285,32 @@ def _get_system_generator( system_generator : openmmtools.SystemGenerator The SystemGenerator for the protocol. """ - # Block out oechem backend in system_generator calls to avoid - # any issues with smiles roundtripping between rdkit and oechem - with without_oechem_backend(): - system_generator = system_creation.get_system_generator( - forcefield_settings=settings["forcefield_settings"], - integrator_settings=settings["integrator_settings"], - thermo_settings=settings["thermo_settings"], - cache=ffcache, - has_solvent=solvent_component is not None, - ) + system_generator = system_creation.get_system_generator( + forcefield_settings=settings["forcefield_settings"], + integrator_settings=settings["integrator_settings"], + thermo_settings=settings["thermo_settings"], + cache=ffcache, + has_solvent=solvent_component is not None, + ) - # Handle openff Molecule templates - # TODO: revisit this once the SystemGenerator update happens - # and we start loading the whole protein into OpenFF Topologies - - # First deduplicate isomoprhic molecules - unique_offmols = [] - for mol in openff_molecules: - unique = all( - [ - not mol.is_isomorphic_with(umol) - for umol in unique_offmols - ] - ) - if unique: - unique_offmols.append(mol) + # Handle openff Molecule templates + # TODO: revisit this once the SystemGenerator update happens + # and we start loading the whole protein into OpenFF Topologies + + # First deduplicate isomoprhic molecules + unique_offmols = [] + for mol in openff_molecules: + unique = all( + [ + not mol.is_isomorphic_with(umol) + for umol in unique_offmols + ] + ) + if unique: + unique_offmols.append(mol) - # register all the templates - system_generator.add_molecules(unique_offmols) + # register all the templates + system_generator.add_molecules(unique_offmols) return system_generator From 76cb934a7ce0cfd956a640b4e83cb62cc74d978a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 13:47:49 -0500 Subject: [PATCH 63/91] Add an early optimisation for a no smc future --- openfe/protocols/openmm_rfe/hybridtop_units.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 5c7c0cada..914ca9333 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -275,8 +275,8 @@ def _get_system_generator( A dictionary of protocol settings. solvent_component : SolventComponent | None The solvent component of the system, if any. - openff_molecules : list[openff.Toolkit] | None - A list of openff molecules to generate templates for, if any. + openff_molecules : list[openff.Toolkit] + A list of openff molecules to generate templates for. ffcache : pathlib.Path | None Path to the force field parameter cache. @@ -296,6 +296,8 @@ def _get_system_generator( # Handle openff Molecule templates # TODO: revisit this once the SystemGenerator update happens # and we start loading the whole protein into OpenFF Topologies + if openff_molecules is None: + return system_generator # First deduplicate isomoprhic molecules unique_offmols = [] From 467848f6c9073cd6fdfa4ad98767189c9135a046 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 13:52:37 -0500 Subject: [PATCH 64/91] fix something --- openfe/protocols/openmm_rfe/hybridtop_units.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 914ca9333..f344d67ff 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -275,8 +275,8 @@ def _get_system_generator( A dictionary of protocol settings. solvent_component : SolventComponent | None The solvent component of the system, if any. - openff_molecules : list[openff.Toolkit] - A list of openff molecules to generate templates for. + openff_molecules : list[openff.toolkit.Molecule] | None + A list of openff molecules to generate templates for, if any. ffcache : pathlib.Path | None Path to the force field parameter cache. From ea49418326f548bc8dc44779ccd74f19c5743e3f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 19:23:02 -0500 Subject: [PATCH 65/91] Some more fixes --- openfe/protocols/openmm_rfe/hybridtop_units.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index a66eddb74..1f96d07aa 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1282,14 +1282,16 @@ def run( restrict_cpu_count=restrict_cpu, ) - # Get the integrator - integrator = self._get_integrator( - integrator_settings=settings["integrator_settings"], - simulation_settings=settings["simulation_settings"], - system=system - ) try: + # Get the integrator + integrator = self._get_integrator( + integrator_settings=settings["integrator_settings"], + simulation_settings=settings["simulation_settings"], + system=system + ) + + # Get the reporter reporter = self._get_reporter( storage_path=self.shared_basepath, selection_indices=selection_indices, @@ -1297,6 +1299,7 @@ def run( simulation_settings=settings["simulation_settings"], ) + # Get the sampler sampler = self._get_sampler( system=system, positions=positions, @@ -1311,6 +1314,7 @@ def run( dry=dry ) + # Run the simulation self._run_simulation( sampler=sampler, reporter=reporter, From 9d72f16adb83d80e02d6f8bb2b846b0f392a0598 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 19:27:37 -0500 Subject: [PATCH 66/91] fix something --- openfe/protocols/openmm_rfe/hybridtop_units.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 78e7f8ddc..3ce97fd8f 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1089,12 +1089,6 @@ def _run_simulation( Simulation output control settings. dry : bool Whether or not to dry run the simulation. - - Returns - ------- - unit_results_dict : dict | None - A dictionary containing the free energy results to report. - ``None`` if it is a dry run. """ # Get the relevant simulation steps mc_steps = settings_validation.convert_steps_per_iteration( @@ -1148,8 +1142,6 @@ def _run_simulation( for fn in fns: os.remove(fn) - return None - def run( self, *, From b8f8bbdff79c216222678f62a2610b17cf0ed5bf Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 19:54:11 -0500 Subject: [PATCH 67/91] remove typo --- openfe/protocols/openmm_rfe/hybridtop_units.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 3ce97fd8f..756e63010 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1437,7 +1437,6 @@ def run( scratch_basepath: pathlib.Path | None = None, shared_basepath: pathlib.Path | None = None, ) -> dict[str, Any]: - """Analyze the multistate simulation. Parameters From d2501126cb62a1e72ff7ba54becd2a7ac1d2de0f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 19:54:31 -0500 Subject: [PATCH 68/91] remove typo --- openfe/protocols/openmm_rfe/hybridtop_units.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 12ad2bcc5..783ed69c4 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1507,7 +1507,6 @@ def run( scratch_basepath: pathlib.Path | None = None, shared_basepath: pathlib.Path | None = None, ) -> dict[str, Any]: - """Analyze the multistate simulation. Parameters From 5da052b3b3a303ec989ab6c834fc8b827543908c Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 1 Jan 2026 20:04:06 -0500 Subject: [PATCH 69/91] update final results dict --- openfe/protocols/openmm_rfe/hybridtop_units.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 756e63010..51ff9e672 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1514,6 +1514,7 @@ def _execute( log_system_probe(logging.INFO, paths=[ctx.scratch]) pdb_file = setup_results.outputs["pdb_structure"] + selection_indices = setup_results.outputs["selection_indices"] trajectory = simulation_results.outputs["nc"] checkpoint = simulation_results.outputs["checkpoint"] @@ -1528,10 +1529,11 @@ def _execute( return { "repeat_id": self._inputs["repeat_id"], "generation": self._inputs["generation"], - # We include paths to various files here also to make - # life easier when gathering results. + # We include various other outputs here to make + # things easier when gathering. "pdb_structure": pdb_file, "trajectory": trajectory, "checkpoint": checkpoint, + "selection_indices": selection_indices, **outputs, } From 74ae84368df3a4f5fb326549d7a2dcd1bd397086 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 02:08:47 +0000 Subject: [PATCH 70/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- devtools/data/gen_serialized_results.py | 2 +- openfe/protocols/openmm_rfe/__init__.py | 8 +- .../protocols/openmm_rfe/equil_rfe_methods.py | 6 +- .../openmm_rfe/hybridtop_protocol_results.py | 5 +- .../openmm_rfe/hybridtop_protocols.py | 42 ++-- .../protocols/openmm_rfe/hybridtop_units.py | 201 +++++++--------- .../openmm_utils/system_validation.py | 12 +- .../openmm_rfe/test_hybrid_top_protocol.py | 95 +++----- .../test_hybrid_top_tokenization.py | 23 +- .../openmm_rfe/test_hybrid_top_validation.py | 221 ++++++++---------- 10 files changed, 247 insertions(+), 368 deletions(-) diff --git a/devtools/data/gen_serialized_results.py b/devtools/data/gen_serialized_results.py index 13e5cb893..9dd6e35ca 100644 --- a/devtools/data/gen_serialized_results.py +++ b/devtools/data/gen_serialized_results.py @@ -103,7 +103,7 @@ def execute_and_serialize( logger.info(f"running {simname}") with tempfile.TemporaryDirectory() as tmpdir: workdir = pathlib.Path(tmpdir) - workdir = pathlib.Path('.') + workdir = pathlib.Path(".") dagres = gufe.protocols.execute_DAG( dag, shared_basedir=workdir, diff --git a/openfe/protocols/openmm_rfe/__init__.py b/openfe/protocols/openmm_rfe/__init__.py index 7bbf4c47b..f0fe367c8 100644 --- a/openfe/protocols/openmm_rfe/__init__.py +++ b/openfe/protocols/openmm_rfe/__init__.py @@ -2,11 +2,11 @@ # For details, see https://github.com/OpenFreeEnergy/openfe from . import _rfe_utils -from .hybridtop_protocols import RelativeHybridTopologyProtocol +from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings from .hybridtop_protocol_results import RelativeHybridTopologyProtocolResult +from .hybridtop_protocols import RelativeHybridTopologyProtocol from .hybridtop_units import ( - HybridTopologySetupUnit, - HybridTopologyMultiStateSimulationUnit, HybridTopologyMultiStateAnalysisUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologySetupUnit, ) -from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings diff --git a/openfe/protocols/openmm_rfe/equil_rfe_methods.py b/openfe/protocols/openmm_rfe/equil_rfe_methods.py index a890ff4f6..e3a8b067d 100644 --- a/openfe/protocols/openmm_rfe/equil_rfe_methods.py +++ b/openfe/protocols/openmm_rfe/equil_rfe_methods.py @@ -18,9 +18,9 @@ from .equil_rfe_settings import RelativeHybridTopologyProtocolSettings from .hybridtop_protocol_results import RelativeHybridTopologyProtocolResult +from .hybridtop_protocols import RelativeHybridTopologyProtocol from .hybridtop_units import ( - HybridTopologySetupUnit, - HybridTopologyMultiStateSimulationUnit, HybridTopologyMultiStateAnalysisUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologySetupUnit, ) -from .hybridtop_protocols import RelativeHybridTopologyProtocol diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocol_results.py b/openfe/protocols/openmm_rfe/hybridtop_protocol_results.py index 596a1bf9f..ca865f8e0 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocol_results.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocol_results.py @@ -16,7 +16,6 @@ from openff.units import Quantity from openmmtools import multistate - logger = logging.getLogger(__name__) @@ -235,8 +234,6 @@ def production_iterations(self) -> list[float]: ------- production_lengths : list[float] """ - production_lengths = [ - pus[0].outputs["production_iterations"] for pus in self.data.values() - ] + production_lengths = [pus[0].outputs["production_iterations"] for pus in self.data.values()] return production_lengths diff --git a/openfe/protocols/openmm_rfe/hybridtop_protocols.py b/openfe/protocols/openmm_rfe/hybridtop_protocols.py index 3d5cdc033..7ea75a29c 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_protocols.py +++ b/openfe/protocols/openmm_rfe/hybridtop_protocols.py @@ -16,9 +16,9 @@ import warnings from collections import defaultdict from typing import Any, Iterable, Optional, Union -import numpy as np import gufe +import numpy as np from gufe import ( ChemicalSystem, Component, @@ -50,12 +50,11 @@ ) from .hybridtop_protocol_results import RelativeHybridTopologyProtocolResult from .hybridtop_units import ( - HybridTopologySetupUnit, - HybridTopologyMultiStateSimulationUnit, HybridTopologyMultiStateAnalysisUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologySetupUnit, ) - logger = logging.getLogger(__name__) @@ -281,9 +280,13 @@ def _validate_mapping( # check that the mapping components are in the alchemical components for m in mapping: if m.componentA not in alchemical_components["stateA"]: - raise ValueError(f"Mapping componentA {m.componentA} not in alchemical components of stateA") + raise ValueError( + f"Mapping componentA {m.componentA} not in alchemical components of stateA" + ) if m.componentB not in alchemical_components["stateB"]: - raise ValueError(f"Mapping componentB {m.componentB} not in alchemical components of stateB") + raise ValueError( + f"Mapping componentB {m.componentB} not in alchemical components of stateB" + ) # TODO: remove - this is now the default behaviour? # Check for element changes in mappings @@ -425,10 +428,7 @@ def _validate_charge_difference( ) raise ValueError(errmsg) - ion = { - -1: solvent_component.positive_ion, - 1: solvent_component.negative_ion - }[difference] + ion = {-1: solvent_component.positive_ion, 1: solvent_component.negative_ion}[difference] wmsg = ( f"A charge difference of {difference} is observed " @@ -459,7 +459,7 @@ def _validate_simulation_settings( Raises ------ ValueError - * If the + * If the """ steps_per_iteration = settings_validation.convert_steps_per_iteration( @@ -567,7 +567,10 @@ def _validate( # Validate alchemical settings # PR #125 temporarily pin lambda schedule spacing to n_replicas - if self.settings.simulation_settings.n_replicas != self.settings.lambda_settings.lambda_windows: + if ( + self.settings.simulation_settings.n_replicas + != self.settings.lambda_settings.lambda_windows + ): errmsg = ( "Number of replicas in ``simulation_settings``: " f"{self.settings.simulation_settings.n_replicas} must equal " @@ -611,10 +614,7 @@ def _create( alchemical_components=alchem_comps, generation=0, repeat_id=repeat_id, - name=( - f"HybridTopology Setup: {Anames} to {Bnames} " - f"repeat {i} generation 0" - ) + name=(f"HybridTopology Setup: {Anames} to {Bnames} repeat {i} generation 0"), ) simulation = HybridTopologyMultiStateSimulationUnit( @@ -622,10 +622,7 @@ def _create( setup_results=setup, generation=0, repeat_id=repeat_id, - name=( - f"HybridTopology Simulation: {Anames} to {Bnames} " - f"repeat {i} generation 0" - ) + name=(f"HybridTopology Simulation: {Anames} to {Bnames} repeat {i} generation 0"), ) analysis = HybridTopologyMultiStateAnalysisUnit( @@ -634,10 +631,7 @@ def _create( simulation_results=simulation, generation=0, repeat_id=repeat_id, - name=( - f"HybridTopology Analysis: {Anames} to {Bnames} " - f"repeat {i} generation 0" - ) + name=(f"HybridTopology Analysis: {Anames} to {Bnames} repeat {i} generation 0"), ) setup_units.append(setup) simulation_units.append(simulation) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 0d996744d..94b4dc727 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -18,31 +18,30 @@ from itertools import chain from typing import Any +import gufe import matplotlib.pyplot as plt import mdtraj import numpy as np import numpy.typing as npt import openmm import openmmtools -from openmmforcefields.generators import SystemGenerator - -import gufe -from gufe.settings import ( - SettingsBaseModel, - ThermoSettings, -) from gufe import ( ChemicalSystem, - LigandAtomMapping, Component, - SolventComponent, + LigandAtomMapping, ProteinComponent, SmallMoleculeComponent, + SolventComponent, +) +from gufe.settings import ( + SettingsBaseModel, + ThermoSettings, ) from openff.toolkit.topology import Molecule as OFFMolecule -from openff.units import unit as offunit from openff.units import Quantity +from openff.units import unit as offunit from openff.units.openmm import ensure_quantity, from_openmm, to_openmm +from openmmforcefields.generators import SystemGenerator from openmmtools import multistate from openfe.protocols.openmm_utils.omm_settings import ( @@ -60,8 +59,8 @@ system_validation, ) from ..openmm_utils.serialization import ( - serialize, deserialize, + serialize, ) from . import _rfe_utils from ._rfe_utils.relative import HybridTopologyFactory @@ -72,8 +71,8 @@ MultiStateOutputSettings, MultiStateSimulationSettings, OpenFFPartialChargeSettings, - OpenMMSolvationSettings, OpenMMEngineSettings, + OpenMMSolvationSettings, RelativeHybridTopologyProtocolSettings, ) @@ -116,7 +115,7 @@ def _set_optional_path(basepath): @staticmethod def _get_settings( - settings: RelativeHybridTopologyProtocolSettings + settings: RelativeHybridTopologyProtocolSettings, ) -> dict[str, SettingsBaseModel]: """ Get a dictionary of Protocol settings. @@ -150,15 +149,11 @@ class HybridTopologySetupUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): """ Calculates the relative free energy of an alchemical ligand transformation. """ + @staticmethod def _get_components( - stateA: ChemicalSystem, - stateB: ChemicalSystem - ) -> tuple[ - SolventComponent, - ProteinComponent, - dict[SmallMoleculeComponent, OFFMolecule] - ]: + stateA: ChemicalSystem, stateB: ChemicalSystem + ) -> tuple[SolventComponent, ProteinComponent, dict[SmallMoleculeComponent, OFFMolecule]]: """ Get the components from the ChemicalSystem inputs. @@ -182,10 +177,7 @@ def _get_components( solvent_comp, protein_comp, smcs_A = system_validation.get_components(stateA) _, _, smcs_B = system_validation.get_components(stateB) - small_mols = { - m: m.to_openff() - for m in set(smcs_A).union(set(smcs_B)) - } + small_mols = {m: m.to_openff() for m in set(smcs_A).union(set(smcs_B))} return solvent_comp, protein_comp, small_mols @@ -221,7 +213,7 @@ def _get_system_generator( settings: dict[str, SettingsBaseModel], solvent_component: SolventComponent | None, openff_molecules: list[OFFMolecule] | None, - ffcache: pathlib.Path | None + ffcache: pathlib.Path | None, ) -> SystemGenerator: """ Get an OpenMM SystemGenerator. @@ -232,11 +224,11 @@ def _get_system_generator( A dictionary of protocol settings. solvent_component : SolventComponent | None The solvent component of the system, if any. - openff_molecules : list[openff.toolkit.Molecule] | None + openff_molecules : list[openff.toolkit.Molecule] | None A list of openff molecules to generate templates for, if any. ffcache : pathlib.Path | None Path to the force field parameter cache. - + Returns ------- system_generator : openmmtools.SystemGenerator @@ -255,22 +247,17 @@ def _get_system_generator( # and we start loading the whole protein into OpenFF Topologies if openff_molecules is None: return system_generator - + # First deduplicate isomoprhic molecules unique_offmols = [] for mol in openff_molecules: - unique = all( - [ - not mol.is_isomorphic_with(umol) - for umol in unique_offmols - ] - ) + unique = all([not mol.is_isomorphic_with(umol) for umol in unique_offmols]) if unique: unique_offmols.append(mol) # register all the templates system_generator.add_molecules(unique_offmols) - + return system_generator @staticmethod @@ -281,10 +268,7 @@ def _create_stateA_system( system_generator: SystemGenerator, solvation_settings: OpenMMSolvationSettings, ) -> tuple[ - openmm.System, - openmm.app.Topology, - openmm.unit.Quantity, - dict[Component, npt.NDArray] + openmm.System, openmm.app.Topology, openmm.unit.Quantity, dict[Component, npt.NDArray] ]: """ Create an OpenMM System for state A. @@ -436,7 +420,7 @@ def _get_omm_objects( settings: dict[str, SettingsBaseModel], protein_component: ProteinComponent | None, solvent_component: SolventComponent | None, - small_mols: dict[SmallMoleculeComponent, OFFMolecule] + small_mols: dict[SmallMoleculeComponent, OFFMolecule], ) -> tuple[ openmm.System, openmm.app.Topology, @@ -488,52 +472,45 @@ def _get_omm_objects( self.logger.info("Parameterizing systems") def _filter_mols(smols, state): - return { - smc: offmol - for smc, offmol in smols.items() - if state.contains(smc) - } + return {smc: offmol for smc, offmol in smols.items() if state.contains(smc)} states_inputs = { - 'A': {'state': stateA, 'mols': _filter_mols(small_mols, stateA)}, - 'B': {'state': stateB, 'mols': _filter_mols(small_mols, stateB)}, + "A": {"state": stateA, "mols": _filter_mols(small_mols, stateA)}, + "B": {"state": stateB, "mols": _filter_mols(small_mols, stateB)}, } # Everything involving systemgenerator handling has a risk of # oechem <-> rdkit smiles conversion clashes, cautiously ban it. with without_oechem_backend(): # Get the system generators with all the templates registered - for state in ['A', 'B']: + for state in ["A", "B"]: ffcache = settings["output_settings"].forcefield_cache if ffcache is not None: ffcache = self.shared_basepath / (f"{state}_" + ffcache) - states_inputs[state]['generator'] = self._get_system_generator( + states_inputs[state]["generator"] = self._get_system_generator( settings=settings, solvent_component=solvent_component, - openff_molecules=list(states_inputs[state]['mols'].values()), + openff_molecules=list(states_inputs[state]["mols"].values()), ffcache=ffcache, ) - ( - stateA_system, stateA_topology, stateA_positions, - comp_resids - ) = self._create_stateA_system( - small_mols=states_inputs['A']['mols'], - protein_component=protein_component, - solvent_component=solvent_component, - system_generator=states_inputs['A']['generator'], - solvation_settings=settings["solvation_settings"] + (stateA_system, stateA_topology, stateA_positions, comp_resids) = ( + self._create_stateA_system( + small_mols=states_inputs["A"]["mols"], + protein_component=protein_component, + solvent_component=solvent_component, + system_generator=states_inputs["A"]["generator"], + solvation_settings=settings["solvation_settings"], + ) ) - ( - stateB_system, stateB_topology, stateB_alchem_resids - ) = self._create_stateB_system( - small_mols=states_inputs['B']['mols'], + (stateB_system, stateB_topology, stateB_alchem_resids) = self._create_stateB_system( + small_mols=states_inputs["B"]["mols"], mapping=mapping, stateA_topology=stateA_topology, exclude_resids=comp_resids[mapping.componentA], - system_generator=states_inputs['B']['generator'], + system_generator=states_inputs["B"]["generator"], ) # Get the mapping between the two systems @@ -575,9 +552,13 @@ def _filter_mols(smols, state): ) return ( - stateA_system, stateA_topology, stateA_positions, - stateB_system, stateB_topology, stateB_positions, - system_mappings + stateA_system, + stateA_topology, + stateA_positions, + stateB_system, + stateB_topology, + stateB_positions, + system_mappings, ) @staticmethod @@ -688,9 +669,9 @@ def _subsample_topology( # bfactor of 0.5 is core atoms # bfactor of 0.75 is unique new atoms bfactors = np.zeros_like(selection_indices, dtype=float) - bfactors[np.isin(selection_indices, list(atom_classes['unique_old_atoms']))] = 0.25 - bfactors[np.isin(selection_indices, list(atom_classes['core_atoms']))] = 0.50 - bfactors[np.isin(selection_indices, list(atom_classes['unique_new_atoms']))] = 0.75 + bfactors[np.isin(selection_indices, list(atom_classes["unique_old_atoms"]))] = 0.25 + bfactors[np.isin(selection_indices, list(atom_classes["core_atoms"]))] = 0.50 + bfactors[np.isin(selection_indices, list(atom_classes["unique_new_atoms"]))] = 0.75 if len(selection_indices) > 0: traj = mdtraj.Trajectory( @@ -709,7 +690,7 @@ def run( dry: bool = False, verbose: bool = True, scratch_basepath: pathlib.Path | None = None, - shared_basepath: pathlib.Path | None = None + shared_basepath: pathlib.Path | None = None, ) -> dict[str, Any]: """Setup a hybrid topology system. @@ -749,17 +730,19 @@ def run( stateB = self._inputs["stateB"] mapping = self._inputs["ligandmapping"] alchem_comps = self._inputs["alchemical_components"] - solvent_comp, protein_comp, small_mols = self._get_components( - stateA, stateB - ) + solvent_comp, protein_comp, small_mols = self._get_components(stateA, stateB) # Assign partial charges now to avoid any discrepancies later self._assign_partial_charges(settings["charge_settings"], small_mols) ( - stateA_system, stateA_topology, stateA_positions, - stateB_system, stateB_topology, stateB_positions, - system_mappings + stateA_system, + stateA_topology, + stateA_positions, + stateB_system, + stateB_topology, + stateB_positions, + system_mappings, ) = self._get_omm_objects( stateA=stateA, stateB=stateB, @@ -767,7 +750,7 @@ def run( settings=settings, protein_component=protein_comp, solvent_component=solvent_comp, - small_mols=small_mols + small_mols=small_mols, ) # Get the hybrid factory & system @@ -835,12 +818,8 @@ def _execute( class HybridTopologyMultiStateSimulationUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): - @staticmethod - def _check_restart( - settings: dict[str, SettingsBaseModel], - shared_path: pathlib.Path - ): + def _check_restart(settings: dict[str, SettingsBaseModel], shared_path: pathlib.Path): """ Check if we are doing a restart. @@ -862,14 +841,14 @@ def _check_restart( if trajectory.is_file() and checkpoint.is_file(): return True - + return False @staticmethod def _get_integrator( integrator_settings: IntegratorSettings, simulation_settings: MultiStateSimulationSettings, - system: openmm.System + system: openmm.System, ) -> openmmtools.mcmc.LangevinDynamicsMove: """ Get and validate the integrator @@ -1034,7 +1013,7 @@ def _get_sampler( The requested sampler. """ _SAMPLERS = { - "repex" : _rfe_utils.multistate.HybridRepexSampler, + "repex": _rfe_utils.multistate.HybridRepexSampler, "sams": _rfe_utils.multistate.HybridSAMSSampler, "independent": _rfe_utils.multistate.HybridMultiStateSampler, } @@ -1054,7 +1033,8 @@ def _get_sampler( sim_length=simulation_settings.production_length, timestep=timestep, mc_steps=steps_per_iteration, - ) / steps_per_iteration + ) + / steps_per_iteration ) # convert early_termination_target_error from kcal/mol to kT @@ -1082,9 +1062,7 @@ def _get_sampler( } if sampler_method == "repex": - sampler_kwargs |= { - "replica_mixing_scheme": "swap-all" - } + sampler_kwargs |= {"replica_mixing_scheme": "swap-all"} # Restarting doesn't need any setup, we just rebuild from storage. if restart: @@ -1122,9 +1100,9 @@ def _run_simulation( self, sampler: multistate.MultiStateSampler, reporter: multistate.MultiStateReporter, - simulation_settings : MultiStateSimulationSettings, - integrator_settings : IntegratorSettings, - output_settings : MultiStateOutputSettings, + simulation_settings: MultiStateSimulationSettings, + integrator_settings: IntegratorSettings, + output_settings: MultiStateOutputSettings, dry: bool, ): """ @@ -1184,7 +1162,7 @@ def _run_simulation( # We use `run` so that we're limited by the number of iterations # we passed when we built the sampler. # TODO: I'm being extra prudent by passing in n_iterations here - remove? - sampler.run(n_iterations=int(prod_steps / mc_steps)-sampler._iteration) + sampler.run(n_iterations=int(prod_steps / mc_steps) - sampler._iteration) if self.verbose: self.logger.info("production phase complete") @@ -1211,7 +1189,7 @@ def run( dry: bool = False, verbose: bool = True, scratch_basepath: pathlib.Path | None = None, - shared_basepath: pathlib.Path | None = None + shared_basepath: pathlib.Path | None = None, ) -> dict[str, Any]: """Run the free energy calculation using a multistate sampler. @@ -1253,16 +1231,13 @@ def run( settings = self._get_settings(self._inputs["protocol"].settings) # Check for a restart - self.restart = self._check_restart( - settings=settings, - shared_path=self.shared_basepath - ) + self.restart = self._check_restart(settings=settings, shared_path=self.shared_basepath) # Get the lambda schedule # TODO - this should be better exposed to users lambdas = _rfe_utils.lambdaprotocol.LambdaProtocol( functions=settings["lambda_settings"].lambda_functions, - windows=settings["lambda_settings"].lambda_windows + windows=settings["lambda_settings"].lambda_windows, ) # Get the compute platform @@ -1273,13 +1248,12 @@ def run( restrict_cpu_count=restrict_cpu, ) - try: # Get the integrator integrator = self._get_integrator( integrator_settings=settings["integrator_settings"], simulation_settings=settings["simulation_settings"], - system=system + system=system, ) # Get the reporter @@ -1302,7 +1276,7 @@ def run( alchem_settings=settings["alchemical_settings"], platform=platform, restart=self.restart, - dry=dry + dry=dry, ) # Run the simulation @@ -1338,12 +1312,13 @@ def run( if not dry: # pragma: no-cover return { "nc": self.shared_basepath / settings["output_settings"].output_filename, - "checkpoint": self.shared_basepath / settings["output_settings"].checkpoint_storage_filename, + "checkpoint": self.shared_basepath + / settings["output_settings"].checkpoint_storage_filename, } else: return { - "sampler": sampler, - "integrator": integrator, + "sampler": sampler, + "integrator": integrator, } def _execute( @@ -1366,7 +1341,7 @@ def _execute( positions=positions, selection_indices=selection_indices, scratch_basepath=ctx.scratch, - shared_basepath=ctx.shared + shared_basepath=ctx.shared, ) return { @@ -1377,7 +1352,6 @@ def _execute( class HybridTopologyMultiStateAnalysisUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): - @staticmethod def _analyze_multistate_energies( trajectory: pathlib.Path, @@ -1429,7 +1403,7 @@ def _analyze_multistate_energies( def _structural_analysis( pdb_file: pathlib.Path, trj_file: pathlib.Path, - output_directory : pathlib.Path, + output_directory: pathlib.Path, dry: bool, ) -> dict[str, str | pathlib.Path]: """ @@ -1472,13 +1446,10 @@ def _structural_analysis( fig = plotting.plot_2D_rmsd(d) fig.savefig(output_directory / "protein_2D_RMSD.png") plt.close(fig) - f2 = plotting.plot_ligand_COM_drift( - data["time(ps)"], - data["ligand_wander"] - ) + f2 = plotting.plot_ligand_COM_drift(data["time(ps)"], data["ligand_wander"]) f2.savefig(output_directory / "ligand_COM_drift.png") plt.close(f2) - + f3 = plotting.plot_ligand_RMSD(data["time(ps)"], data["ligand_RMSD"]) f3.savefig(output_directory / "ligand_RMSD.png") plt.close(f3) @@ -1593,7 +1564,7 @@ def _execute( trajectory=trajectory, checkpoint=checkpoint, scratch_basepath=ctx.scratch, - shared_basepath=ctx.shared + shared_basepath=ctx.shared, ) return { diff --git a/openfe/protocols/openmm_utils/system_validation.py b/openfe/protocols/openmm_utils/system_validation.py index 7cacaa1f1..3e8ed5c50 100644 --- a/openfe/protocols/openmm_utils/system_validation.py +++ b/openfe/protocols/openmm_utils/system_validation.py @@ -170,15 +170,9 @@ def _get_single_comps(state, comptype): else: return None - solvent_comp: Optional[SolventComponent] = _get_single_comps( - state, - SolventComponent - ) - - protein_comp: Optional[ProteinComponent] = _get_single_comps( - state, - ProteinComponent - ) + solvent_comp: Optional[SolventComponent] = _get_single_comps(state, SolventComponent) + + protein_comp: Optional[ProteinComponent] = _get_single_comps(state, ProteinComponent) small_mols = state.get_components_of_type(SmallMoleculeComponent) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index f1ff16403..aab18b689 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -29,12 +29,12 @@ import openfe from openfe import setup from openfe.protocols import openmm_rfe +from openfe.protocols.openmm_rfe._rfe_utils import topologyhelpers from openfe.protocols.openmm_rfe.hybridtop_units import ( - HybridTopologySetupUnit, - HybridTopologyMultiStateSimulationUnit, HybridTopologyMultiStateAnalysisUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologySetupUnit, ) -from openfe.protocols.openmm_rfe._rfe_utils import topologyhelpers from openfe.protocols.openmm_utils import omm_compute, system_creation from openfe.protocols.openmm_utils.charge_generation import ( HAS_ESPALOMA_CHARGE, @@ -47,10 +47,7 @@ def _get_units(protocol_units, unit_type): """ Helper method to extract setup units """ - return [ - pu for pu in protocol_units - if isinstance(pu, unit_type) - ] + return [pu for pu in protocol_units if isinstance(pu, unit_type)] @pytest.fixture() @@ -279,7 +276,7 @@ def test_setup_dry_sim_default_vacuum( system=setup_results["hybrid_system"], positions=setup_results["hybrid_positions"], selection_indices=setup_results["selection_indices"], - dry=True + dry=True, ) sampler = sim_results["sampler"] @@ -328,11 +325,7 @@ def test_setup_dry_sim_default_vacuum( def test_setup_gaff_vacuum( - benzene_vacuum_system, - toluene_vacuum_system, - benzene_to_toluene_mapping, - vac_settings, - tmpdir + benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, vac_settings, tmpdir ): """ Simple dry run of the setup unit to make sure that parameterisation @@ -475,10 +468,7 @@ def test_setup_core_element_change(vac_settings, tmpdir): assert system.getParticleMass(1) == 12.0127235 * omm_unit.amu # Get out the CustomNonbondedForce - cnf = [ - f for f in system.getForces() - if f.__class__.__name__ == "CustomNonbondedForce" - ][0] + cnf = [f for f in system.getForces() if f.__class__.__name__ == "CustomNonbondedForce"][0] # there should be no new unique atoms assert cnf.getInteractionGroupParameters(6) == [(), ()] # there should be one old unique atom (spare hydrogen from the benzene) @@ -512,7 +502,7 @@ def test_dry_run_ligand( system=setup_results["hybrid_system"], positions=setup_results["hybrid_positions"], selection_indices=setup_results["selection_indices"], - dry=True + dry=True, ) sampler = sim_results["sampler"] @@ -576,10 +566,7 @@ def tip4p_hybrid_factory( stateB=toluene_system, mapping=benzene_to_toluene_mapping, ) - dag_setup_unit = [ - pu for pu in dag.protocol_units - if isinstance(pu, HybridTopologySetupUnit) - ][0] + dag_setup_unit = [pu for pu in dag.protocol_units if isinstance(pu, HybridTopologySetupUnit)][0] shared_temp = tmp_path_factory.mktemp("tip4p_shared") scratch_temp = tmp_path_factory.mktemp("tip4p_scratch") @@ -694,10 +681,7 @@ def test_setup_ligand_system_cutoff( mapping=benzene_to_toluene_mapping, ) - dag_setup_unit = [ - pu for pu in dag.protocol_units - if isinstance(pu, HybridTopologySetupUnit) - ][0] + dag_setup_unit = [pu for pu in dag.protocol_units if isinstance(pu, HybridTopologySetupUnit)][0] with tmpdir.as_cwd(): hs = dag_setup_unit.run(dry=True)["hybrid_system"] @@ -764,10 +748,7 @@ def test_setup_charge_backends( dag = protocol.create(stateA=systemA, stateB=systemB, mapping=mapping) - dag_setup_unit = [ - pu for pu in dag.protocol_units - if isinstance(pu, HybridTopologySetupUnit) - ][0] + dag_setup_unit = [pu for pu in dag.protocol_units if isinstance(pu, HybridTopologySetupUnit)][0] with tmpdir.as_cwd(): results = dag_setup_unit.run(dry=True) @@ -806,11 +787,7 @@ def test_setup_charge_backends( np.testing.assert_allclose(c, ref, rtol=1e-4) -def test_setup_same_mol_different_charges( - benzene_modifications, - vac_settings, - tmpdir -): +def test_setup_same_mol_different_charges(benzene_modifications, vac_settings, tmpdir): """ Issue #1120 - make sure we can do an RFE of a system with different parameters but the same molecule. @@ -819,7 +796,7 @@ def test_setup_same_mol_different_charges( benzene_offmol = benzene_modifications["benzene"].to_openff() # Give state A some gasteiger charges - benzene_offmol.assign_partial_charges(partial_charge_method='gasteiger') + benzene_offmol.assign_partial_charges(partial_charge_method="gasteiger") stateA_charges = copy.deepcopy(benzene_offmol.partial_charges) stateA_mol = openfe.SmallMoleculeComponent.from_openff(benzene_offmol) @@ -838,10 +815,7 @@ def test_setup_same_mol_different_charges( stateB=openfe.ChemicalSystem({"l": stateB_mol}), mapping=mapping, ) - dag_setup_unit = [ - pu for pu in dag.protocol_units - if isinstance(pu, HybridTopologySetupUnit) - ][0] + dag_setup_unit = [pu for pu in dag.protocol_units if isinstance(pu, HybridTopologySetupUnit)][0] with tmpdir.as_cwd(): results = dag_setup_unit.run(dry=True) @@ -926,10 +900,7 @@ def check_propchgs(smc, charge_array): stateB=openfe.ChemicalSystem({"l": toluene_smc}), mapping=mapping, ) - dag_setup_unit = [ - pu for pu in dag.protocol_units - if isinstance(pu, HybridTopologySetupUnit) - ][0] + dag_setup_unit = [pu for pu in dag.protocol_units if isinstance(pu, HybridTopologySetupUnit)][0] with tmpdir.as_cwd(): results = dag_setup_unit.run(dry=True) @@ -1034,7 +1005,7 @@ def test_virtual_sites_no_reassign( system=setup_results["hybrid_system"], positions=setup_results["hybrid_positions"], selection_indices=setup_results["selection_indices"], - dry=True + dry=True, ) @@ -1100,10 +1071,10 @@ def test_dry_run_complex( with tmpdir.as_cwd(): setup_results = dag_setup_unit.run(dry=True) sim_results = dag_sim_unit.run( - system=setup_results["hybrid_system"], - positions=setup_results["hybrid_positions"], - selection_indices=setup_results["selection_indices"], - dry=True + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + dry=True, ) sampler = sim_results["sampler"] assert isinstance(sampler, MultiStateSampler) @@ -1162,8 +1133,7 @@ def test_setup_ligand_overlap_warning( mapping=mapping, ) dag_setup_unit = [ - pu for pu in dag.protocol_units - if isinstance(pu, HybridTopologySetupUnit) + pu for pu in dag.protocol_units if isinstance(pu, HybridTopologySetupUnit) ][0] with tmpdir.as_cwd(): dag_setup_unit.run(dry=True) @@ -1193,7 +1163,7 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): "positions": Path("positions.npy"), "pdb_structure": Path("hybrid_system.pdb"), "selection_indices": np.zeros(100), - } + }, ), mock.patch( "openfe.protocols.openmm_rfe.hybridtop_units.np.load", @@ -1203,20 +1173,20 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): "openfe.protocols.openmm_rfe.hybridtop_units.deserialize", return_value={ "item": "foo", - } + }, ), mock.patch( "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateSimulationUnit.run", return_value={ "nc": Path("file.nc"), "checkpoint": Path("chk.chk"), - } + }, ), mock.patch( "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateAnalysisUnit.run", return_value={ "foo": "bar", - } + }, ), ): setup_results = {} @@ -1234,8 +1204,7 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): for u in sim_units: rid = u.inputs["repeat_id"] sim_results[rid] = u.execute( - context=gufe.Context(tmpdir, tmpdir), - setup_results=setup_results[rid] + context=gufe.Context(tmpdir, tmpdir), setup_results=setup_results[rid] ) for u in analysis_units: @@ -1243,7 +1212,7 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): analysis_results[rid] = u.execute( context=gufe.Context(tmpdir, tmpdir), setup_results=setup_results[rid], - simulation_results=sim_results[rid] + simulation_results=sim_results[rid], ) repeats = set() for results in [setup_results, sim_results, analysis_results]: @@ -1265,7 +1234,7 @@ def test_gather(solvent_protocol_dag, tmpdir): "positions": Path("positions.npy"), "pdb_structure": Path("hybrid_system.pdb"), "selection_indices": np.zeros(100), - } + }, ), mock.patch( "openfe.protocols.openmm_rfe.hybridtop_units.np.load", @@ -1275,20 +1244,20 @@ def test_gather(solvent_protocol_dag, tmpdir): "openfe.protocols.openmm_rfe.hybridtop_units.deserialize", return_value={ "item": "foo", - } + }, ), mock.patch( "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateSimulationUnit.run", return_value={ "nc": Path("file.nc"), "checkpoint": Path("chk.chk"), - } + }, ), mock.patch( "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateAnalysisUnit.run", return_value={ "foo": "bar", - } + }, ), ): dagres = gufe.protocols.execute_DAG( @@ -2235,7 +2204,7 @@ def test_dry_run_vacuum_write_frequency( system=setup_results["hybrid_system"], positions=setup_results["hybrid_positions"], selection_indices=setup_results["selection_indices"], - dry=True + dry=True, ) sampler = sim_results["sampler"] diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py index 9fda51710..404b03c06 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_tokenization.py @@ -7,9 +7,9 @@ from openfe.protocols import openmm_rfe from openfe.protocols.openmm_rfe.hybridtop_units import ( - HybridTopologySetupUnit, - HybridTopologyMultiStateSimulationUnit, HybridTopologyMultiStateAnalysisUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologySetupUnit, ) """ @@ -36,12 +36,7 @@ def rfe_protocol_other_input_units(): @pytest.fixture -def protocol_units( - rfe_protocol, - benzene_system, - toluene_system, - benzene_to_toluene_mapping -): +def protocol_units(rfe_protocol, benzene_system, toluene_system, benzene_to_toluene_mapping): pus = rfe_protocol.create( stateA=benzene_system, stateB=toluene_system, @@ -51,27 +46,21 @@ def protocol_units( @pytest.fixture -def protocol_setup_unit( - protocol_units -): +def protocol_setup_unit(protocol_units): for pu in protocol_units: if isinstance(pu, HybridTopologySetupUnit): return pu @pytest.fixture -def protocol_simulation_unit( - protocol_units -): +def protocol_simulation_unit(protocol_units): for pu in protocol_units: if isinstance(pu, HybridTopologyMultiStateSimulationUnit): return pu @pytest.fixture -def protocol_analysis_unit( - protocol_units -): +def protocol_analysis_unit(protocol_units): for pu in protocol_units: if isinstance(pu, HybridTopologyMultiStateAnalysisUnit): return pu diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py index 984dbd536..7865118a2 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_validation.py @@ -33,49 +33,54 @@ def test_invalid_protocol_repeats(): settings.protocol_repeats = -1 -@pytest.mark.parametrize('state', ['A', 'B']) +@pytest.mark.parametrize("state", ["A", "B"]) def test_endstate_two_alchemcomp_stateA(state, benzene_modifications): - first_state = openfe.ChemicalSystem({ - 'ligandA': benzene_modifications['benzene'], - 'ligandB': benzene_modifications['toluene'], - 'solvent': openfe.SolventComponent(), - }) - other_state = openfe.ChemicalSystem({ - 'ligandC': benzene_modifications['phenol'], - 'solvent': openfe.SolventComponent(), - }) - - if state == 'A': + first_state = openfe.ChemicalSystem( + { + "ligandA": benzene_modifications["benzene"], + "ligandB": benzene_modifications["toluene"], + "solvent": openfe.SolventComponent(), + } + ) + other_state = openfe.ChemicalSystem( + { + "ligandC": benzene_modifications["phenol"], + "solvent": openfe.SolventComponent(), + } + ) + + if state == "A": args = (first_state, other_state) else: args = (other_state, first_state) with pytest.raises(ValueError, match="Only one alchemical component"): - openmm_rfe.RelativeHybridTopologyProtocol._validate_endstates( - *args - ) + openmm_rfe.RelativeHybridTopologyProtocol._validate_endstates(*args) -@pytest.mark.parametrize('state', ['A', 'B']) + +@pytest.mark.parametrize("state", ["A", "B"]) def test_endstates_not_smc(state, benzene_modifications): - first_state = openfe.ChemicalSystem({ - 'ligand': benzene_modifications['benzene'], - 'foo': openfe.SolventComponent(), - }) - other_state = openfe.ChemicalSystem({ - 'ligand': benzene_modifications['benzene'], - 'foo': benzene_modifications['toluene'], - }) - - if state == 'A': + first_state = openfe.ChemicalSystem( + { + "ligand": benzene_modifications["benzene"], + "foo": openfe.SolventComponent(), + } + ) + other_state = openfe.ChemicalSystem( + { + "ligand": benzene_modifications["benzene"], + "foo": benzene_modifications["toluene"], + } + ) + + if state == "A": args = (first_state, other_state) else: args = (other_state, first_state) errmsg = "only SmallMoleculeComponents transformations" with pytest.raises(ValueError, match=errmsg): - openmm_rfe.RelativeHybridTopologyProtocol._validate_endstates( - *args - ) + openmm_rfe.RelativeHybridTopologyProtocol._validate_endstates(*args) def test_validate_mapping_none_mapping(): @@ -88,12 +93,11 @@ def test_validate_mapping_multi_mapping(benzene_to_toluene_mapping): errmsg = "A single LigandAtomMapping is expected" with pytest.raises(ValueError, match=errmsg): openmm_rfe.RelativeHybridTopologyProtocol._validate_mapping( - [benzene_to_toluene_mapping] * 2, - None + [benzene_to_toluene_mapping] * 2, None ) -@pytest.mark.parametrize('state', ['A', 'B']) +@pytest.mark.parametrize("state", ["A", "B"]) def test_validate_mapping_alchem_not_in(state, benzene_to_toluene_mapping): errmsg = f"not in alchemical components of state{state}" @@ -110,10 +114,7 @@ def test_validate_mapping_alchem_not_in(state, benzene_to_toluene_mapping): def test_vaccuum_PME_error( - benzene_vacuum_system, - toluene_vacuum_system, - benzene_to_toluene_mapping, - solv_settings + benzene_vacuum_system, toluene_vacuum_system, benzene_to_toluene_mapping, solv_settings ): p = openmm_rfe.RelativeHybridTopologyProtocol(settings=solv_settings) @@ -126,79 +127,66 @@ def test_vaccuum_PME_error( ) -@pytest.mark.parametrize('charge', [None, 'gasteiger']) -def test_smcs_same_charge_passes( - charge, - benzene_modifications -): - benzene = benzene_modifications['benzene'] +@pytest.mark.parametrize("charge", [None, "gasteiger"]) +def test_smcs_same_charge_passes(charge, benzene_modifications): + benzene = benzene_modifications["benzene"] if charge is None: smc = benzene else: offmol = benzene.to_openff() - offmol.assign_partial_charges(partial_charge_method='gasteiger') + offmol.assign_partial_charges(partial_charge_method="gasteiger") smc = openfe.SmallMoleculeComponent.from_openff(offmol) # Just pass the same thing twice - state = openfe.ChemicalSystem({'l': smc}) + state = openfe.ChemicalSystem({"l": smc}) openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs(state, state) -def test_smcs_different_charges_none_not_none( - benzene_modifications -): +def test_smcs_different_charges_none_not_none(benzene_modifications): # smcA has no charges - smcA = benzene_modifications['benzene'] + smcA = benzene_modifications["benzene"] # smcB has charges offmol = smcA.to_openff() - offmol.assign_partial_charges(partial_charge_method='gasteiger') + offmol.assign_partial_charges(partial_charge_method="gasteiger") smcB = openfe.SmallMoleculeComponent.from_openff(offmol) - state = openfe.ChemicalSystem({'a': smcA, 'b': smcB}) + state = openfe.ChemicalSystem({"a": smcA, "b": smcB}) errmsg = "isomorphic but with different charges" with pytest.raises(ValueError, match=errmsg): - openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs( - state, state - ) + openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs(state, state) -def test_smcs_different_charges_all( - benzene_modifications -): - offmol = benzene_modifications['benzene'].to_openff() - offmol.assign_partial_charges(partial_charge_method='gasteiger') +def test_smcs_different_charges_all(benzene_modifications): + offmol = benzene_modifications["benzene"].to_openff() + offmol.assign_partial_charges(partial_charge_method="gasteiger") smcA = openfe.SmallMoleculeComponent.from_openff(offmol) # now alter the offmol charges, scaling by 0.1 offmol.partial_charges *= 0.1 smcB = openfe.SmallMoleculeComponent.from_openff(offmol) - state = openfe.ChemicalSystem({'l1': smcA, 'l2': smcB}) + state = openfe.ChemicalSystem({"l1": smcA, "l2": smcB}) errmsg = "isomorphic but with different charges" with pytest.raises(ValueError, match=errmsg): - openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs( - state, state - ) + openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs(state, state) -def test_smcs_different_charges_different_endstates( - benzene_modifications -): +def test_smcs_different_charges_different_endstates(benzene_modifications): # This should just pass, the charge is different but only # in the end states - which is an acceptable transformation. - offmol = benzene_modifications['benzene'].to_openff() - offmol.assign_partial_charges(partial_charge_method='gasteiger') + offmol = benzene_modifications["benzene"].to_openff() + offmol.assign_partial_charges(partial_charge_method="gasteiger") smcA = openfe.SmallMoleculeComponent.from_openff(offmol) # now alter the offmol charges, scaling by 0.1 offmol.partial_charges *= 0.1 smcB = openfe.SmallMoleculeComponent.from_openff(offmol) - stateA = openfe.ChemicalSystem({'l': smcA}) - stateB = openfe.ChemicalSystem({'l': smcB}) + stateA = openfe.ChemicalSystem({"l": smcA}) + stateB = openfe.ChemicalSystem({"l": smcB}) openmm_rfe.RelativeHybridTopologyProtocol._validate_smcs(stateA, stateB) @@ -226,20 +214,15 @@ def test_nonwater_solvent_error( benzene_to_toluene_mapping, solv_settings, ): - solvent = openfe.SolventComponent(smiles='C') + solvent = openfe.SolventComponent(smiles="C") stateA = openfe.ChemicalSystem( { - 'ligand': benzene_modifications['benzene'], - 'solvent': solvent, + "ligand": benzene_modifications["benzene"], + "solvent": solvent, } ) - stateB = openfe.ChemicalSystem( - { - 'ligand': benzene_modifications['toluene'], - 'solvent': solvent - } - ) + stateB = openfe.ChemicalSystem({"ligand": benzene_modifications["toluene"], "solvent": solvent}) p = openmm_rfe.RelativeHybridTopologyProtocol(settings=solv_settings) @@ -260,17 +243,17 @@ def test_too_many_solv_comps_error( ): stateA = openfe.ChemicalSystem( { - 'ligand': benzene_modifications['benzene'], - 'solvent!': openfe.SolventComponent(neutralize=True), - 'solvent2': openfe.SolventComponent(neutralize=False), + "ligand": benzene_modifications["benzene"], + "solvent!": openfe.SolventComponent(neutralize=True), + "solvent2": openfe.SolventComponent(neutralize=False), } ) stateB = openfe.ChemicalSystem( { - 'ligand': benzene_modifications['toluene'], - 'solvent!': openfe.SolventComponent(neutralize=True), - 'solvent2': openfe.SolventComponent(neutralize=False), + "ligand": benzene_modifications["toluene"], + "solvent!": openfe.SolventComponent(neutralize=True), + "solvent2": openfe.SolventComponent(neutralize=False), } ) @@ -304,11 +287,7 @@ def test_bad_solv_settings( errmsg = "Only one of solvent_padding, number_of_solvent_molecules," with pytest.raises(ValueError, match=errmsg): - p.validate( - stateA=benzene_system, - stateB=toluene_system, - mapping=benzene_to_toluene_mapping - ) + p.validate(stateA=benzene_system, stateB=toluene_system, mapping=benzene_to_toluene_mapping) def test_too_many_prot_comps_error( @@ -318,22 +297,21 @@ def test_too_many_prot_comps_error( eg5_protein, solv_settings, ): - stateA = openfe.ChemicalSystem( { - 'ligand': benzene_modifications['benzene'], - 'solvent': openfe.SolventComponent(), - 'protein1': T4_protein_component, - 'protein2': eg5_protein, + "ligand": benzene_modifications["benzene"], + "solvent": openfe.SolventComponent(), + "protein1": T4_protein_component, + "protein2": eg5_protein, } ) stateB = openfe.ChemicalSystem( { - 'ligand': benzene_modifications['toluene'], - 'solvent': openfe.SolventComponent(), - 'protein1': T4_protein_component, - 'protein2': eg5_protein, + "ligand": benzene_modifications["toluene"], + "solvent": openfe.SolventComponent(), + "protein1": T4_protein_component, + "protein2": eg5_protein, } ) @@ -433,19 +411,16 @@ def test_greater_than_one_charge_difference_error(aniline_to_benzoic_mapping): def test_get_charge_difference(mapping_name, result, request, caplog): mapping = request.getfixturevalue(mapping_name) caplog.set_level(logging.INFO) - + ion = r"Na+" if result == -1 else r"Cl-" msg = ( f"A charge difference of {result} is observed " "between the end states. This will be addressed by " f"transforming a water into a {ion} ion" ) - + openmm_rfe.RelativeHybridTopologyProtocol._validate_charge_difference( - mapping, - "pme", - True, - openfe.SolventComponent() + mapping, "pme", True, openfe.SolventComponent() ) if result != 0: @@ -471,7 +446,7 @@ def test_hightimestep( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) @@ -493,13 +468,11 @@ def test_time_per_iteration_divmod( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) -@pytest.mark.parametrize( - "attribute", ["equilibration_length", "production_length"] -) +@pytest.mark.parametrize("attribute", ["equilibration_length", "production_length"]) def test_simsteps_not_timestep_divisible( attribute, benzene_vacuum_system, @@ -517,13 +490,11 @@ def test_simsteps_not_timestep_divisible( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) -@pytest.mark.parametrize( - "attribute", ["equilibration_length", "production_length"] -) +@pytest.mark.parametrize("attribute", ["equilibration_length", "production_length"]) def test_simsteps_not_mcstep_divisible( attribute, benzene_vacuum_system, @@ -534,17 +505,14 @@ def test_simsteps_not_mcstep_divisible( setattr(vac_settings.simulation_settings, attribute, 102 * offunit.ps) p = openmm_rfe.RelativeHybridTopologyProtocol(settings=vac_settings) - errmsg = ( - "should contain a number of steps divisible by the number of " - "integrator timesteps" - ) + errmsg = "should contain a number of steps divisible by the number of integrator timesteps" with pytest.raises(ValueError, match=errmsg): p.validate( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) @@ -565,14 +533,11 @@ def test_checkpoint_interval_not_divisible_time_per_iter( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) -@pytest.mark.parametrize( - "attribute", - ["positions_write_frequency", "velocities_write_frequency"] -) +@pytest.mark.parametrize("attribute", ["positions_write_frequency", "velocities_write_frequency"]) def test_pos_vel_write_frequency_not_divisible( benzene_vacuum_system, toluene_vacuum_system, @@ -590,13 +555,12 @@ def test_pos_vel_write_frequency_not_divisible( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) @pytest.mark.parametrize( - "attribute", - ["real_time_analysis_interval", "real_time_analysis_interval"] + "attribute", ["real_time_analysis_interval", "real_time_analysis_interval"] ) def test_real_time_analysis_not_divisible( benzene_vacuum_system, @@ -615,9 +579,10 @@ def test_real_time_analysis_not_divisible( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) + def test_n_replicas_not_n_windows( benzene_vacuum_system, toluene_vacuum_system, @@ -637,5 +602,5 @@ def test_n_replicas_not_n_windows( stateA=benzene_vacuum_system, stateB=toluene_vacuum_system, mapping=benzene_to_toluene_mapping, - extends=None + extends=None, ) From f7a23326557cf7c92da44c1170e26364b0e0d4d0 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 3 Jan 2026 16:41:52 -0500 Subject: [PATCH 71/91] remove redundant list declarations --- openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index aab18b689..3a9c7ba49 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -193,10 +193,6 @@ def test_repeat_units(benzene_system, toluene_system, benzene_to_toluene_mapping assert len(pus) == 9 # Aggregate some info for each repeat - setup = [] - simulation = [] - analysis = [] - setup = _get_units(pus, HybridTopologySetupUnit) simulation = _get_units(pus, HybridTopologyMultiStateSimulationUnit) analysis = _get_units(pus, HybridTopologyMultiStateAnalysisUnit) From bd4d2888aaf27cddd831d8c15f4f9102ac09065e Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 3 Jan 2026 21:30:26 -0500 Subject: [PATCH 72/91] move integrator creation out of try/except --- openfe/protocols/openmm_rfe/hybridtop_units.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 94b4dc727..fa5e1fdb9 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1248,14 +1248,14 @@ def run( restrict_cpu_count=restrict_cpu, ) - try: - # Get the integrator - integrator = self._get_integrator( - integrator_settings=settings["integrator_settings"], - simulation_settings=settings["simulation_settings"], - system=system, - ) + # Get the integrator + integrator = self._get_integrator( + integrator_settings=settings["integrator_settings"], + simulation_settings=settings["simulation_settings"], + system=system, + ) + try: # Get the reporter reporter = self._get_reporter( storage_path=self.shared_basepath, From 296da5d1c46939b7c827748269dc9b5c705c9932 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 17 Jan 2026 18:38:58 +0000 Subject: [PATCH 73/91] ignore typing issues due to mixin --- openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 1947103f0..6c267dfd6 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1063,7 +1063,7 @@ def _get_sampler( # Restarting doesn't need any setup, we just rebuild from storage. if restart: - sampler = _SAMPLERS[sampler_method].from_storage(reporter) + sampler = _SAMPLERS[sampler_method].from_storage(reporter) # type: ignore[attr-defined] else: sampler = _SAMPLERS[sampler_method](**sampler_kwargs) From 4e26101e66f037c1b341ca841b52e947603daa37 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Wed, 21 Jan 2026 23:42:54 +0000 Subject: [PATCH 74/91] Add a base test and some fixes --- .../protocols/openmm_rfe/hybridtop_units.py | 56 ++++-- .../openmm_rfe/test_hybrid_top_resume.py | 182 ++++++++++++++++++ 2 files changed, 219 insertions(+), 19 deletions(-) create mode 100644 openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 6c267dfd6..b4abd27a7 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1064,6 +1064,20 @@ def _get_sampler( # Restarting doesn't need any setup, we just rebuild from storage. if restart: sampler = _SAMPLERS[sampler_method].from_storage(reporter) # type: ignore[attr-defined] + + # We also do some lightweight checks to make sure we are + # running the right system. + sampler_system = sampler._thermodynamic_states[0].get_system(remove_thermostat=True) + if ( + (simulation_settings.n_replicas != sampler.n_states != sampler.n_replicas) or + (system.getNumForces() != sampler_system.getNumForces()) or + (system.getNumParticles() != sampler_system.getNumParticles()) or + (system.getNumConstraints() != sampler_system.getNumConstraints()) or + (sampler.mcmc_moves[0].n_steps != steps_per_iteration) or + (sampler.mcmc_moves[0].timestep != integrator.timestep) + ): + errmsg = "System in checkpoint does not match protocol system, cannot resume" + raise ValueError(errmsg) else: sampler = _SAMPLERS[sampler_method](**sampler_kwargs) @@ -1286,25 +1300,29 @@ def run( dry=dry, ) finally: - # close reporter when you're done, prevent - # file handle clashes - reporter.close() - - # clear GPU contexts - # TODO: use cache.empty() calls when openmmtools #690 is resolved - # replace with above - for context in list(sampler.energy_context_cache._lru._data.keys()): - del sampler.energy_context_cache._lru._data[context] - for context in list(sampler.sampler_context_cache._lru._data.keys()): - del sampler.sampler_context_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.sampler_context_cache, sampler.energy_context_cache - - if not dry: - del integrator, sampler + # Have to wrap this in a try except, because we might + # be in a situation where reporter or sampler weren't created + try: + # Order is reporter, sampler, integrator + reporter.close() # close to prevent file handle clashes + + # clear GPU contexts + # TODO: use cache.empty() calls when openmmtools #690 is resolved + # replace with above + for context in list(sampler.energy_context_cache._lru._data.keys()): + del sampler.energy_context_cache._lru._data[context] + for context in list(sampler.sampler_context_cache._lru._data.keys()): + del sampler.sampler_context_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.sampler_context_cache, sampler.energy_context_cache + + if not dry: + del integrator, sampler + except UnboundLocalError: + pass if not dry: # pragma: no-cover return { diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py new file mode 100644 index 000000000..fe13ecae4 --- /dev/null +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -0,0 +1,182 @@ +# This code is part of OpenFE and is licensed under the MIT license. +# For details, see https://github.com/OpenFreeEnergy/openfe +import os +import pathlib +import shutil +import copy + +import numpy as np +import pooch +import pytest +from gufe.protocols import execute_DAG +from numpy.testing import assert_allclose +from openff.units import unit as offunit +from openff.units.openmm import from_openmm +from openfe_analysis.utils.multistate import _determine_position_indices + +import openfe +from openfe.protocols import openmm_rfe +from openfe.protocols.openmm_rfe.hybridtop_units import ( + HybridTopologyMultiStateAnalysisUnit, + HybridTopologyMultiStateSimulationUnit, + HybridTopologySetupUnit, +) +from openmmtools.multistate import MultiStateReporter +from openfe.protocols.openmm_rfe._rfe_utils.multistate import HybridRepexSampler + +from .test_hybrid_top_protocol import _get_units +from ...conftest import HAS_INTERNET + + +POOCH_CACHE = pooch.os_cache("openfe") +zenodo_resume_data = pooch.create( + path=POOCH_CACHE, + base_url="doi:10.5281/zenodo.18331259", + registry={ + "multistate_checkpoints.zip": "md5:2cf8aa417ac8311aca1551d4abf3b3ed" + }, +) + + +@pytest.fixture(scope='module') +def trajectory_path(): + zenodo_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) + topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" + subdir = "hybrid_top" + filename = "simulation.nc" + return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") + + +@pytest.fixture(scope='module') +def checkpoint_path(): + zenodo_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) + topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" + subdir = "hybrid_top" + filename = "checkpoint.chk" + return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") + + +@pytest.mark.skipif( + not os.path.exists(POOCH_CACHE) and not HAS_INTERNET, + reason="Internet unavailable and test data is not cached locally", +) +class TestCheckpointResuming: + @pytest.fixture(scope='class') + def protocol_dag(self, benzene_system, toluene_system, benzene_to_toluene_mapping): + settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() + settings.solvation_settings.solvent_padding = None + settings.solvation_settings.number_of_solvent_molecules = 750 + settings.solvation_settings.box_shape = "dodecahedron" + settings.protocol_repeats = 1 + settings.simulation_settings.equilibration_length = 100 * offunit.picosecond + settings.simulation_settings.production_length = 200 * offunit.picosecond + settings.simulation_settings.time_per_iteration = 2.5 * offunit.picosecond + settings.output_settings.checkpoint_interval = 100 * offunit.picosecond + protocol = openmm_rfe.RelativeHybridTopologyProtocol(settings=settings) + + return protocol.create( + stateA=benzene_system, + stateB=toluene_system, + mapping=benzene_to_toluene_mapping + ) + + @staticmethod + def _check_sampler(sampler, num_iterations: int): + # Helper method to do some checks on the sampler + assert sampler._iteration == num_iterations + assert sampler.number_of_iterations == 80 + assert sampler.is_completed is (num_iterations == 80) + assert sampler.n_states == sampler.n_replicas == 11 + assert sampler.is_periodic + assert sampler.mcmc_moves[0].n_steps == 625 + assert from_openmm(sampler.mcmc_moves[0].timestep) == 4 * offunit.fs + + @staticmethod + def _get_positions(dataset): + frame_list = _determine_position_indices(dataset) + positions = [] + for frame in frame_list: + positions.append( + copy.deepcopy(dataset.variables["positions"][frame].data) + ) + return positions + + def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): + """ + Attempt to resume a simulation unit with pre-existing checkpoint & + trajectory files. + """ + # define a temp directory path + cwd = pathlib.Path(str(tmpdir)) + + shutil.copyfile(trajectory_path, f"{cwd}/{trajectory_path.name}") + shutil.copyfile(checkpoint_path, f"{cwd}/{checkpoint_path.name}") + + # 1. Check that the trajectory / checkpoint contain what we expect + reporter = MultiStateReporter( + f"{cwd}/{trajectory_path.name}", + checkpoint_storage=checkpoint_path.name, + ) + sampler = HybridRepexSampler.from_storage(reporter) + + self._check_sampler(sampler, num_iterations=40) + # Deep copy energies & positions for later tests + init_energies = copy.deepcopy(reporter.read_energies())[0] + assert init_energies.shape == (41, 11, 11) + init_positions = self._get_positions(reporter._storage[0]) + assert len(init_positions) == 2 + + reporter.close() + del sampler + + # 2. get & run the units + pus = list(protocol_dag.protocol_units) + setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] + simulation_unit = _get_units(pus, HybridTopologyMultiStateSimulationUnit)[0] + analysis_unit = _get_units(pus, HybridTopologyMultiStateAnalysisUnit)[0] + + # Dry run the setup since it'll be easier to use the objects directly + setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) + + # Now we run the simulation in resume mode + sim_results = simulation_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + scratch_basepath=cwd, + shared_basepath=cwd, + ) + + # TODO: can't do this right now: openfe-analysis isn't closing + # netcdf files properly, so we can't do any follow-up operations + # Once openfe-analysis is released, add tests for this. + # # Finally we analyze the results + # analysis_results = analysis_unit.run( + # pdb_file=setup_results["pdb_structure"], + # trajectory=sim_results["nc"], + # checkpoint=sim_results["checkpoint"], + # scratch_basepath=cwd, + # shared_basepath=cwd, + # ) + + # 3. Analyze the trajectory/checkpoint again + reporter = MultiStateReporter( + f"{cwd}/{trajectory_path.name}", + checkpoint_storage=checkpoint_path.name, + ) + sampler = HybridRepexSampler.from_storage(reporter) + + self._check_sampler(sampler, num_iterations=80) + + # Check the energies and positions + energies = reporter.read_energies()[0] + assert energies.shape == (81, 11, 11) + assert_allclose(init_energies, energies[:41]) + + positions = self._get_positions(reporter._storage[0]) + assert len(positions) == 3 + for i in range(2): + assert_allclose(positions[i], init_positions[i]) + + reporter.close() + del sampler From d9da07f1229f7fa1918c4bf71f0a9ab2e2172d7a Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 22 Jan 2026 00:38:23 +0000 Subject: [PATCH 75/91] Add extra tests --- .../protocols/openmm_rfe/hybridtop_units.py | 15 +-- .../openmm_rfe/test_hybrid_top_resume.py | 96 +++++++++++++++---- 2 files changed, 84 insertions(+), 27 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index b4abd27a7..6174fc1a1 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -816,14 +816,14 @@ class HybridTopologyMultiStateSimulationUnit(gufe.ProtocolUnit, HybridTopologyUn """ @staticmethod - def _check_restart(settings: dict[str, SettingsBaseModel], shared_path: pathlib.Path): + def _check_restart(output_settings: SettingsBaseModel, shared_path: pathlib.Path): """ Check if we are doing a restart. Parameters ---------- - settings : dict[str, SettingsBaseModel] - The settings for this transformation + output_settings : SettingsBaseModel + The simulation output settings shared_path : pathlib.Path The shared directory where we should be looking for existing files. @@ -833,8 +833,8 @@ def _check_restart(settings: dict[str, SettingsBaseModel], shared_path: pathlib. shared directory but in the future this may expand depending on how warehouse works. """ - trajectory = shared_path / settings["output_settings"].output_filename - checkpoint = shared_path / settings["output_settings"].checkpoint_storage_filename + trajectory = shared_path / output_settings.output_filename + checkpoint = shared_path / output_settings.checkpoint_storage_filename if trajectory.is_file() and checkpoint.is_file(): return True @@ -1242,7 +1242,10 @@ def run( settings = self._get_settings(self._inputs["protocol"].settings) # Check for a restart - self.restart = self._check_restart(settings=settings, shared_path=self.shared_basepath) + self.restart = self._check_restart( + output_settings=settings["output_settings"], + shared_path=self.shared_basepath + ) # Get the lambda schedule # TODO - this should be better exposed to users diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index fe13ecae4..027bf15cc 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -13,6 +13,7 @@ from openff.units import unit as offunit from openff.units.openmm import from_openmm from openfe_analysis.utils.multistate import _determine_position_indices +import openmm import openfe from openfe.protocols import openmm_rfe @@ -56,24 +57,45 @@ def checkpoint_path(): return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") +@pytest.fixture() +def protocol_settings(): + settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() + settings.solvation_settings.solvent_padding = None + settings.solvation_settings.number_of_solvent_molecules = 750 + settings.solvation_settings.box_shape = "dodecahedron" + settings.protocol_repeats = 1 + settings.simulation_settings.equilibration_length = 100 * offunit.picosecond + settings.simulation_settings.production_length = 200 * offunit.picosecond + settings.simulation_settings.time_per_iteration = 2.5 * offunit.picosecond + settings.output_settings.checkpoint_interval = 100 * offunit.picosecond + return settings + + @pytest.mark.skipif( not os.path.exists(POOCH_CACHE) and not HAS_INTERNET, reason="Internet unavailable and test data is not cached locally", ) -class TestCheckpointResuming: - @pytest.fixture(scope='class') - def protocol_dag(self, benzene_system, toluene_system, benzene_to_toluene_mapping): - settings = openmm_rfe.RelativeHybridTopologyProtocol.default_settings() - settings.solvation_settings.solvent_padding = None - settings.solvation_settings.number_of_solvent_molecules = 750 - settings.solvation_settings.box_shape = "dodecahedron" - settings.protocol_repeats = 1 - settings.simulation_settings.equilibration_length = 100 * offunit.picosecond - settings.simulation_settings.production_length = 200 * offunit.picosecond - settings.simulation_settings.time_per_iteration = 2.5 * offunit.picosecond - settings.output_settings.checkpoint_interval = 100 * offunit.picosecond - protocol = openmm_rfe.RelativeHybridTopologyProtocol(settings=settings) +def test_check_restart(protocol_settings, trajectory_path): + assert openmm_rfe.HybridTopologyMultiStateSimulationUnit._check_restart( + output_settings=protocol_settings.output_settings, + shared_path=trajectory_path.parent, + ) + + assert not openmm_rfe.HybridTopologyMultiStateSimulationUnit._check_restart( + output_settings=protocol_settings.output_settings, + shared_path=pathlib.Path('.'), + ) + +@pytest.mark.skipif( + not os.path.exists(POOCH_CACHE) and not HAS_INTERNET, + reason="Internet unavailable and test data is not cached locally", +) +class TestCheckpointResuming: + @pytest.fixture() + def protocol_dag(self, protocol_settings, benzene_system, toluene_system, benzene_to_toluene_mapping): + protocol = openmm_rfe.RelativeHybridTopologyProtocol(settings=protocol_settings) + return protocol.create( stateA=benzene_system, stateB=toluene_system, @@ -101,21 +123,25 @@ def _get_positions(dataset): ) return positions + @staticmethod + def _copy_simfiles(cwd: pathlib.Path, trajectory_path, checkpoint_path): + shutil.copyfile(trajectory_path, f"{cwd}/{trajectory_path.name}") + shutil.copyfile(checkpoint_path, f"{cwd}/{checkpoint_path.name}") + + @pytest.mark.integration def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): """ Attempt to resume a simulation unit with pre-existing checkpoint & trajectory files. """ - # define a temp directory path + # define a temp directory path & copy files cwd = pathlib.Path(str(tmpdir)) - - shutil.copyfile(trajectory_path, f"{cwd}/{trajectory_path.name}") - shutil.copyfile(checkpoint_path, f"{cwd}/{checkpoint_path.name}") + self._copy_simfiles(cwd, trajectory_path, checkpoint_path) # 1. Check that the trajectory / checkpoint contain what we expect reporter = MultiStateReporter( - f"{cwd}/{trajectory_path.name}", - checkpoint_storage=checkpoint_path.name, + f"{cwd}/simulation.nc", + checkpoint_storage="checkpoint.chk", ) sampler = HybridRepexSampler.from_storage(reporter) @@ -161,8 +187,8 @@ def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): # 3. Analyze the trajectory/checkpoint again reporter = MultiStateReporter( - f"{cwd}/{trajectory_path.name}", - checkpoint_storage=checkpoint_path.name, + f"{cwd}/simulation.nc", + checkpoint_storage="checkpoint.chk", ) sampler = HybridRepexSampler.from_storage(reporter) @@ -180,3 +206,31 @@ def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): reporter.close() del sampler + + def test_resume_fail(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): + """ + Test that the run unit will fail with a system incompatible + to the one present in the trajectory/checkpoint files. + """ + # define a temp directory path & copy files + cwd = pathlib.Path(str(tmpdir)) + self._copy_simfiles(cwd, trajectory_path, checkpoint_path) + + pus = list(protocol_dag.protocol_units) + setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] + simulation_unit = _get_units(pus, HybridTopologyMultiStateSimulationUnit)[0] + analysis_unit = _get_units(pus, HybridTopologyMultiStateAnalysisUnit)[0] + + # Dry run the setup since it'll be easier to use the objects directly + setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) + + # Fake system should trigger a mismatch + with pytest.raises(ValueError, match="System in checkpoint does not"): + sim_results = simulation_unit.run( + system=openmm.System(), + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + scratch_basepath=cwd, + shared_basepath=cwd, + ) + From b1631e6afbb6058e47aefd08325fb4a270fd666f Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 22 Jan 2026 00:39:39 +0000 Subject: [PATCH 76/91] revert changes to serialization tool --- devtools/data/gen_serialized_results.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/devtools/data/gen_serialized_results.py b/devtools/data/gen_serialized_results.py index 9dd6e35ca..9cbe2ea1e 100644 --- a/devtools/data/gen_serialized_results.py +++ b/devtools/data/gen_serialized_results.py @@ -103,7 +103,6 @@ def execute_and_serialize( logger.info(f"running {simname}") with tempfile.TemporaryDirectory() as tmpdir: workdir = pathlib.Path(tmpdir) - workdir = pathlib.Path(".") dagres = gufe.protocols.execute_DAG( dag, shared_basedir=workdir, @@ -238,7 +237,6 @@ def generate_rfe_settings(): settings = RelativeHybridTopologyProtocol.default_settings() settings.simulation_settings.equilibration_length = 10 * unit.picosecond settings.simulation_settings.production_length = 250 * unit.picosecond - settings.output_settings.checkpoint_interval = 10 * unit.picosecond settings.forcefield_settings.nonbonded_method = "nocutoff" return settings From f66b18c55cb9d1387612eea1b61ceccc3253a84e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 00:43:49 +0000 Subject: [PATCH 77/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../protocols/openmm_rfe/hybridtop_units.py | 23 +++++------ .../openmm_rfe/test_hybrid_top_resume.py | 40 ++++++++----------- 2 files changed, 28 insertions(+), 35 deletions(-) diff --git a/openfe/protocols/openmm_rfe/hybridtop_units.py b/openfe/protocols/openmm_rfe/hybridtop_units.py index 6174fc1a1..744055692 100644 --- a/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -1069,13 +1069,13 @@ def _get_sampler( # running the right system. sampler_system = sampler._thermodynamic_states[0].get_system(remove_thermostat=True) if ( - (simulation_settings.n_replicas != sampler.n_states != sampler.n_replicas) or - (system.getNumForces() != sampler_system.getNumForces()) or - (system.getNumParticles() != sampler_system.getNumParticles()) or - (system.getNumConstraints() != sampler_system.getNumConstraints()) or - (sampler.mcmc_moves[0].n_steps != steps_per_iteration) or - (sampler.mcmc_moves[0].timestep != integrator.timestep) - ): + (simulation_settings.n_replicas != sampler.n_states != sampler.n_replicas) + or (system.getNumForces() != sampler_system.getNumForces()) + or (system.getNumParticles() != sampler_system.getNumParticles()) + or (system.getNumConstraints() != sampler_system.getNumConstraints()) + or (sampler.mcmc_moves[0].n_steps != steps_per_iteration) + or (sampler.mcmc_moves[0].timestep != integrator.timestep) + ): errmsg = "System in checkpoint does not match protocol system, cannot resume" raise ValueError(errmsg) else: @@ -1243,8 +1243,7 @@ def run( # Check for a restart self.restart = self._check_restart( - output_settings=settings["output_settings"], - shared_path=self.shared_basepath + output_settings=settings["output_settings"], shared_path=self.shared_basepath ) # Get the lambda schedule @@ -1308,7 +1307,7 @@ def run( try: # Order is reporter, sampler, integrator reporter.close() # close to prevent file handle clashes - + # clear GPU contexts # TODO: use cache.empty() calls when openmmtools #690 is resolved # replace with above @@ -1319,9 +1318,9 @@ def run( # 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.sampler_context_cache, sampler.energy_context_cache - + if not dry: del integrator, sampler except UnboundLocalError: diff --git a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 027bf15cc..f20740ecc 100644 --- a/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -1,45 +1,42 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe +import copy import os import pathlib import shutil -import copy import numpy as np +import openmm import pooch import pytest from gufe.protocols import execute_DAG from numpy.testing import assert_allclose +from openfe_analysis.utils.multistate import _determine_position_indices from openff.units import unit as offunit from openff.units.openmm import from_openmm -from openfe_analysis.utils.multistate import _determine_position_indices -import openmm +from openmmtools.multistate import MultiStateReporter import openfe from openfe.protocols import openmm_rfe +from openfe.protocols.openmm_rfe._rfe_utils.multistate import HybridRepexSampler from openfe.protocols.openmm_rfe.hybridtop_units import ( HybridTopologyMultiStateAnalysisUnit, HybridTopologyMultiStateSimulationUnit, HybridTopologySetupUnit, ) -from openmmtools.multistate import MultiStateReporter -from openfe.protocols.openmm_rfe._rfe_utils.multistate import HybridRepexSampler -from .test_hybrid_top_protocol import _get_units from ...conftest import HAS_INTERNET - +from .test_hybrid_top_protocol import _get_units POOCH_CACHE = pooch.os_cache("openfe") zenodo_resume_data = pooch.create( path=POOCH_CACHE, base_url="doi:10.5281/zenodo.18331259", - registry={ - "multistate_checkpoints.zip": "md5:2cf8aa417ac8311aca1551d4abf3b3ed" - }, + registry={"multistate_checkpoints.zip": "md5:2cf8aa417ac8311aca1551d4abf3b3ed"}, ) -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def trajectory_path(): zenodo_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" @@ -48,7 +45,7 @@ def trajectory_path(): return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def checkpoint_path(): zenodo_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" @@ -83,7 +80,7 @@ def test_check_restart(protocol_settings, trajectory_path): assert not openmm_rfe.HybridTopologyMultiStateSimulationUnit._check_restart( output_settings=protocol_settings.output_settings, - shared_path=pathlib.Path('.'), + shared_path=pathlib.Path("."), ) @@ -93,13 +90,13 @@ def test_check_restart(protocol_settings, trajectory_path): ) class TestCheckpointResuming: @pytest.fixture() - def protocol_dag(self, protocol_settings, benzene_system, toluene_system, benzene_to_toluene_mapping): + def protocol_dag( + self, protocol_settings, benzene_system, toluene_system, benzene_to_toluene_mapping + ): protocol = openmm_rfe.RelativeHybridTopologyProtocol(settings=protocol_settings) - + return protocol.create( - stateA=benzene_system, - stateB=toluene_system, - mapping=benzene_to_toluene_mapping + stateA=benzene_system, stateB=toluene_system, mapping=benzene_to_toluene_mapping ) @staticmethod @@ -118,9 +115,7 @@ def _get_positions(dataset): frame_list = _determine_position_indices(dataset) positions = [] for frame in frame_list: - positions.append( - copy.deepcopy(dataset.variables["positions"][frame].data) - ) + positions.append(copy.deepcopy(dataset.variables["positions"][frame].data)) return positions @staticmethod @@ -163,7 +158,7 @@ def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): # Dry run the setup since it'll be easier to use the objects directly setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) - + # Now we run the simulation in resume mode sim_results = simulation_unit.run( system=setup_results["hybrid_system"], @@ -233,4 +228,3 @@ def test_resume_fail(self, protocol_dag, trajectory_path, checkpoint_path, tmpdi scratch_basepath=cwd, shared_basepath=cwd, ) - From fda4a48d2353eb2599f3f0fff05178681a52b188 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Thu, 5 Feb 2026 20:31:53 +0000 Subject: [PATCH 78/91] Add some more file checking tests --- .../protocols/openmm_rfe/hybridtop_units.py | 12 +++ .../openmm_rfe/test_hybrid_top_resume.py | 88 +++++++++++++++++-- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index 744055692..64fe80395 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -832,10 +832,22 @@ def _check_restart(output_settings: SettingsBaseModel, shared_path: pathlib.Path For now this just checks if the netcdf files are present in the shared directory but in the future this may expand depending on how warehouse works. + + Raises + ------ + IOError + If either the checkpoint or trajectory files don't exist. """ trajectory = shared_path / output_settings.output_filename checkpoint = shared_path / output_settings.checkpoint_storage_filename + if trajectory.is_file() ^ checkpoint.is_file(): + errmsg = ( + "One of either the trajectory or checkpoint files are missing but " + "the other is not. This should not happen under normal circumstances." + ) + raise IOError(errmsg) + if trajectory.is_file() and checkpoint.is_file(): return True diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index f20740ecc..06157da82 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -119,9 +119,8 @@ def _get_positions(dataset): return positions @staticmethod - def _copy_simfiles(cwd: pathlib.Path, trajectory_path, checkpoint_path): - shutil.copyfile(trajectory_path, f"{cwd}/{trajectory_path.name}") - shutil.copyfile(checkpoint_path, f"{cwd}/{checkpoint_path.name}") + def _copy_simfiles(cwd: pathlib.Path, filepath): + shutil.copyfile(filepath, f"{cwd}/{filepath.name}") @pytest.mark.integration def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): @@ -131,7 +130,8 @@ def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): """ # define a temp directory path & copy files cwd = pathlib.Path(str(tmpdir)) - self._copy_simfiles(cwd, trajectory_path, checkpoint_path) + self._copy_simfiles(cwd, trajectory_path) + self._copy_simfiles(cwd, checkpoint_path) # 1. Check that the trajectory / checkpoint contain what we expect reporter = MultiStateReporter( @@ -209,7 +209,8 @@ def test_resume_fail(self, protocol_dag, trajectory_path, checkpoint_path, tmpdi """ # define a temp directory path & copy files cwd = pathlib.Path(str(tmpdir)) - self._copy_simfiles(cwd, trajectory_path, checkpoint_path) + self._copy_simfiles(cwd, trajectory_path) + self._copy_simfiles(cwd, checkpoint_path) pus = list(protocol_dag.protocol_units) setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] @@ -228,3 +229,80 @@ def test_resume_fail(self, protocol_dag, trajectory_path, checkpoint_path, tmpdi scratch_basepath=cwd, shared_basepath=cwd, ) + + @pytest.mark.parametrize("bad_file", ["trajectory", "checkpoint"]) + def test_resume_bad_files(self, protocol_dag, trajectory_path, checkpoint_path, bad_file, tmpdir): + """ + Test what happens when you have a bad trajectory and/or checkpoint + files. + """ + # define a temp directory path & copy files + cwd = pathlib.Path(str(tmpdir)) + + if bad_file == "trajectory": + with open(f"{cwd}/simulation.nc", "w") as f: + f.write("foo") + else: + self._copy_simfiles(cwd, trajectory_path) + + if bad_file == "checkpoint": + with open(f"{cwd}/checkpoint.chk", "w") as f: + f.write("bar") + else: + self._copy_simfiles(cwd, checkpoint_path) + + pus = list(protocol_dag.protocol_units) + setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] + simulation_unit = _get_units(pus, HybridTopologyMultiStateSimulationUnit)[0] + analysis_unit = _get_units(pus, HybridTopologyMultiStateAnalysisUnit)[0] + + # Dry run the setup since it'll be easier to use the objects directly + setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) + + with pytest.raises(OSError, match="Unknown file format"): + sim_results = simulation_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + scratch_basepath=cwd, + shared_basepath=cwd, + ) + + @pytest.mark.parametrize("missing_file", ["trajectory", "checkpoint"]) + def test_missing_file(self, protocol_dag, trajectory_path, checkpoint_path, missing_file, tmpdir): + """ + Test that an error is thrown if either file is missing but the other isn't. + """ + # define a temp directory path & copy files + cwd = pathlib.Path(str(tmpdir)) + + if missing_file == "trajectory": + pass + else: + self._copy_simfiles(cwd, trajectory_path) + + if missing_file == "checkpoint": + pass + else: + self._copy_simfiles(cwd, checkpoint_path) + + pus = list(protocol_dag.protocol_units) + setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] + simulation_unit = _get_units(pus, HybridTopologyMultiStateSimulationUnit)[0] + analysis_unit = _get_units(pus, HybridTopologyMultiStateAnalysisUnit)[0] + + # Dry run the setup since it'll be easier to use the objects directly + setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) + + errmsg = "One of either the trajectory or checkpoint files are missing" + with pytest.raises(IOError, match=errmsg): + sim_results = simulation_unit.run( + system=setup_results["hybrid_system"], + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + scratch_basepath=cwd, + shared_basepath=cwd, + ) + + + From 35b927a1a1a2707f90fa50ebd2a9db7274168247 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:05:13 +0000 Subject: [PATCH 79/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../protocols/openmm_rfe/test_hybrid_top_resume.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 06157da82..4e3ea1834 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -231,7 +231,9 @@ def test_resume_fail(self, protocol_dag, trajectory_path, checkpoint_path, tmpdi ) @pytest.mark.parametrize("bad_file", ["trajectory", "checkpoint"]) - def test_resume_bad_files(self, protocol_dag, trajectory_path, checkpoint_path, bad_file, tmpdir): + def test_resume_bad_files( + self, protocol_dag, trajectory_path, checkpoint_path, bad_file, tmpdir + ): """ Test what happens when you have a bad trajectory and/or checkpoint files. @@ -269,7 +271,9 @@ def test_resume_bad_files(self, protocol_dag, trajectory_path, checkpoint_path, ) @pytest.mark.parametrize("missing_file", ["trajectory", "checkpoint"]) - def test_missing_file(self, protocol_dag, trajectory_path, checkpoint_path, missing_file, tmpdir): + def test_missing_file( + self, protocol_dag, trajectory_path, checkpoint_path, missing_file, tmpdir + ): """ Test that an error is thrown if either file is missing but the other isn't. """ @@ -303,6 +307,3 @@ def test_missing_file(self, protocol_dag, trajectory_path, checkpoint_path, miss scratch_basepath=cwd, shared_basepath=cwd, ) - - - From 97ce8c8c5a234c1a0349f1856d00cc02ffae5d43 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sat, 7 Feb 2026 23:54:09 +0000 Subject: [PATCH 80/91] Implement more complicate system equality checks --- .../protocols/openmm_rfe/hybridtop_units.py | 117 ++++++++++++++++-- 1 file changed, 110 insertions(+), 7 deletions(-) diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index 64fe80395..660ca04e6 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -809,6 +809,108 @@ def _execute( } +def _assert_system_equality( + ref_system: openmm.System, + stored_system: openmm.System, +): + """ + Verify the equality of a MultiStateReporter + stored system, with that of a pre-exisiting + standard system. + + + Raises + ------ + ValueError + * If the particles in the two System don't match. + * If the constraints in the two System don't match. + * If the forces in the two systems don't match. + """ + # Assert particle equality + def _get_masses(system): + return [ + system.getParticleMass(i).value_in_unit(openmm.unit.dalton) + for i in range(system.getNumParticles()) + ] + + if not np.allclose(_get_masses(ref_system), _get_masses(stored_system)): + errmsg = ( + "Stored checkpoint System particles do not match those of the simulated System" + ) + raise ValueError(errmsg) + + # Assert constraint equality + def _get_constraints(system): + constraints = [] + for index in range(system.getNumConstraints()): + i, j, d = system.getConstraintParameters(index) + constraints.append([i, j, d.value_in_unit(openmm.unit.nanometer)]) + + return constraints + + if not np.allclose( + _get_constraints(ref_system), _get_constraints(stored_system) + ): + errmsg = ( + "Stored checkpoint System constraints do not match those " + "of the simulation System" + ) + raise ValueError(errmsg) + + # Assert force equality + # Notes: + # * Store forces are in different order + # * The barostat doesn't exactly match because seeds have changed + + # Create dictionaries of forces keyed by their hash + # Note: we can't rely on names because they may clash + ref_force_dict = { + hash(openmm.XmlSerializer.serialize(f)): f + for f in ref_system.getForces() + } + stored_force_dict = { + hash(openmm.XmlSerializer.serialize(f)): f + for f in stored_system.getForces() + } + + # Assert the number of forces is equal + if len(ref_force_dict) != len(stored_force_dict): + errmsg = ( + "Number of forces stored in checkpoint System does not match " + "simulation System" + ) + raise ValueError(errmsg) + + # Loop through forces and check for equality + for sfhash, sforce in stored_force_dict.items(): + errmsg = ( + f"Force {sforce.getName()} in the stored checkpoint System " + "does not match the same force in the simulated System" + ) + + # Barostat case - seed changed so we need to check manually + barostats = [openmm.MonteCarloBarostat, openmm.MonteCarloMembraneBarostat] + + if any(isinstance(sforce, forcetype) for forcetype in barostats): + # Find the equivalent force in the reference + rforce = [ + f for f in ref_force_dict.values() + if any(isinstance(f, forcetype)for forcetype in barostats) + ][0] + + if ( + (sforce.getFrequency() != rforce.getFrequency()) or + (sforce.getForceGroup() != rforce.getForceGroup()) or + (sforce.getDefaultPressure() != rforce.getDefaultPressure()) or + (sforce.getDefaultTemperature() != rforce.getDefaultTemperature()) + ): + raise ValueError(errmsg) + + else: + if sfhash not in ref_force_dict: + raise ValueError(errmsg) + + class HybridTopologyMultiStateSimulationUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): """ Multi-state simulation (e.g. multi replica methods like hamiltonian @@ -1077,19 +1179,20 @@ def _get_sampler( if restart: sampler = _SAMPLERS[sampler_method].from_storage(reporter) # type: ignore[attr-defined] - # We also do some lightweight checks to make sure we are - # running the right system. - sampler_system = sampler._thermodynamic_states[0].get_system(remove_thermostat=True) + # We do some checks to make sure we are running the same system + _assert_system_equality( + ref_system=system, + stored_system=sampler._thermodynamic_states[0].get_system(remove_thermostat=True) + ) + if ( (simulation_settings.n_replicas != sampler.n_states != sampler.n_replicas) - or (system.getNumForces() != sampler_system.getNumForces()) - or (system.getNumParticles() != sampler_system.getNumParticles()) - or (system.getNumConstraints() != sampler_system.getNumConstraints()) or (sampler.mcmc_moves[0].n_steps != steps_per_iteration) or (sampler.mcmc_moves[0].timestep != integrator.timestep) ): - errmsg = "System in checkpoint does not match protocol system, cannot resume" + errmsg = "Sampler in checkpoint does not match Protocol settings, cannot resume." raise ValueError(errmsg) + else: sampler = _SAMPLERS[sampler_method](**sampler_kwargs) From ce73442d672aa566f2354cd1d2043cf35584846b Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 8 Feb 2026 00:52:05 +0000 Subject: [PATCH 81/91] Add some version checks --- .../protocols/openmm_rfe/hybridtop_units.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index 660ca04e6..1d2ff731e 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -18,6 +18,7 @@ from typing import Any import gufe +from gufe.protocols.errors import ProtocolUnitExecutionError import matplotlib.pyplot as plt import mdtraj import numpy as np @@ -143,6 +144,22 @@ def _get_settings( protocol_settings["engine_settings"] = settings.engine_settings return protocol_settings + @staticmethod + def _verify_execution_environment( + setup_outputs: dict[Any], + ) -> None: + """ + Check that the Python environment hasn't changed based on the + relevant Python library versions stored in the setup outputs. + """ + if ( + (gufe.__version__ != setup_outputs["gufe_version"]) or + (openfe.__version__ != setup_outputs["openfe_version"]) or + (openmm.__version__ != setup_outputs["openmm_version"]) + ): + errmsg = "Python environment has changed, cannot continue Protocol execution." + raise ProtocolUnitExecutionError(errmsg) + class HybridTopologySetupUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): """ @@ -781,6 +798,9 @@ def run( "positions": positions_outfile, "pdb_structure": self.shared_basepath / settings["output_settings"].output_structure, "selection_indices": selection_indices, + "openmm_version": openmm.__version__, + "openfe_version": openfe.__version__, + "gufe_version": gufe.__version__, } if dry: @@ -1461,7 +1481,10 @@ def _execute( **inputs, ) -> dict[str, Any]: log_system_probe(logging.INFO, paths=[ctx.scratch]) - # Get the relevant inputs + # Ensure that we the environment hasn't changed + self._verify_execution_environment(setup_results.outputs) + + # Get the relevant inputs for running the unit system = deserialize(setup_results.outputs["system"]) positions = to_openmm(np.load(setup_results.outputs["positions"]) * offunit.nm) selection_indices = setup_results.outputs["selection_indices"] @@ -1689,6 +1712,9 @@ def _execute( ) -> dict[str, Any]: log_system_probe(logging.INFO, paths=[ctx.scratch]) + # Ensure that we the environment hasn't changed + self._verify_execution_environment(setup_results.outputs) + pdb_file = setup_results.outputs["pdb_structure"] selection_indices = setup_results.outputs["selection_indices"] trajectory = simulation_results.outputs["nc"] From f8070e919df15b31a4d6fe8abb2fc3fee831d485 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 8 Feb 2026 01:21:36 +0000 Subject: [PATCH 82/91] some test fixes --- .../protocols/openmm_rfe/hybridtop_units.py | 1 + .../openmm_rfe/test_hybrid_top_protocol.py | 113 +++++++----------- 2 files changed, 46 insertions(+), 68 deletions(-) diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index 1d2ff731e..67fa55621 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -44,6 +44,7 @@ from openmmforcefields.generators import SystemGenerator from openmmtools import multistate +import openfe from openfe.protocols.openmm_utils.omm_settings import ( BasePartialChargeSettings, ) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index bd7a1f72f..baf220de9 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -19,6 +19,7 @@ from openff.toolkit import Molecule from openff.units import unit from openff.units.openmm import ensure_quantity, from_openmm, to_openmm +import openmm from openmm import CustomNonbondedForce, MonteCarloBarostat, NonbondedForce, XmlSerializer, app from openmm import unit as omm_unit from openmmforcefields.generators import SMIRNOFFTemplateGenerator @@ -1154,9 +1155,8 @@ def solvent_protocol_dag(benzene_system, toluene_system, benzene_to_toluene_mapp ) -def test_unit_tagging(solvent_protocol_dag, tmpdir): - # test that executing the Units includes correct generation and repeat info - dag_units = solvent_protocol_dag.protocol_units +@pytest.fixture() +def unit_mock_patcher(): with ( mock.patch( "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologySetupUnit.run", @@ -1165,6 +1165,9 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): "positions": Path("positions.npy"), "pdb_structure": Path("hybrid_system.pdb"), "selection_indices": np.zeros(100), + "gufe_version": gufe.__version__, + "openfe_version": openfe.__version__, + "openmm_version": openmm.__version__, }, ), mock.patch( @@ -1191,31 +1194,39 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): }, ), ): - setup_results = {} - sim_results = {} - analysis_results = {} - - setup_units = _get_units(dag_units, HybridTopologySetupUnit) - sim_units = _get_units(dag_units, HybridTopologyMultiStateSimulationUnit) - analysis_units = _get_units(dag_units, HybridTopologyMultiStateAnalysisUnit) - - for u in setup_units: - rid = u.inputs["repeat_id"] - setup_results[rid] = u.execute(context=gufe.Context(tmpdir, tmpdir)) - - for u in sim_units: - rid = u.inputs["repeat_id"] - sim_results[rid] = u.execute( - context=gufe.Context(tmpdir, tmpdir), setup_results=setup_results[rid] - ) + yield + + +def test_unit_tagging(solvent_protocol_dag, unit_mock_patcher, tmpdir): + # test that executing the Units includes correct generation and repeat info + dag_units = solvent_protocol_dag.protocol_units + + setup_results = {} + sim_results = {} + analysis_results = {} + + setup_units = _get_units(dag_units, HybridTopologySetupUnit) + sim_units = _get_units(dag_units, HybridTopologyMultiStateSimulationUnit) + analysis_units = _get_units(dag_units, HybridTopologyMultiStateAnalysisUnit) + + for u in setup_units: + rid = u.inputs["repeat_id"] + setup_results[rid] = u.execute(context=gufe.Context(tmpdir, tmpdir)) + + for u in sim_units: + rid = u.inputs["repeat_id"] + sim_results[rid] = u.execute( + context=gufe.Context(tmpdir, tmpdir), setup_results=setup_results[rid] + ) + + for u in analysis_units: + rid = u.inputs["repeat_id"] + analysis_results[rid] = u.execute( + context=gufe.Context(tmpdir, tmpdir), + setup_results=setup_results[rid], + simulation_results=sim_results[rid], + ) - for u in analysis_units: - rid = u.inputs["repeat_id"] - analysis_results[rid] = u.execute( - context=gufe.Context(tmpdir, tmpdir), - setup_results=setup_results[rid], - simulation_results=sim_results[rid], - ) for results in [setup_results, sim_results, analysis_results]: for ret in results.values(): assert isinstance(ret, gufe.ProtocolUnitResult) @@ -1225,48 +1236,14 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): assert len(setup_results) == len(sim_results) == len(analysis_results) == 3 -def test_gather(solvent_protocol_dag, tmpdir): +def test_gather(solvent_protocol_dag, unit_mock_patcher, tmpdir): # check .gather behaves as expected - with ( - mock.patch( - "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologySetupUnit.run", - return_value={ - "system": Path("system.xml.bz2"), - "positions": Path("positions.npy"), - "pdb_structure": Path("hybrid_system.pdb"), - "selection_indices": np.zeros(100), - }, - ), - mock.patch( - "openfe.protocols.openmm_rfe.hybridtop_units.np.load", - return_value=np.zeros(100), - ), - mock.patch( - "openfe.protocols.openmm_rfe.hybridtop_units.deserialize", - return_value={ - "item": "foo", - }, - ), - mock.patch( - "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateSimulationUnit.run", - return_value={ - "nc": Path("file.nc"), - "checkpoint": Path("chk.chk"), - }, - ), - mock.patch( - "openfe.protocols.openmm_rfe.hybridtop_units.HybridTopologyMultiStateAnalysisUnit.run", - return_value={ - "foo": "bar", - }, - ), - ): - dagres = gufe.protocols.execute_DAG( - solvent_protocol_dag, - shared_basedir=tmpdir, - scratch_basedir=tmpdir, - keep_shared=True, - ) + dagres = gufe.protocols.execute_DAG( + solvent_protocol_dag, + shared_basedir=tmpdir, + scratch_basedir=tmpdir, + keep_shared=True, + ) prot = openmm_rfe.RelativeHybridTopologyProtocol( settings=openmm_rfe.RelativeHybridTopologyProtocol.default_settings() From b84297df2323fe95e4f289338d70752bbf1ab9b6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 01:23:01 +0000 Subject: [PATCH 83/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../protocols/openmm_rfe/hybridtop_units.py | 60 ++++++++----------- .../openmm_rfe/test_hybrid_top_protocol.py | 4 +- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index 67fa55621..13ded8ae4 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -18,7 +18,6 @@ from typing import Any import gufe -from gufe.protocols.errors import ProtocolUnitExecutionError import matplotlib.pyplot as plt import mdtraj import numpy as np @@ -33,6 +32,7 @@ SmallMoleculeComponent, SolventComponent, ) +from gufe.protocols.errors import ProtocolUnitExecutionError from gufe.settings import ( SettingsBaseModel, ThermoSettings, @@ -154,9 +154,9 @@ def _verify_execution_environment( relevant Python library versions stored in the setup outputs. """ if ( - (gufe.__version__ != setup_outputs["gufe_version"]) or - (openfe.__version__ != setup_outputs["openfe_version"]) or - (openmm.__version__ != setup_outputs["openmm_version"]) + (gufe.__version__ != setup_outputs["gufe_version"]) + or (openfe.__version__ != setup_outputs["openfe_version"]) + or (openmm.__version__ != setup_outputs["openmm_version"]) ): errmsg = "Python environment has changed, cannot continue Protocol execution." raise ProtocolUnitExecutionError(errmsg) @@ -847,37 +847,31 @@ def _assert_system_equality( * If the constraints in the two System don't match. * If the forces in the two systems don't match. """ + # Assert particle equality def _get_masses(system): return [ system.getParticleMass(i).value_in_unit(openmm.unit.dalton) for i in range(system.getNumParticles()) ] - + if not np.allclose(_get_masses(ref_system), _get_masses(stored_system)): - errmsg = ( - "Stored checkpoint System particles do not match those of the simulated System" - ) + errmsg = "Stored checkpoint System particles do not match those of the simulated System" raise ValueError(errmsg) - + # Assert constraint equality def _get_constraints(system): constraints = [] for index in range(system.getNumConstraints()): i, j, d = system.getConstraintParameters(index) constraints.append([i, j, d.value_in_unit(openmm.unit.nanometer)]) - + return constraints - - if not np.allclose( - _get_constraints(ref_system), _get_constraints(stored_system) - ): - errmsg = ( - "Stored checkpoint System constraints do not match those " - "of the simulation System" - ) + + if not np.allclose(_get_constraints(ref_system), _get_constraints(stored_system)): + errmsg = "Stored checkpoint System constraints do not match those of the simulation System" raise ValueError(errmsg) - + # Assert force equality # Notes: # * Store forces are in different order @@ -885,21 +879,14 @@ def _get_constraints(system): # Create dictionaries of forces keyed by their hash # Note: we can't rely on names because they may clash - ref_force_dict = { - hash(openmm.XmlSerializer.serialize(f)): f - for f in ref_system.getForces() - } + ref_force_dict = {hash(openmm.XmlSerializer.serialize(f)): f for f in ref_system.getForces()} stored_force_dict = { - hash(openmm.XmlSerializer.serialize(f)): f - for f in stored_system.getForces() + hash(openmm.XmlSerializer.serialize(f)): f for f in stored_system.getForces() } # Assert the number of forces is equal if len(ref_force_dict) != len(stored_force_dict): - errmsg = ( - "Number of forces stored in checkpoint System does not match " - "simulation System" - ) + errmsg = "Number of forces stored in checkpoint System does not match simulation System" raise ValueError(errmsg) # Loop through forces and check for equality @@ -915,15 +902,16 @@ def _get_constraints(system): if any(isinstance(sforce, forcetype) for forcetype in barostats): # Find the equivalent force in the reference rforce = [ - f for f in ref_force_dict.values() - if any(isinstance(f, forcetype)for forcetype in barostats) + f + for f in ref_force_dict.values() + if any(isinstance(f, forcetype) for forcetype in barostats) ][0] if ( - (sforce.getFrequency() != rforce.getFrequency()) or - (sforce.getForceGroup() != rforce.getForceGroup()) or - (sforce.getDefaultPressure() != rforce.getDefaultPressure()) or - (sforce.getDefaultTemperature() != rforce.getDefaultTemperature()) + (sforce.getFrequency() != rforce.getFrequency()) + or (sforce.getForceGroup() != rforce.getForceGroup()) + or (sforce.getDefaultPressure() != rforce.getDefaultPressure()) + or (sforce.getDefaultTemperature() != rforce.getDefaultTemperature()) ): raise ValueError(errmsg) @@ -1203,7 +1191,7 @@ def _get_sampler( # We do some checks to make sure we are running the same system _assert_system_equality( ref_system=system, - stored_system=sampler._thermodynamic_states[0].get_system(remove_thermostat=True) + stored_system=sampler._thermodynamic_states[0].get_system(remove_thermostat=True), ) if ( diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index baf220de9..5811ea973 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -12,6 +12,7 @@ import gufe import mdtraj as mdt import numpy as np +import openmm import pytest from kartograf import KartografAtomMapper from kartograf.atom_aligner import align_mol_shape @@ -19,7 +20,6 @@ from openff.toolkit import Molecule from openff.units import unit from openff.units.openmm import ensure_quantity, from_openmm, to_openmm -import openmm from openmm import CustomNonbondedForce, MonteCarloBarostat, NonbondedForce, XmlSerializer, app from openmm import unit as omm_unit from openmmforcefields.generators import SMIRNOFFTemplateGenerator @@ -1200,7 +1200,7 @@ def unit_mock_patcher(): def test_unit_tagging(solvent_protocol_dag, unit_mock_patcher, tmpdir): # test that executing the Units includes correct generation and repeat info dag_units = solvent_protocol_dag.protocol_units - + setup_results = {} sim_results = {} analysis_results = {} From 1af55c8cca55dcd83a5b180627d9883f49e3b2cf Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 15 Feb 2026 20:43:01 +0000 Subject: [PATCH 84/91] Make mypy happy --- src/openfe/protocols/openmm_rfe/hybridtop_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index 13ded8ae4..4c46938d8 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -147,7 +147,7 @@ def _get_settings( @staticmethod def _verify_execution_environment( - setup_outputs: dict[Any], + setup_outputs: dict[str. Any], ) -> None: """ Check that the Python environment hasn't changed based on the From a25700e47b4a3cb3dda6fb00197943a2f82ab46d Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 15 Feb 2026 20:45:00 +0000 Subject: [PATCH 85/91] update md5 hash --- src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 4e3ea1834..105f6336b 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -32,7 +32,7 @@ zenodo_resume_data = pooch.create( path=POOCH_CACHE, base_url="doi:10.5281/zenodo.18331259", - registry={"multistate_checkpoints.zip": "md5:2cf8aa417ac8311aca1551d4abf3b3ed"}, + registry={"multistate_checkpoints.zip": "md5:6addeabbfa37fd5f9114e3b043bfa568"}, ) From 90cce0346ac79de1a8969212bde4ff50fcd65de8 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 15 Feb 2026 21:25:23 +0000 Subject: [PATCH 86/91] various test file fixes --- src/openfe/data/_registry.py | 7 +++ .../protocols/openmm_rfe/hybridtop_units.py | 2 +- src/openfe/tests/conftest.py | 1 - src/openfe/tests/protocols/conftest.py | 26 +++++++++ .../openmm_rfe/test_hybrid_top_resume.py | 54 ++++++------------- .../restraints/test_geometry_boresch.py | 3 +- .../restraints/test_geometry_boresch_host.py | 1 - .../restraints/test_geometry_utils.py | 2 +- 8 files changed, 51 insertions(+), 45 deletions(-) diff --git a/src/openfe/data/_registry.py b/src/openfe/data/_registry.py index 7a87814dd..80a2aade6 100644 --- a/src/openfe/data/_registry.py +++ b/src/openfe/data/_registry.py @@ -17,8 +17,15 @@ fname="industry_benchmark_systems.zip", known_hash="sha256:2bb5eee36e29b718b96bf6e9350e0b9957a592f6c289f77330cbb6f4311a07bd", ) +zenodo_resume_data = dict( + base_url="doi:10.5281/zenodo.18331259", + fname="multistate_checkpoints.zip", + known_hash="md5:6addeabbfa37fd5f9114e3b043bfa568", +) + zenodo_data_registry = [ zenodo_rfe_simulation_nc, zenodo_t4_lysozyme_traj, zenodo_industry_benchmark_systems, + zenodo_resume_data, ] diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index 4c46938d8..d67af3849 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -147,7 +147,7 @@ def _get_settings( @staticmethod def _verify_execution_environment( - setup_outputs: dict[str. Any], + setup_outputs: dict[str, Any], ) -> None: """ Check that the Python environment hasn't changed based on the diff --git a/src/openfe/tests/conftest.py b/src/openfe/tests/conftest.py index deaad59e3..e3c5c84d4 100644 --- a/src/openfe/tests/conftest.py +++ b/src/openfe/tests/conftest.py @@ -12,7 +12,6 @@ import numpy as np import openmm import pandas as pd -import pooch import pytest from gufe import AtomMapper, LigandAtomMapping, ProteinComponent, SmallMoleculeComponent from openff.toolkit import ForceField diff --git a/src/openfe/tests/protocols/conftest.py b/src/openfe/tests/protocols/conftest.py index b5f302947..cc55455de 100644 --- a/src/openfe/tests/protocols/conftest.py +++ b/src/openfe/tests/protocols/conftest.py @@ -22,6 +22,7 @@ zenodo_industry_benchmark_systems, zenodo_rfe_simulation_nc, zenodo_t4_lysozyme_traj, + zenodo_resume_data, ) @@ -334,6 +335,31 @@ def simulation_nc(): ) +pooch_resume_data = pooch.create( + path=POOCH_CACHE, + base_url=zenodo_resume_data["base_url"], + registry={zenodo_resume_data["fname"]: zenodo_resume_data["known_hash"]}, +) + + +@pytest.fixture(scope="session") +def htop_trajectory_path(): + pooch_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) + topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" + subdir = "hybrid_top" + filename = "simulation.nc" + return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") + + +@pytest.fixture(scope="session") +def htop_checkpoint_path(): + pooch_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) + topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" + subdir = "hybrid_top" + filename = "checkpoint.chk" + return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") + + @pytest.fixture def get_available_openmm_platforms() -> set[str]: """ diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 105f6336b..3d4186b24 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -24,35 +24,11 @@ HybridTopologyMultiStateSimulationUnit, HybridTopologySetupUnit, ) +from openfe.data._registry import POOCH_CACHE from ...conftest import HAS_INTERNET from .test_hybrid_top_protocol import _get_units -POOCH_CACHE = pooch.os_cache("openfe") -zenodo_resume_data = pooch.create( - path=POOCH_CACHE, - base_url="doi:10.5281/zenodo.18331259", - registry={"multistate_checkpoints.zip": "md5:6addeabbfa37fd5f9114e3b043bfa568"}, -) - - -@pytest.fixture(scope="module") -def trajectory_path(): - zenodo_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) - topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" - subdir = "hybrid_top" - filename = "simulation.nc" - return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") - - -@pytest.fixture(scope="module") -def checkpoint_path(): - zenodo_resume_data.fetch("multistate_checkpoints.zip", processor=pooch.Unzip()) - topdir = "multistate_checkpoints.zip.unzip/multistate_checkpoints" - subdir = "hybrid_top" - filename = "checkpoint.chk" - return pathlib.Path(pooch.os_cache("openfe") / f"{topdir}/{subdir}/{filename}") - @pytest.fixture() def protocol_settings(): @@ -72,10 +48,10 @@ def protocol_settings(): not os.path.exists(POOCH_CACHE) and not HAS_INTERNET, reason="Internet unavailable and test data is not cached locally", ) -def test_check_restart(protocol_settings, trajectory_path): +def test_check_restart(protocol_settings, htop_trajectory_path): assert openmm_rfe.HybridTopologyMultiStateSimulationUnit._check_restart( output_settings=protocol_settings.output_settings, - shared_path=trajectory_path.parent, + shared_path=htop_trajectory_path.parent, ) assert not openmm_rfe.HybridTopologyMultiStateSimulationUnit._check_restart( @@ -123,15 +99,15 @@ def _copy_simfiles(cwd: pathlib.Path, filepath): shutil.copyfile(filepath, f"{cwd}/{filepath.name}") @pytest.mark.integration - def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): + def test_resume(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): """ Attempt to resume a simulation unit with pre-existing checkpoint & trajectory files. """ # define a temp directory path & copy files cwd = pathlib.Path(str(tmpdir)) - self._copy_simfiles(cwd, trajectory_path) - self._copy_simfiles(cwd, checkpoint_path) + self._copy_simfiles(cwd, htop_trajectory_path) + self._copy_simfiles(cwd, htop_checkpoint_path) # 1. Check that the trajectory / checkpoint contain what we expect reporter = MultiStateReporter( @@ -202,15 +178,15 @@ def test_resume(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): reporter.close() del sampler - def test_resume_fail(self, protocol_dag, trajectory_path, checkpoint_path, tmpdir): + def test_resume_fail(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): """ Test that the run unit will fail with a system incompatible to the one present in the trajectory/checkpoint files. """ # define a temp directory path & copy files cwd = pathlib.Path(str(tmpdir)) - self._copy_simfiles(cwd, trajectory_path) - self._copy_simfiles(cwd, checkpoint_path) + self._copy_simfiles(cwd, htop_trajectory_path) + self._copy_simfiles(cwd, htop_checkpoint_path) pus = list(protocol_dag.protocol_units) setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] @@ -232,7 +208,7 @@ def test_resume_fail(self, protocol_dag, trajectory_path, checkpoint_path, tmpdi @pytest.mark.parametrize("bad_file", ["trajectory", "checkpoint"]) def test_resume_bad_files( - self, protocol_dag, trajectory_path, checkpoint_path, bad_file, tmpdir + self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, bad_file, tmpdir ): """ Test what happens when you have a bad trajectory and/or checkpoint @@ -245,13 +221,13 @@ def test_resume_bad_files( with open(f"{cwd}/simulation.nc", "w") as f: f.write("foo") else: - self._copy_simfiles(cwd, trajectory_path) + self._copy_simfiles(cwd, htop_trajectory_path) if bad_file == "checkpoint": with open(f"{cwd}/checkpoint.chk", "w") as f: f.write("bar") else: - self._copy_simfiles(cwd, checkpoint_path) + self._copy_simfiles(cwd, htop_checkpoint_path) pus = list(protocol_dag.protocol_units) setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] @@ -272,7 +248,7 @@ def test_resume_bad_files( @pytest.mark.parametrize("missing_file", ["trajectory", "checkpoint"]) def test_missing_file( - self, protocol_dag, trajectory_path, checkpoint_path, missing_file, tmpdir + self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, missing_file, tmpdir ): """ Test that an error is thrown if either file is missing but the other isn't. @@ -283,12 +259,12 @@ def test_missing_file( if missing_file == "trajectory": pass else: - self._copy_simfiles(cwd, trajectory_path) + self._copy_simfiles(cwd, htop_trajectory_path) if missing_file == "checkpoint": pass else: - self._copy_simfiles(cwd, checkpoint_path) + self._copy_simfiles(cwd, htop_checkpoint_path) pus = list(protocol_dag.protocol_units) setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] diff --git a/src/openfe/tests/protocols/restraints/test_geometry_boresch.py b/src/openfe/tests/protocols/restraints/test_geometry_boresch.py index 5231deef5..d650333b6 100644 --- a/src/openfe/tests/protocols/restraints/test_geometry_boresch.py +++ b/src/openfe/tests/protocols/restraints/test_geometry_boresch.py @@ -4,7 +4,6 @@ import pathlib import MDAnalysis as mda -import pooch import pytest from openff.units import unit from rdkit import Chem @@ -15,7 +14,7 @@ find_boresch_restraint, ) -from ...conftest import HAS_INTERNET, POOCH_CACHE +from ...conftest import HAS_INTERNET @pytest.fixture() diff --git a/src/openfe/tests/protocols/restraints/test_geometry_boresch_host.py b/src/openfe/tests/protocols/restraints/test_geometry_boresch_host.py index 5f89cf5aa..e5437909f 100644 --- a/src/openfe/tests/protocols/restraints/test_geometry_boresch_host.py +++ b/src/openfe/tests/protocols/restraints/test_geometry_boresch_host.py @@ -5,7 +5,6 @@ import MDAnalysis as mda import numpy as np -import pooch import pytest from numpy.testing import assert_equal from openff.units import unit diff --git a/src/openfe/tests/protocols/restraints/test_geometry_utils.py b/src/openfe/tests/protocols/restraints/test_geometry_utils.py index 077c32c24..454147cc7 100644 --- a/src/openfe/tests/protocols/restraints/test_geometry_utils.py +++ b/src/openfe/tests/protocols/restraints/test_geometry_utils.py @@ -31,7 +31,7 @@ stable_secondary_structure_selection, ) -from ...conftest import HAS_INTERNET, POOCH_CACHE +from ...conftest import HAS_INTERNET @pytest.fixture(scope="module") From 95d406c09627268e5326dfea9c130f57ab56ed16 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:25:55 +0000 Subject: [PATCH 87/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/openfe/tests/protocols/conftest.py | 2 +- src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openfe/tests/protocols/conftest.py b/src/openfe/tests/protocols/conftest.py index cc55455de..6978148a4 100644 --- a/src/openfe/tests/protocols/conftest.py +++ b/src/openfe/tests/protocols/conftest.py @@ -20,9 +20,9 @@ from openfe.data._registry import ( POOCH_CACHE, zenodo_industry_benchmark_systems, + zenodo_resume_data, zenodo_rfe_simulation_nc, zenodo_t4_lysozyme_traj, - zenodo_resume_data, ) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 3d4186b24..15f5153a7 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -17,6 +17,7 @@ from openmmtools.multistate import MultiStateReporter import openfe +from openfe.data._registry import POOCH_CACHE from openfe.protocols import openmm_rfe from openfe.protocols.openmm_rfe._rfe_utils.multistate import HybridRepexSampler from openfe.protocols.openmm_rfe.hybridtop_units import ( @@ -24,7 +25,6 @@ HybridTopologyMultiStateSimulationUnit, HybridTopologySetupUnit, ) -from openfe.data._registry import POOCH_CACHE from ...conftest import HAS_INTERNET from .test_hybrid_top_protocol import _get_units From 1ff87a2801c9472c1e5435f10f58fe9eeb52877e Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 15 Feb 2026 22:35:14 +0000 Subject: [PATCH 88/91] some tests and moving functionality over to system_validation --- .../protocols/openmm_rfe/hybridtop_units.py | 92 +--------------- .../openmm_utils/system_validation.py | 103 ++++++++++++++++++ .../openmm_rfe/test_hybrid_top_resume.py | 28 ++++- 3 files changed, 131 insertions(+), 92 deletions(-) diff --git a/src/openfe/protocols/openmm_rfe/hybridtop_units.py b/src/openfe/protocols/openmm_rfe/hybridtop_units.py index d67af3849..346829cc6 100644 --- a/src/openfe/protocols/openmm_rfe/hybridtop_units.py +++ b/src/openfe/protocols/openmm_rfe/hybridtop_units.py @@ -830,96 +830,6 @@ def _execute( } -def _assert_system_equality( - ref_system: openmm.System, - stored_system: openmm.System, -): - """ - Verify the equality of a MultiStateReporter - stored system, with that of a pre-exisiting - standard system. - - - Raises - ------ - ValueError - * If the particles in the two System don't match. - * If the constraints in the two System don't match. - * If the forces in the two systems don't match. - """ - - # Assert particle equality - def _get_masses(system): - return [ - system.getParticleMass(i).value_in_unit(openmm.unit.dalton) - for i in range(system.getNumParticles()) - ] - - if not np.allclose(_get_masses(ref_system), _get_masses(stored_system)): - errmsg = "Stored checkpoint System particles do not match those of the simulated System" - raise ValueError(errmsg) - - # Assert constraint equality - def _get_constraints(system): - constraints = [] - for index in range(system.getNumConstraints()): - i, j, d = system.getConstraintParameters(index) - constraints.append([i, j, d.value_in_unit(openmm.unit.nanometer)]) - - return constraints - - if not np.allclose(_get_constraints(ref_system), _get_constraints(stored_system)): - errmsg = "Stored checkpoint System constraints do not match those of the simulation System" - raise ValueError(errmsg) - - # Assert force equality - # Notes: - # * Store forces are in different order - # * The barostat doesn't exactly match because seeds have changed - - # Create dictionaries of forces keyed by their hash - # Note: we can't rely on names because they may clash - ref_force_dict = {hash(openmm.XmlSerializer.serialize(f)): f for f in ref_system.getForces()} - stored_force_dict = { - hash(openmm.XmlSerializer.serialize(f)): f for f in stored_system.getForces() - } - - # Assert the number of forces is equal - if len(ref_force_dict) != len(stored_force_dict): - errmsg = "Number of forces stored in checkpoint System does not match simulation System" - raise ValueError(errmsg) - - # Loop through forces and check for equality - for sfhash, sforce in stored_force_dict.items(): - errmsg = ( - f"Force {sforce.getName()} in the stored checkpoint System " - "does not match the same force in the simulated System" - ) - - # Barostat case - seed changed so we need to check manually - barostats = [openmm.MonteCarloBarostat, openmm.MonteCarloMembraneBarostat] - - if any(isinstance(sforce, forcetype) for forcetype in barostats): - # Find the equivalent force in the reference - rforce = [ - f - for f in ref_force_dict.values() - if any(isinstance(f, forcetype) for forcetype in barostats) - ][0] - - if ( - (sforce.getFrequency() != rforce.getFrequency()) - or (sforce.getForceGroup() != rforce.getForceGroup()) - or (sforce.getDefaultPressure() != rforce.getDefaultPressure()) - or (sforce.getDefaultTemperature() != rforce.getDefaultTemperature()) - ): - raise ValueError(errmsg) - - else: - if sfhash not in ref_force_dict: - raise ValueError(errmsg) - - class HybridTopologyMultiStateSimulationUnit(gufe.ProtocolUnit, HybridTopologyUnitMixin): """ Multi-state simulation (e.g. multi replica methods like hamiltonian @@ -1189,7 +1099,7 @@ def _get_sampler( sampler = _SAMPLERS[sampler_method].from_storage(reporter) # type: ignore[attr-defined] # We do some checks to make sure we are running the same system - _assert_system_equality( + system_validation.assert_multistate_system_equality( ref_system=system, stored_system=sampler._thermodynamic_states[0].get_system(remove_thermostat=True), ) diff --git a/src/openfe/protocols/openmm_utils/system_validation.py b/src/openfe/protocols/openmm_utils/system_validation.py index 3e8ed5c50..f112d3c97 100644 --- a/src/openfe/protocols/openmm_utils/system_validation.py +++ b/src/openfe/protocols/openmm_utils/system_validation.py @@ -14,7 +14,9 @@ SmallMoleculeComponent, SolventComponent, ) +import numpy as np from openff.toolkit import Molecule as OFFMol +import openmm def get_alchemical_components( @@ -177,3 +179,104 @@ def _get_single_comps(state, comptype): small_mols = state.get_components_of_type(SmallMoleculeComponent) return solvent_comp, protein_comp, small_mols + + +def assert_multistate_system_equality( + ref_system: openmm.System, + stored_system: openmm.System, +): + """ + Verify the equality of a MultiStateReporter + stored system, with that of a pre-exisiting + standard system. + + + Raises + ------ + ValueError + * If the particles in the two System don't match. + * If the constraints in the two System don't match. + * If the forces in the two systems don't match. + """ + + # Assert particle equality + def _get_masses(system): + return np.array( + [ + system.getParticleMass(i).value_in_unit(openmm.unit.dalton) + for i in range(system.getNumParticles()) + ] + ) + + ref_masses = _get_masses(ref_system) + stored_masses = _get_masses(stored_system) + + if not ( + (ref_masses.shape == stored_masses.shape) and + (np.allclose(ref_masses, stored_masses)) + ): + errmsg = "Stored checkpoint System particles do not match those of the simulated System" + raise ValueError(errmsg) + + # Assert constraint equality + def _get_constraints(system): + constraints = [] + for index in range(system.getNumConstraints()): + i, j, d = system.getConstraintParameters(index) + constraints.append([i, j, d.value_in_unit(openmm.unit.nanometer)]) + + return np.array(constraints) + + ref_constraints = _get_constraints(ref_system) + stored_constraints = _get_constraints(stored_system) + + if not ( + (ref_constraints.shape == stored_constraints.shape) and + (np.allclose(ref_constraints, stored_constraints)) + ): + errmsg = "Stored checkpoint System constraints do not match those of the simulation System" + raise ValueError(errmsg) + + # Assert force equality + # Notes: + # * Store forces are in different order + # * The barostat doesn't exactly match because seeds have changed + + # Create dictionaries of forces keyed by their hash + # Note: we can't rely on names because they may clash + ref_force_dict = {hash(openmm.XmlSerializer.serialize(f)): f for f in ref_system.getForces()} + stored_force_dict = { + hash(openmm.XmlSerializer.serialize(f)): f for f in stored_system.getForces() + } + + # Assert the number of forces is equal + if len(ref_force_dict) != len(stored_force_dict): + errmsg = "Number of forces stored in checkpoint System does not match simulation System" + raise ValueError(errmsg) + + # Loop through forces and check for equality + for sfhash, sforce in stored_force_dict.items(): + errmsg = ( + f"Force {sforce.getName()} in the stored checkpoint System " + "does not match the same force in the simulated System" + ) + + # Barostat case - seed changed so we need to check manually + if isinstance(sforce, (openmm.MonteCarloBarostat, openmm.MonteCarloMembraneBarostat)): + # Find the equivalent force in the reference + rforce = [ + f for f in ref_force_dict.values() + if isinstance(f, (openmm.MonteCarloBarostat, openmm.MonteCarloMembraneBarostat)) + ][0] + + if ( + (sforce.getFrequency() != rforce.getFrequency()) + or (sforce.getForceGroup() != rforce.getForceGroup()) + or (sforce.getDefaultPressure() != rforce.getDefaultPressure()) + or (sforce.getDefaultTemperature() != rforce.getDefaultTemperature()) + ): + raise ValueError(errmsg) + + else: + if sfhash not in ref_force_dict: + raise ValueError(errmsg) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 15f5153a7..520bbeff4 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -9,7 +9,9 @@ import openmm import pooch import pytest +import gufe from gufe.protocols import execute_DAG +from gufe.protocols.errors import ProtocolUnitExecutionError from numpy.testing import assert_allclose from openfe_analysis.utils.multistate import _determine_position_indices from openff.units import unit as offunit @@ -44,6 +46,29 @@ def protocol_settings(): return settings +def test_verify_execution_environment(): + # Verification should pass + openmm_rfe.HybridTopologyMultiStateSimulationUnit._verify_execution_environment( + setup_outputs={ + "gufe_version": gufe.__version__, + "openfe_version": openfe.__version__, + "openmm_version": openmm.__version__, + }, + ) + + +def test_verify_execution_environment_fail(): + # Passing a bad version should fail + with pytest.raises(ProtocolUnitExecutionError, match="Python environment"): + openmm_rfe.HybridTopologyMultiStateSimulationUnit._verify_execution_environment( + setup_outputs={ + "gufe_version": 0.1, + "openfe_version": openfe.__version__, + "openmm_version": openmm.__version__, + }, + ) + + @pytest.mark.skipif( not os.path.exists(POOCH_CACHE) and not HAS_INTERNET, reason="Internet unavailable and test data is not cached locally", @@ -197,7 +222,8 @@ def test_resume_fail(self, protocol_dag, htop_trajectory_path, htop_checkpoint_p setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) # Fake system should trigger a mismatch - with pytest.raises(ValueError, match="System in checkpoint does not"): + errmsg = "Stored checkpoint System particles do not" + with pytest.raises(ValueError, match=errmsg): sim_results = simulation_unit.run( system=openmm.System(), positions=setup_results["hybrid_positions"], From b1bb516a88c248ca44d25dc8508446d31ae6c7bb Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 15 Feb 2026 23:42:38 +0000 Subject: [PATCH 89/91] Add more tests --- .../openmm_rfe/test_hybrid_top_resume.py | 131 +++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 520bbeff4..7090f8a0d 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -203,10 +203,12 @@ def test_resume(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, reporter.close() del sampler - def test_resume_fail(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + def test_resume_fail_particles(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): """ Test that the run unit will fail with a system incompatible to the one present in the trajectory/checkpoint files. + + Here we check that we don't have the same particles / mass. """ # define a temp directory path & copy files cwd = pathlib.Path(str(tmpdir)) @@ -232,6 +234,133 @@ def test_resume_fail(self, protocol_dag, htop_trajectory_path, htop_checkpoint_p shared_basepath=cwd, ) + def test_resume_fail_constraints(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + """ + Test that the run unit will fail with a system incompatible + to the one present in the trajectory/checkpoint files. + + Here we check that we don't have the same constraints. + """ + # define a temp directory path & copy files + cwd = pathlib.Path(str(tmpdir)) + self._copy_simfiles(cwd, htop_trajectory_path) + self._copy_simfiles(cwd, htop_checkpoint_path) + + pus = list(protocol_dag.protocol_units) + setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] + simulation_unit = _get_units(pus, HybridTopologyMultiStateSimulationUnit)[0] + analysis_unit = _get_units(pus, HybridTopologyMultiStateAnalysisUnit)[0] + + # Dry run the setup since it'll be easier to use the objects directly + setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) + + # Create a fake system without constraints + fake_system = copy.deepcopy(setup_results["hybrid_system"]) + + for i in reversed(range(fake_system.getNumConstraints())): + fake_system.removeConstraint(i) + + # Fake system should trigger a mismatch + errmsg = "Stored checkpoint System constraints do not" + with pytest.raises(ValueError, match=errmsg): + sim_results = simulation_unit.run( + system=fake_system, + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + scratch_basepath=cwd, + shared_basepath=cwd, + ) + + + def test_resume_fail_forces(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + """ + Test that the run unit will fail with a system incompatible + to the one present in the trajectory/checkpoint files. + + Here we check we don't have the same forces. + """ + # define a temp directory path & copy files + cwd = pathlib.Path(str(tmpdir)) + self._copy_simfiles(cwd, htop_trajectory_path) + self._copy_simfiles(cwd, htop_checkpoint_path) + + pus = list(protocol_dag.protocol_units) + setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] + simulation_unit = _get_units(pus, HybridTopologyMultiStateSimulationUnit)[0] + analysis_unit = _get_units(pus, HybridTopologyMultiStateAnalysisUnit)[0] + + # Dry run the setup since it'll be easier to use the objects directly + setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) + + # Create a fake system without the last force + fake_system = copy.deepcopy(setup_results["hybrid_system"]) + fake_system.removeForce(fake_system.getNumForces() - 1) + + # Fake system should trigger a mismatch + errmsg = "Number of forces stored in checkpoint System" + with pytest.raises(ValueError, match=errmsg): + sim_results = simulation_unit.run( + system=fake_system, + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + scratch_basepath=cwd, + shared_basepath=cwd, + ) + + @pytest.mark.parametrize('forcetype', [openmm.NonbondedForce, openmm.MonteCarloBarostat]) + def test_resume_differ_forces(self, forcetype, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + """ + Test that the run unit will fail with a system incompatible + to the one present in the trajectory/checkpoint files. + + Here we check we have a different force + """ + # define a temp directory path & copy files + cwd = pathlib.Path(str(tmpdir)) + self._copy_simfiles(cwd, htop_trajectory_path) + self._copy_simfiles(cwd, htop_checkpoint_path) + + pus = list(protocol_dag.protocol_units) + setup_unit = _get_units(pus, HybridTopologySetupUnit)[0] + simulation_unit = _get_units(pus, HybridTopologyMultiStateSimulationUnit)[0] + analysis_unit = _get_units(pus, HybridTopologyMultiStateAnalysisUnit)[0] + + # Dry run the setup since it'll be easier to use the objects directly + setup_results = setup_unit.run(dry=True, scratch_basepath=cwd, shared_basepath=cwd) + + # Create a fake system with the fake forcetype + fake_system = copy.deepcopy(setup_results["hybrid_system"]) + + # Loop through forces and remove the force matching forcetype + for i, f in enumerate(fake_system.getForces()): + if isinstance(f, forcetype): + findex = i + + fake_system.removeForce(findex) + + # Now add a fake force + if forcetype == openmm.MonteCarloBarostat: + new_force = forcetype( + 1*openmm.unit.atmosphere, + 300*openmm.unit.kelvin, + 100 + ) + else: + new_force = forcetype() + + fake_system.addForce(new_force) + + # Fake system should trigger a mismatch + errmsg = "stored checkpoint System does not match the same force" + with pytest.raises(ValueError, match=errmsg): + sim_results = simulation_unit.run( + system=fake_system, + positions=setup_results["hybrid_positions"], + selection_indices=setup_results["selection_indices"], + scratch_basepath=cwd, + shared_basepath=cwd, + ) + @pytest.mark.parametrize("bad_file", ["trajectory", "checkpoint"]) def test_resume_bad_files( self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, bad_file, tmpdir From 1cbd4e2ed74d08cee24cd5df8b9171f2ae6f8c0c Mon Sep 17 00:00:00 2001 From: IAlibay Date: Sun, 15 Feb 2026 23:56:53 +0000 Subject: [PATCH 90/91] Add slow guards since the tests are quite expensive --- .../tests/protocols/openmm_rfe/test_hybrid_top_resume.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 7090f8a0d..2771323ea 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -43,6 +43,7 @@ def protocol_settings(): settings.simulation_settings.production_length = 200 * offunit.picosecond settings.simulation_settings.time_per_iteration = 2.5 * offunit.picosecond settings.output_settings.checkpoint_interval = 100 * offunit.picosecond + settings.engine_settings.compute_platform = None return settings @@ -203,6 +204,7 @@ def test_resume(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, reporter.close() del sampler + @pytest.mark.slow def test_resume_fail_particles(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): """ Test that the run unit will fail with a system incompatible @@ -234,6 +236,7 @@ def test_resume_fail_particles(self, protocol_dag, htop_trajectory_path, htop_ch shared_basepath=cwd, ) + @pytest.mark.slow def test_resume_fail_constraints(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): """ Test that the run unit will fail with a system incompatible @@ -271,7 +274,7 @@ def test_resume_fail_constraints(self, protocol_dag, htop_trajectory_path, htop_ shared_basepath=cwd, ) - + @pytest.mark.slow def test_resume_fail_forces(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): """ Test that the run unit will fail with a system incompatible @@ -307,6 +310,7 @@ def test_resume_fail_forces(self, protocol_dag, htop_trajectory_path, htop_check shared_basepath=cwd, ) + @pytest.mark.slow @pytest.mark.parametrize('forcetype', [openmm.NonbondedForce, openmm.MonteCarloBarostat]) def test_resume_differ_forces(self, forcetype, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): """ @@ -361,6 +365,7 @@ def test_resume_differ_forces(self, forcetype, protocol_dag, htop_trajectory_pat shared_basepath=cwd, ) + @pytest.mark.slow @pytest.mark.parametrize("bad_file", ["trajectory", "checkpoint"]) def test_resume_bad_files( self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, bad_file, tmpdir @@ -401,6 +406,7 @@ def test_resume_bad_files( shared_basepath=cwd, ) + @pytest.mark.slow @pytest.mark.parametrize("missing_file", ["trajectory", "checkpoint"]) def test_missing_file( self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, missing_file, tmpdir From 5a25356f162e2262f43632a44304b938b2a067a3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:57:35 +0000 Subject: [PATCH 91/91] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../openmm_utils/system_validation.py | 18 ++++++------ .../openmm_rfe/test_hybrid_top_resume.py | 28 +++++++++++-------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/src/openfe/protocols/openmm_utils/system_validation.py b/src/openfe/protocols/openmm_utils/system_validation.py index f112d3c97..7b45a077a 100644 --- a/src/openfe/protocols/openmm_utils/system_validation.py +++ b/src/openfe/protocols/openmm_utils/system_validation.py @@ -7,6 +7,8 @@ from typing import Optional, Tuple +import numpy as np +import openmm from gufe import ( ChemicalSystem, Component, @@ -14,9 +16,7 @@ SmallMoleculeComponent, SolventComponent, ) -import numpy as np from openff.toolkit import Molecule as OFFMol -import openmm def get_alchemical_components( @@ -211,10 +211,7 @@ def _get_masses(system): ref_masses = _get_masses(ref_system) stored_masses = _get_masses(stored_system) - if not ( - (ref_masses.shape == stored_masses.shape) and - (np.allclose(ref_masses, stored_masses)) - ): + if not ((ref_masses.shape == stored_masses.shape) and (np.allclose(ref_masses, stored_masses))): errmsg = "Stored checkpoint System particles do not match those of the simulated System" raise ValueError(errmsg) @@ -229,10 +226,10 @@ def _get_constraints(system): ref_constraints = _get_constraints(ref_system) stored_constraints = _get_constraints(stored_system) - + if not ( - (ref_constraints.shape == stored_constraints.shape) and - (np.allclose(ref_constraints, stored_constraints)) + (ref_constraints.shape == stored_constraints.shape) + and (np.allclose(ref_constraints, stored_constraints)) ): errmsg = "Stored checkpoint System constraints do not match those of the simulation System" raise ValueError(errmsg) @@ -265,7 +262,8 @@ def _get_constraints(system): if isinstance(sforce, (openmm.MonteCarloBarostat, openmm.MonteCarloMembraneBarostat)): # Find the equivalent force in the reference rforce = [ - f for f in ref_force_dict.values() + f + for f in ref_force_dict.values() if isinstance(f, (openmm.MonteCarloBarostat, openmm.MonteCarloMembraneBarostat)) ][0] diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py index 2771323ea..dcfbabb37 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_resume.py @@ -5,11 +5,11 @@ import pathlib import shutil +import gufe import numpy as np import openmm import pooch import pytest -import gufe from gufe.protocols import execute_DAG from gufe.protocols.errors import ProtocolUnitExecutionError from numpy.testing import assert_allclose @@ -205,7 +205,9 @@ def test_resume(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, del sampler @pytest.mark.slow - def test_resume_fail_particles(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + def test_resume_fail_particles( + self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir + ): """ Test that the run unit will fail with a system incompatible to the one present in the trajectory/checkpoint files. @@ -237,7 +239,9 @@ def test_resume_fail_particles(self, protocol_dag, htop_trajectory_path, htop_ch ) @pytest.mark.slow - def test_resume_fail_constraints(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + def test_resume_fail_constraints( + self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir + ): """ Test that the run unit will fail with a system incompatible to the one present in the trajectory/checkpoint files. @@ -275,7 +279,9 @@ def test_resume_fail_constraints(self, protocol_dag, htop_trajectory_path, htop_ ) @pytest.mark.slow - def test_resume_fail_forces(self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + def test_resume_fail_forces( + self, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir + ): """ Test that the run unit will fail with a system incompatible to the one present in the trajectory/checkpoint files. @@ -311,8 +317,10 @@ def test_resume_fail_forces(self, protocol_dag, htop_trajectory_path, htop_check ) @pytest.mark.slow - @pytest.mark.parametrize('forcetype', [openmm.NonbondedForce, openmm.MonteCarloBarostat]) - def test_resume_differ_forces(self, forcetype, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir): + @pytest.mark.parametrize("forcetype", [openmm.NonbondedForce, openmm.MonteCarloBarostat]) + def test_resume_differ_forces( + self, forcetype, protocol_dag, htop_trajectory_path, htop_checkpoint_path, tmpdir + ): """ Test that the run unit will fail with a system incompatible to the one present in the trajectory/checkpoint files. @@ -341,14 +349,10 @@ def test_resume_differ_forces(self, forcetype, protocol_dag, htop_trajectory_pat findex = i fake_system.removeForce(findex) - + # Now add a fake force if forcetype == openmm.MonteCarloBarostat: - new_force = forcetype( - 1*openmm.unit.atmosphere, - 300*openmm.unit.kelvin, - 100 - ) + new_force = forcetype(1 * openmm.unit.atmosphere, 300 * openmm.unit.kelvin, 100) else: new_force = forcetype()