def add_perturbation(self, sigma=0.05, max_length=0.2): """Add a random perturbation to all atoms in the configuration ---------------------------------------------------------------------- :param sigma: (float) Variance of the normal distribution used to generate displacements in Å :param max_length: (float) Maximum length of the random displacement vector in Å """ logger.info(f'Displacing all atoms in the system using a random ' f'displacments from a normal distribution:\n' f'σ = {sigma} Å\n' f'max(|v|) = {max_length} Å') for atom in self.atoms: # Generate random vectors until one has length < threshold while True: vector = np.random.normal(loc=0.0, scale=sigma, size=3) if np.linalg.norm(vector) < max_length: atom.translate(vector) break return None
def _set_soap(self, atom_symbols): """Set the SOAP parameters""" added_pairs = [] for symbol in set(atom_symbols): if symbol == 'H': logger.warning('Not adding SOAP on H') continue params = GTConfig.gap_default_soap_params.copy() # Add all the atomic symbols that aren't this one, the neighbour # density for which also hasn't been added already params["other"] = [ s for s in set(atom_symbols) if s + symbol not in added_pairs and symbol + s not in added_pairs ] # If there are no other atoms of this type then remove the self # pair if atom_symbols.count(symbol) == 1: params["other"].remove(symbol) for other_symbol in params["other"]: added_pairs.append(symbol + other_symbol) if len(params["other"]) == 0: logger.info(f'Not adding SOAP to {symbol} - should be covered') continue self.soap[symbol] = params return None
def remove_above_e(self, threshold, min_energy=None): """ Remove configurations above an energy threshold :param threshold: (float) Energy threshold in eV :param min_energy: (float or None) Minimum/reference energy to use to calculate the relative energy of a configuration. If None then the lowest energy in this set is used """ if any(config.energy is None for config in self._list): raise ValueError('Cannot truncate: a config had energy=None') if min_energy is None: min_energy = min(config.energy for config in self._list) logger.info(f'Truncating {len(self._list)} configurations with an ' f'energy threshold of {threshold:.3f} eV above a reference' f' energy of {min_energy:.3f} eV') self._list = [ config for config in self._list if (config.energy - min_energy) < threshold ] logger.info(f'Truncated on energies to {len(self._list)}') return None
def __init__(self, xyz_filename=None, charge=0, spin_multiplicity=1, gmx_itp_filename=None, atoms=None): """Molecule e.g. H2O ----------------------------------------------------------------------- :param xyz_filename: (str) :param charge: (int) :param spin_multiplicity: (int) :param gmx_itp_filename: (str) Filename(path) of the GROMACS .itp file containing MM parameters required to simulate :param atoms: (list(autode.atoms.Atom)) """ if xyz_filename is not None: atoms = xyz_file_to_atoms(xyz_filename) super().__init__(charge=charge, spin_multiplicity=spin_multiplicity, atoms=atoms) self.itp_filename = gmx_itp_filename self.name = str(self) logger.info(f'Initialised {self.name}\n' f'Number of atoms = {self.n_atoms}\n' f'GROMACS itp filename = {self.itp_filename}')
def simulation_steps(dt, kwargs): """Calculate the number of simulation steps :param dt: (float) Timestep in fs :param kwargs: (dict) :return: (float) """ if dt < 0.09 or dt > 5: logger.warning('Unexpectedly small or large timestep - is it in fs?') if 'ps' in kwargs: time_fs = 1E3 * kwargs['ps'] elif 'fs' in kwargs: time_fs = kwargs['fs'] elif 'ns' in kwargs: time_fs = 1E6 * kwargs['ns'] else: raise ValueError('Simulation time not found') logger.info(f'Running {time_fs / dt:.0f} steps with a timestep of {dt} fs') # Run at least one step return max(int(time_fs / dt), 1)
def remove_intra(configs, gap): """ Remove the intramolecular energy and forces from a set of configurations using a GAP :param configs: (gt.ConfigurationSet) :param gap: (gt.GAP) """ if isinstance(gap, gt.gap.IIGAP): logger.info('Removing the intramolecular energy and forces..') intra_configs = configs.copy() intra_configs.parallel_gap(gap=gap.intra) for config, i_config in zip(configs, intra_configs): config.energy -= i_config.energy config.forces -= i_config.forces # If there is also a solute in the system then remove the energy # associated with it if isinstance(gap, gt.gap.SSGAP): solute_configs = configs.copy() solute_configs.parallel_gap(gap=gap.solute_intra) for config, s_config in zip(configs, solute_configs): config.energy -= s_config.energy config.forces -= s_config.forces return configs
def train(self, data=None, sub_sample=False): """ Train the ensemble of GAPS :param data: (gaptrain.data.Data) :param sub_sample: (bool) Should the data be sub sampled or the full set used? """ if data is None: data = self.training_data if data is None: raise AssertionError('Could not train - no training data set') logger.info(f'Training an ensemble with a total of {len(data)} ' 'configurations') for i, gap in enumerate(self.gaps): if sub_sample: training_data = self.sub_sampled_data(data, gap, random=True) else: training_data = data.copy() # Ensure that the data's name is unique, for saving etc. training_data.name += f's{i}' # Train the GAP gap.train(data=training_data) return None
def __init__(self, configs, e_lower=0.1, e_thresh=None, max_fs=1000, interval_fs=20, temp=300, dt_fs=0.5): """ τ_acc prospective error metric in fs ---------------------------------------------------------------------- :param configs: (list(gt.Configuration) | gt.ConfigurationSet) A set of initial configurations from which dynamics with be propagated from :param e_lower: (float) E_l energy threshold in eV below which the error is zero-ed, i.e. the acceptable level of error possible in the system :param e_thresh: (float | None) E_t total cumulative error in eV. τ_acc is defined at the time in the simulation where this threshold is exceeded. If None then: e_thresh = 10 * e_lower :param max_fs: (float) Maximum time in femto-seconds for τ_acc :zaram interval_fs: (float) Interval between which |E_true - E_GAP| is calculated. *MUST* be at least one timestep :param temp: (float) Temperature of the simulation to perform :param dt_fs: (float) Timestep of the simulation in femto-seconds """ if len(configs) < 1: raise ValueError('Must have at least one configuration to ' 'calculate τ_acc from') if interval_fs < dt_fs: raise ValueError('The calculated interval must be more than a ' 'single timestep') self.value = 0 # τ_acc / fs self.error = None # standard error in the mean self.init_configs = configs self.dt = float(dt_fs) self.temp = float(temp) self.max_time = float(max_fs) self.interval_time = float(interval_fs) self.e_l = float(e_lower) self.e_t = 10 * self.e_l if e_thresh is None else float(e_thresh) logger.info('Successfully initialised τ_acc, will do a maximum of ' f'{int(self.max_time // self.interval_time)} reference ' f'calculations')
def histogram(self, name=None, ref_energy=None): """Generate a histogram of the energies and forces in these data""" logger.info('Plotting histogram of energies and forces') # Histogram |F_ij| rather than the components F_ijk for a force F in on # an atom j in a configuration i return histogram(self.energies(), self.force_magnitudes(), name=name, ref_energy=ref_energy)
def set_threads(n_cores): """Set the number of threads to use""" n_cores = GTConfig.n_cores if n_cores is None else n_cores logger.info(f'Using {n_cores} cores') os.environ['OMP_NUM_THREADS'] = str(n_cores) os.environ['MLK_NUM_THREADS'] = str(n_cores) return None
def train(self, data): """Train the inter-component of the GAP""" logger.info('Training the intermolecular component of the potential. ' 'Expecting data that is free from intra energy and force') if not os.path.exists(f'{self.intra.name}.xml'): raise RuntimeError('Intra must be already trained') self.training_data = data return self.inter.train(data)
def calc_error(frame, gap, method_name): """Calculate the error between the ground truth and the GAP prediction""" frame.single_point(method_name=method_name, n_cores=1) pred = frame.copy() pred.run_gap(gap=gap, n_cores=1) error = np.abs(pred.energy - frame.energy) logger.info(f'|E_GAP - E_0| = {np.round(error, 3)} eV') return error
def get_active_config_qbc(config, gap, temp, std_e_thresh, max_time_fs, **kwargs): """ Generate an 'active' configuration, i.e. a configuration to be added to the training set by active learning, using a query-by-committee model, where the prediction between different models (standard deviation) exceeds a threshold (std_e_thresh) ------------------------------------------------------------------------ :param config: (gt.Configuration) :param gap: (gt.GAPEnsemble) An 'ensemble' of GAPs trained on the same/ similar data to make predictions with :param temp: (float) Temperature for the GAP-MD :param std_e_thresh: (float) Threshold for the maximum standard deviation between the GAP predictions (on the whole system), above which a frame is added :param max_time_fs: (float) :return: (gt.Configuration) """ n_iters, curr_time = 0, 0.0 while curr_time < max_time_fs: gap_traj = gt.md.run_gapmd(config, gap=gap.gaps[0], temp=float(temp), dt=0.5, interval=4, fs=2 + n_iters**3, n_cores=1, **kwargs) for frame in gap_traj[::max(1, len(gap_traj)//10)]: pred_es = [] # Calculated predicted energies in serial for all the gaps for single_gap in gap.gaps: frame.run_gap(gap=single_gap, n_cores=1) pred_es.append(frame.energy) # and return the frame if the standard deviation in the predictions # is larger than a threshold std_e = np.std(np.array(pred_es)) if std_e > std_e_thresh: return frame logger.info(f'σ(t={curr_time:.1f}) = {std_e:.6f}') n_iters += 1 curr_time += 2 + n_iters**3 return None
def predict_energy_error(self, *args): """ Predict the standard deviation between predicted energies ----------------------------------------------------------------------- :param args: (gaptrain.configurations.Configuration | gaptrain.configurations.ConfigurationSet) :return: (float | list(float)) Error on each of the configurations """ configs = [] # Populate a list of all the configurations that need to be for arg in args: try: for config in arg: configs.append(config) except TypeError: assert arg.__class__.__name__ == 'Configuration' configs.append(arg) assert len(configs) != 0 start_time = time() results = np.empty(shape=(len(configs), self.n_gaps()), dtype=object) predictions = np.empty(shape=results.shape) os.environ['OMP_NUM_THREADS'] = '1' os.environ['MLK_NUM_THREADS'] = '1' logger.info('Set OMP and MLK threads to 1') with Pool(processes=GTConfig.n_cores) as pool: # Apply the method to each configuration in this set for i, config in enumerate(configs): for j, gap in enumerate(self.gaps): result = pool.apply_async(func=run_gap, args=(config, None, gap)) results[i, j] = result # Reset all the configurations in this set with updated energy # and forces (each with .true) for i in range(len(configs)): for j in range(self.n_gaps()): predictions[i, j] = results[i, j].get(timeout=None).energy logger.info(f'Calculations done in {(time() - start_time):.1f} s') if len(configs) == 1: return np.std(predictions[0]) return [np.std(predictions[i, :]) for i in range(len(configs))]
def _set_pairs(self, atom_symbols): """Set the two-body pair parameters""" for pair in combinations_with_replacement(set(atom_symbols), r=2): s_a, s_b = pair # Atomic symbols of the pair if s_a == s_b and atom_symbols.count(s_a) == 1: logger.info(f'Only a single {s_b} atom not adding pairwise') continue self.pairwise[pair] = GTConfig.gap_default_2b_params return None
def __init__(self, configs_a, configs_b): """ A loss on energies (eV) and forces (eV Å-1). Force loss is computed element-wise :param configs_a: (gaptrain.configurations.ConfigurationSet) :param configs_b: (gaptrain.configurations.ConfigurationSet) """ logger.info( f'Calculating loss between {len(configs_a)} configurations') self.energy = self.loss(configs_a, configs_b, attr='energy') self.force = self.loss(configs_a, configs_b, attr='forces')
def save(self, filename=None, append=False): """ Print this configuration as an extended xyz file where the first 4 columns are the atom symbol, x, y, z and, if this configuration contains forces then add the x, y, z components of the force on as columns 4-7. ----------------------------------------------------------------------- :param filename: (str) :param append: (bool) Append to the end of this exyz file? """ if filename is None: filename = f'{self.name}.xyz' logger.info(f'Saving configuration as {filename}') a, b, c = self.box.size energy_str = '' if self.energy is not None: energy_str += f'dft_energy={self.energy:.8f}' prop_str = 'Properties=species:S:1:pos:R:3' if self.forces is not None: prop_str += ':dft_forces:R:3' if not filename.endswith('.xyz'): logger.warning('Filename had no .xyz extension - adding') filename += '.xyz' with open(filename, 'a' if append else 'w') as exyz_file: print( f'{len(self.atoms)}\n' f'Lattice="{a:.6f} 0.000000 0.000000 ' f'0.000000 {b:.6f} 0.000000 ' f'0.000000 0.000000 {c:.6f}" ' f'{prop_str} ' f'{energy_str}', file=exyz_file) for i, atom in enumerate(self.atoms): x, y, z = atom.coord line = f'{atom.label} {x:.5f} {y:.5f} {z:.5f} ' if self.forces is not None: fx, fy, fz = self.forces[i] line += f'{fx:.5f} {fy:.5f} {fz:.5f}' print(line, file=exyz_file) return None
def truncate(self, n, method='random', **kwargs): """ Truncate this set of configurations to a n configurations :param n: (int) Number of configurations to truncate to :param method: (str) Name of the method to use :param kwargs: ensemble (gaptrain.gap.GAPEnsemble) """ implemented_methods = ['random', 'cur', 'ensemble', 'higher', 'cur_k'] if method.lower() not in implemented_methods: raise NotImplementedError(f'Methods are {implemented_methods}') if n > len(self._list): raise ValueError(f'Cannot truncate a set of {len(self)} ' f'configurations to {n}') if method.lower() == 'random': return self.remove_random(remainder=n) if 'cur' in method.lower(): if method.lower() == 'cur': matrix = gt.descriptors.soap(self) elif method.lower() == 'cur_k': matrix = gt.descriptors.soap_kernel_matrix(self) else: raise NotImplementedError cur_idxs = gt.cur.rows(matrix, k=n, return_indexes=True) self._list = [self._list[idx] for idx in cur_idxs] if method.lower() == 'ensemble': logger.info(f'Truncating {len(self)} configurations based on the' f' largest errors using a ensemble of GAP models') if 'ensemble' not in kwargs: raise KeyError('A GAPEnsemble must be provided to use' 'ensemble truncation i.e.: ' 'truncate(.., ensemble=ensemble_instance)') errors = kwargs['ensemble'].predict_energy_error(self._list) # Use the n configurations with the largest error, where numpy # argsort sorts from smallest->largest so take the last n values self._list = [self._list[i] for i in np.argsort(errors)[-n:]] if method.lower() == 'higher': return self.remove_highest_e(n=len(self) - n) return None
def __init__(self, name, system, molecule): """An intramolecular GAP, must be initialised with a system so the molecules are defined :param name: (str) :param system: (gt.system.System) """ super().__init__(name, system) self.mol_idxs = [] self._set_mol_idxs(system, molecule) logger.info(f'Initialised an intra-GAP with molecular indexes:' f' {self.mol_idxs}')
def __add__(self, other): """Add another configuration or set of configurations onto this one""" if isinstance(other, Configuration): self._list.append(other) elif isinstance(other, ConfigurationSet): self._list += other._list else: raise TypeError('Can only add a Configuration or' f' ConfigurationSet, not {type(other)}') logger.info(f'Current number of configurations is {len(self)}') return self
def ase_momenta_string(configuration, temp, bbond_energy: dict, fbond_energy: dict): """Generate a string to set the initial momenta :param configuration: (gt.Configuration) :param temp: (float) :param bbond_energy: (dict | None) Breaking bond energy :param fbond_energy: (dict | None) Forming bond energy """ string = '' if temp > 0: logger.info(f'Initialising initial velocities for a temperature ' f'{temp} K') string += f'MaxwellBoltzmannDistribution(system, {temp} * units.kB)\n' else: # Set the momenta to zero string += f"system.arrays['momenta'] = np.zeros((len(system), 3))\n" def momenta(idx, vector, energy): return (f"system.arrays['momenta'][{int(idx)}] = " f"np.sqrt(system.get_masses()[{int(idx)}] * {energy})" f" * np.array({vector.tolist()})\n") coords = configuration.coordinates() if bbond_energy is not None: logger.info('Adding breaking bond momenta') for atom_idxs, energy in bbond_energy.items(): i, j = atom_idxs logger.info(f'Adding {energy} eV to break bond: {i}-{j}') # vec # <--- i--j where i and j are two atoms # vec = coords[i] - coords[j] vec /= np.linalg.norm(vec) # normalise string += momenta(idx=i, vector=vec, energy=energy) string += momenta(idx=j, vector=-vec, energy=energy) if fbond_energy is not None: for atom_idxs, energy in fbond_energy.items(): i, j = atom_idxs logger.info(f'Adding {energy} eV to form bond: {i}-{j}') # vec # ---> i--j where i and j are two atoms # vec = coords[j] - coords[i] vec /= np.linalg.norm(vec) # normalise string += momenta(idx=i, vector=vec, energy=energy) string += momenta(idx=j, vector=-vec, energy=energy) return string
def _calculate_single(self, init_config, gap, method_name): """ Calculate a single τ_acc from one configuration :param init_config: (gt.Configuration) :param gap: (gt.GAP) :param method_name: (str) Ground truth method e.g. dftb, orca, gpaw """ cuml_error, curr_time = 0, 0 block_time = self.interval_time * gt.GTConfig.n_cores step_interval = self.interval_time // self.dt while curr_time < self.max_time: traj = gt.md.run_gapmd(init_config, gap=gap, temp=self.temp, dt=self.dt, interval=step_interval, fs=block_time, n_cores=min(gt.GTConfig.n_cores, 4)) # Only evaluate the energy try: traj.single_point(method_name=method_name) except ValueError: logger.warning('Failed to calculate single point energies with' f' {method_name}. τ_acc will be underestimated ' f'by <{block_time}') return curr_time pred = traj.copy() pred.parallel_gap(gap=gap) logger.info(' ___ |E_true - E_GAP|/eV ___') logger.info(f' t/fs err cumul(err)') for j in range(len(traj)): e_error = np.abs(traj[j].energy - pred[j].energy) # Add any error above the allowed threshold cuml_error += max(e_error - self.e_l, 0) curr_time += self.dt * step_interval logger.info(f'{curr_time:5.0f} ' f'{e_error:6.4f} ' f'{cuml_error:6.4f}') if cuml_error > self.e_t: return curr_time init_config = traj[-1] logger.info(f'Reached max(τ_acc) = {self.max_time} fs') return self.max_time
def calculate(self, gap, method_name): """Calculate the time to accumulate self.e_t eV of error above self.e_l eV""" taus = [] for config in self.init_configs: tau = self._calculate_single(init_config=config, gap=gap, method_name=method_name) taus.append(tau) # Calculate τ_acc as the average ± the standard error in the mean self.value = np.average(np.array(taus)) if len(taus) > 1: self.error = np.std(np.array(taus)) / np.sqrt(len(taus)) # σ / √N logger.info(str(self)) return None
def soap(*args): """ Create a SOAP vector using dscribe (https://github.com/SINGROUP/dscribe) for a set of configurations soap(config) -> [[v0, v1, ..]] soap(config1, config2) -> [[v0, v1, ..], [u0, u1, ..]] soap(configset) -> [[v0, v1, ..], ..] --------------------------------------------------------------------------- :param args: (gaptrain.configurations.Configuration) or (gaptrain.configurations.ConfigurationSet) :return: (np.ndarray) shape = (len(args), n) where n is the length of the SOAP descriptor """ from dscribe.descriptors import SOAP configurations = args # If a configuration set is specified then use that as the list of configs if len(args) == 1 and isinstance(args[0], Iterable): configurations = args[0] logger.info(f'Calculating SOAP descriptor for {len(configurations)}' f' configurations') unique_elements = list(set(atom.label for atom in configurations[0].atoms)) # Compute the average SOAP vector where the expansion coefficients are # calculated over averages over each site soap_desc = SOAP( species=unique_elements, rcut=5, # Distance cutoff (Å) nmax=6, # Maximum component of the radials lmax=6, # Maximum component of the angular average='inner') soap_vec = soap_desc.create([conf.ase_atoms() for conf in configurations]) logger.info('SOAP calculation done') return soap_vec
def train(self, data): """ Train this GAP on some data :param data: (gaptrain.data.Data) """ assert all(config.energy is not None for config in data) assert self.params is not None if all( len(params) == 0 for params in (self.params.soap, self.params.pairwise, self.params.angle)): raise AssertionError('Must have some GAP parameters!') logger.info('Training a Gaussian Approximation potential on ' f'*{len(data)}* training data points') start_time = time() self.training_data = data self.training_data.save() # Run the training using a specified number of total cores os.environ['OMP_NUM_THREADS'] = str(GTConfig.n_cores) p = Popen(GTConfig.gap_fit_command + self.train_command(), shell=False, stdout=PIPE, stderr=PIPE) out, err = p.communicate() delta_time = time() - start_time print(f'GAP training ran in {delta_time/60:.1f} m') if any((delta_time < 0.01, b'SYSTEM ABORT' in err, not os.path.exists(f'{self.name}.xml'))): raise GAPFailed(f'GAP train errored with:\n ' f'{err.decode()}\n' f'{" ".join(self.train_command())}') return None
def remove_above_f(self, threshold): """ Remove configurations with an atomic force component above a threshold value :param threshold: (float) Force threshold in eV Å-1 """ if any(config.forces is None for config in self._list): raise ValueError('Cannot truncate: a config had forces=None') logger.info(f'Truncating {len(self._list)} configurations with a ' f'force threshold of {threshold:.3f} eV Å-1') self._list = [ config for config in self._list if np.max(config.forces) < threshold ] logger.info(f'Truncated on forces to {len(self._list)}') return None
def set_energy_forces_cp2k_out(configuration, out_filename='cp2k.out'): """ Set the energy and forces of a configuration from a CP2K output file :param configuration: (gt.Configuration) :param out_filename: (str) """ n_atoms = len(configuration.atoms) forces = [] out_lines = open(out_filename, 'r').readlines() for i, line in enumerate(out_lines): """ Total energy: -17.23430883483457 """ if 'Total energy:' in line: # Convert from Ha to eV configuration.energy = ha_to_ev * float(line.split()[-1]) # And grab the first set of atomic forces if 'ATOMIC FORCES' in line: logger.info('Found CP2K forces') """ Format e.g.: ATOMIC FORCES in [a.u.] # Atom Kind Element X Y Z 1 1 O 0.02872261 0.00136975 0.02168759 2 2 H -0.00988376 0.02251862 -0.01740272 3 2 H -0.01791165 -0.02390685 -0.00393702 """ for f_line in out_lines[i + 3:i + 3 + n_atoms]: fx, fy, fz = f_line.split()[3:] forces.append([float(fx), float(fy), float(fz)]) break # Convert from atomic units to eV Å-1 configuration.forces = np.array(forces) * (ha_to_ev / a0_to_ang) return None
def remove_highest_e(self, n): """ Remove the highest energy n configurations from this set :param n: (int) Number of configurations to remove """ energies = [config.energy for config in self._list] if any(energy is None for energy in energies): raise ValueError('Cannot remove highest energy from a set ' 'with some undefined energies') if n > len(self._list): raise ValueError('The number of configurations needs to be larger ' 'than the number removed') logger.info(f'Removing the least stable {n} configurations') idxs = np.argsort(energies) self._list = [self._list[i] for i in idxs[:-n]] return None
def wrap(self, max_wraps=100): """ Wrap all the atoms into the box :param max_wraps: (int) Maximum number of recursive calls """ logger.info('Wrapping all atoms back into the box') if self.all_atoms_in_box(): logger.info('All atoms in the box - nothing to be done') return None for atom in self.atoms: for i, _ in enumerate(['x', 'y', 'z']): # Atom is not in the upper right octant of 3D space if atom.coord[i] < 0: atom.coord[i] += self.box.size[i] # Atom is further than the upper right quadrant if atom.coord[i] > self.box.size[i]: atom.coord[i] -= self.box.size[i] while not self.all_atoms_in_box(): logger.info('All atoms are still not in the box') self.n_wraps += 1 # Prevent an overflow in the recursive call by setting a threshold if self.n_wraps > max_wraps: return None return self.wrap() # Reset the number of wraps performed on this configuration(?) return None
def __init__(self, name, system=None, n=5, gap=None): """ Ensemble of Gaussian approximation potentials allowing for error estimates by sub-sampling :param name: (str) :param system: (gt.System) :param gap: (gt.GAP) """ logger.info(f'Initialising a GAP ensemble with {int(n)} GAPs') self.training_data = None if system and not gap: self.gaps = [GAP(f'{name}_{i}', system) for i in range(int(n))] elif gap and not system: self.gaps = [deepcopy(gap) for _ in range(int(n))] self.training_data = gap.training_data else: raise AssertionError('Must initialise a GAP ensemble with either ' 'a GAP or a System')