com.act.analysis.surfactant.SurfactantAnalysis.java Source code

Java tutorial

Introduction

Here is the source code for com.act.analysis.surfactant.SurfactantAnalysis.java

Source

/*************************************************************************
*                                                                        *
*  This file is part of the 20n/act project.                             *
*  20n/act enables DNA prediction for synthetic biology/bioengineering.  *
*  Copyright (C) 2017 20n Labs, Inc.                                     *
*                                                                        *
*  Please direct all queries to act@20n.com.                             *
*                                                                        *
*  This program is free software: you can redistribute it and/or modify  *
*  it under the terms of the GNU General Public License as published by  *
*  the Free Software Foundation, either version 3 of the License, or     *
*  (at your option) any later version.                                   *
*                                                                        *
*  This program is distributed in the hope that it will be useful,       *
*  but WITHOUT ANY WARRANTY; without even the implied warranty of        *
*  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
*  GNU General Public License for more details.                          *
*                                                                        *
*  You should have received a copy of the GNU General Public License     *
*  along with this program.  If not, see <http://www.gnu.org/licenses/>. *
*                                                                        *
*************************************************************************/

package com.act.analysis.surfactant;

import chemaxon.calculations.clean.Cleaner;
import chemaxon.formats.MolFormatException;
import chemaxon.formats.MolImporter;
import chemaxon.marvin.calculations.HlbPlugin;
import chemaxon.marvin.calculations.LogPMethod;
import chemaxon.marvin.calculations.MajorMicrospeciesPlugin;
import chemaxon.marvin.calculations.logPPlugin;
import chemaxon.marvin.calculations.pKaPlugin;
import chemaxon.marvin.plugin.PluginException;
import chemaxon.marvin.space.MSpaceEasy;
import chemaxon.marvin.space.MolecularSurfaceComponent;
import chemaxon.marvin.space.MoleculeComponent;
import chemaxon.marvin.space.SurfaceColoring;
import chemaxon.marvin.space.SurfaceComponent;
import chemaxon.struc.DPoint3;
import chemaxon.struc.MolAtom;
import chemaxon.struc.MolBond;
import chemaxon.struc.Molecule;
import com.chemaxon.calculations.solubility.SolubilityCalculator;
import com.chemaxon.calculations.solubility.SolubilityResult;
import com.chemaxon.calculations.solubility.SolubilityUnit;
import com.dreizak.miniball.highdim.Miniball;
import com.dreizak.miniball.model.ArrayPointSet;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.math3.stat.regression.RegressionResults;
import org.apache.commons.math3.stat.regression.SimpleRegression;

import javax.swing.JFrame;
import javax.swing.WindowConstants;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class SurfactantAnalysis {
    String inchi;
    logPPlugin plugin = new logPPlugin();
    MajorMicrospeciesPlugin microspeciesPlugin = new MajorMicrospeciesPlugin();

    Molecule mol;
    // MolAtom objects don't seem to record their index in the parent molecule, so we'll build a mapping here.
    Map<MolAtom, Integer> atomToIndexMap = new HashMap<>();

    // Atom indices for the longest vector between any two atoms in the molecule.
    Integer lvIndex1;
    Integer lvIndex2;
    // Coordinates with lvIndex1 treated as the origin.
    List<DPoint3> normalizedCoordinates;
    Map<Integer, Double> distancesFromLongestVector = new HashMap<>();
    Map<Integer, Double> distancesAlongLongestVector = new HashMap<>();
    Map<Integer, Plane> normalPlanes = new HashMap<>();

    // Atoms with max/min logP values.
    Integer maxLogPIndex;
    Integer minLogPIndex;

    public enum FEATURES {
        // Whole-molecule features
        LOGP_TRUE,

        //TODO:
        //LOGD_7_4,
        //LOGD_2_5,
        //LOGD_RATIO,

        // Plane split features
        PS_LEFT_MEAN_LOGP, PS_RIGHT_MEAN_LOGP, PS_LR_SIZE_DIFF_RATIO, PS_LR_POS_NEG_RATIO_1, // Left neg / right pos
        PS_LR_POS_NEG_RATIO_2, // Right neg / left pos
        PS_ABS_LOGP_DIFF, PS_ABS_LOGP_SIGNS_DIFFER, PS_WEIGHTED_LOGP_DIFF, PS_WEIGHTED_LOGP_SIGNS_DIFFER, PS_MAX_ABS_DIFF, // This should be equivalent to the old split metric from the DARPA report (I hope).
        PS_LEFT_POS_NEG_RATIO, PS_RIGHT_POS_NEG_RATIO,

        // Regression features
        REG_WEIGHTED_SLOPE, REG_WEIGHTED_INTERCEPT, REG_VAL_AT_FARTHEST_POINT, REG_CROSSES_X_AXIS, REG_ABS_SLOPE,

        // Geometric features,
        GEO_LV_FD_RATIO,

        // Extreme neighborhood features
        NBH_MAX_AND_MIN_TOGETHER, NBH_MAX_IN_V1, NBH_MAX_IN_V2, NBH_MIN_IN_V1, NBH_MIN_IN_V2, NBH_MAX_N_MEAN, NBH_MIN_N_MEAN, NBH_MAX_POS_RATIO, NBH_MIN_NEG_RATIO,

        // Solubility features
        SOL_MG_ML_25, SOL_MG_ML_30, SOL_MG_ML_35,

        // pKa features
        PKA_ACID_1, PKA_ACID_1_IDX, PKA_ACID_2, PKA_ACID_2_IDX, PKA_ACID_3, PKA_ACID_3_IDX, PKA_BASE_1, PKA_BASE_1_IDX, PKA_BASE_2, PKA_BASE_2_IDX, PKA_BASE_3, PKA_BASE_3_IDX,

        // HBL features
        HLB_VAL,
    }

    public SurfactantAnalysis() {
    }

    /**
     * Imports a molecule and runs essential calculations (like logP).
     * @param inchi The InChI of a molecule to be imported.
     * @throws MolFormatException
     * @throws PluginException
     * @throws IOException
     */
    public void init(String inchi) throws MolFormatException, PluginException, IOException {
        this.inchi = inchi;
        Molecule importMol = MolImporter.importMol(this.inchi);
        Cleaner.clean(importMol, 3); // This will assign 3D atom coordinates to the MolAtoms in this.mol.
        plugin.standardize(importMol);

        // Note: this doesn't seem to have any effect, but we'll try anyway for our current use case.
        microspeciesPlugin.setpH(1.5);
        microspeciesPlugin.setMolecule(importMol);
        microspeciesPlugin.run();
        Molecule phMol = microspeciesPlugin.getMajorMicrospecies();

        plugin.setlogPMethod(LogPMethod.CONSENSUS);

        // TODO: do we need to explicitly specify ion concentration?
        plugin.setUserTypes("logPTrue,logPMicro,logPNonionic"); // These arguments were chosen via experimentation.

        plugin.setMolecule(phMol);
        plugin.run();
        this.mol = plugin.getResultMolecule();

        // The logP values exposed by the plugin are only accessible by index; make an object -> id map for easier lookup.
        MolAtom[] molAtoms = mol.getAtomArray();
        for (int i = 0; i < molAtoms.length; i++) {
            atomToIndexMap.put(molAtoms[i], i);
        }
    }

    /**
     * Finds the pair of most distant atoms that contribute to the molecule's logP value.
     * @return A pair of atom indices for the two most distant atoms in the molecule.
     */
    public Pair<Integer, Integer> findFarthestContributingAtomPair() {
        Double maxDist = 0.0d;
        Integer di1 = null, di2 = null; // Endpoint atoms of the diameter of the structure.
        for (int i = 0; i < mol.getAtomCount(); i++) {
            if (Double.isNaN(plugin.getAtomlogPIncrement(i))) {
                continue;
            }
            for (int j = 0; j < mol.getAtomCount(); j++) {
                if (i == j) {
                    continue;
                }
                if (Double.isNaN(plugin.getAtomlogPIncrement(j))) {
                    continue;
                }

                MolAtom m1 = mol.getAtom(i);
                MolAtom m2 = mol.getAtom(j);

                DPoint3 c1 = m1.getLocation();
                DPoint3 c2 = m2.getLocation();

                Double dist = c1.distance(c2);

                if (dist > maxDist) {
                    maxDist = dist;
                    di1 = i;
                    di2 = j;
                }
            }
        }

        this.lvIndex1 = di1;
        this.lvIndex2 = di2;

        this.normalizedCoordinates = resetOriginForCoordinates(di1);

        return Pair.of(di1, di2);
    }

    /**
     * Compute the distance between two atoms in the molecule being analyzed.
     * @param a1 The index of one atom.
     * @param a2 The index of the other atom.
     * @return A distance (units not specified) between the two atoms in the molecule's coordinate space.
     */
    public Double computeDistance(Integer a1, Integer a2) {
        return this.normalizedCoordinates.get(a1).distance(this.normalizedCoordinates.get(a2));
    }

    /**
     * Recenters all atomic coordinates around a new origin.
     * @param newOriginIndex The atom index to use as a new origin.
     * @return A list of coordinates for all atoms using the specified atom as the origin.
     */
    public List<DPoint3> resetOriginForCoordinates(Integer newOriginIndex) {
        DPoint3 newOrigin = mol.getAtom(newOriginIndex).getLocation();
        List<DPoint3> coords = new ArrayList<>();
        for (int i = 0; i < mol.getAtomCount(); i++) {
            DPoint3 c = mol.getAtom(i).getLocation();
            c.subtract(newOrigin);
            coords.add(c);
        }
        return coords;
    }

    public static class Plane {
        public double a;
        public double b;
        public double c;
        public double d;

        public Plane(double a, double b, double c, double d) {
            this.a = a;
            this.b = b;
            this.c = c;
            this.d = d;
        }

        public double computeProductForPoint(double x, double y, double z) {
            return a * x + b * y + c * z + d;
        }
    }

    /**
     * Computes an atom's projection onto `lv` and the `lv`-normal plane that intersects that projection, where `lv` is
     * the vector between the pair of most distant atoms in the molecule.
     *
     * @return Maps of atomic indices to distances from `lv` and to an `lv`-normal plane that intersects that molecule.
     */
    public Pair<Map<Integer, Double>, Map<Integer, Plane>> computeAtomDistanceToLongestVectorAndNormalPlanes() {
        List<DPoint3> coords = this.normalizedCoordinates;
        for (int i = 0; i < mol.getAtomCount(); i++) {
            if (i == lvIndex1 || i == lvIndex2) {
                continue;
            }

            DPoint3 diameter = coords.get(lvIndex2);
            DPoint3 exp = coords.get(i);

            Double dotProduct = diameter.x * exp.x + diameter.y * exp.y + diameter.z * exp.z;
            Double lengthProduct = Math.sqrt(diameter.lengthSquare()) * Math.sqrt(exp.lengthSquare());
            Double cosine = dotProduct / lengthProduct;
            Double sine = Math.sqrt(1 - cosine * cosine);
            Double vLength = Math.sqrt(exp.lengthSquare());

            Double perpendicularDist = sine * vLength;

            Double proj = cosine * vLength;

            distancesFromLongestVector.put(i, perpendicularDist);
            distancesAlongLongestVector.put(i, proj);
            normalPlanes.put(i, new Plane(diameter.x, diameter.y, diameter.z, -1d * dotProduct));
        }

        distancesFromLongestVector.put(lvIndex1, 0.0);
        distancesFromLongestVector.put(lvIndex2, 0.0);

        distancesAlongLongestVector.put(lvIndex1, 0.0);
        distancesAlongLongestVector.put(lvIndex2, Math.sqrt(coords.get(lvIndex2).lengthSquare()));

        return Pair.of(distancesFromLongestVector, normalPlanes);
    }

    /**
     * Computes sets of atoms on either side of each `lv`-normal plane defined by each atom.
     * @return A map of atom index to lists of atoms on each side of the atom-incident `lv`-normal plane.
     */
    public Map<Integer, Pair<List<Integer>, List<Integer>>> splitAtomsByNormalPlanes() {
        List<DPoint3> coords = resetOriginForCoordinates(lvIndex1);
        Map<Integer, Pair<List<Integer>, List<Integer>>> results = new HashMap<>();

        for (int i = 0; i < mol.getAtomCount(); i++) {
            Plane p = normalPlanes.get(i);
            if (p == null) {
                continue;
            }

            List<Integer> negSide = new ArrayList<>();
            List<Integer> posSide = new ArrayList<>();

            for (int j = 0; j < mol.getAtomCount(); j++) {
                if (i == j) {
                    continue;
                }
                DPoint3 c = coords.get(j);
                double prod = p.computeProductForPoint(c.x, c.y, c.z);
                // It seems unlikely that an atom would be coplanar to the dividing atom, but who knows.  Throw it in pos if so.
                if (prod < 0.0000d) {
                    negSide.add(j);
                } else {
                    posSide.add(j);
                }
            }
            results.put(i, Pair.of(negSide, posSide));
        }

        return results;
    }

    /**
     * Computes the minimum bounding ball around a list of coordinates.
     * @param coords A list of coordinates whose minimum bounding ball to compute.
     * @return A center and radius of the minimum bounding ball for the specified list of points.
     */
    public Pair<DPoint3, Double> computeMinimumBoundingBall(List<DPoint3> coords) {
        ArrayPointSet aps = new ArrayPointSet(3, coords.size());
        for (int i = 0; i < coords.size(); i++) {
            DPoint3 c = coords.get(i);
            aps.set(i, 0, c.x);
            aps.set(i, 1, c.y);
            aps.set(i, 2, c.z);
        }

        Miniball mb = new Miniball(aps);
        double[] c = mb.center();
        DPoint3 center = new DPoint3(c[0], c[1], c[2]);
        return Pair.of(center, mb.radius());
    }

    /**
     * Contribute the minimum bounding ball for all atoms that contribute the the molecule's logP value.
     * @return A center and raidus for the minimum bounding ball around logP-contributing atoms.
     */
    public Pair<DPoint3, Double> computeMinimumBoundingBallForContributingAtoms() {
        MolAtom[] atoms = mol.getAtomArray();
        List<DPoint3> coords = new ArrayList<>(atoms.length);
        for (int i = 0; i < atoms.length; i++) {
            // Ignore atoms that don't contribute to the logP value (i.e. have a NaN LogP value).
            if (Double.isNaN(plugin.getAtomlogPIncrement(i))) {
                continue;
            }
            coords.add(atoms[i].getLocation());
        }
        return computeMinimumBoundingBall(coords);
    }

    /**
     * Explore the neighborhood within `depths` steps of the atom with the specified atomic index, returning a map of
     * neighboring atomic indices to their step-wise distance from the specified origin atom.
     *
     * @param index The index of the atom whose neighborhood to explore.
     * @param depth The maximum number of steps to take away from the origin atom.
     * @return A map of atomic index to step-wise distance from the specified origin atom.
     */
    public Map<Integer, Integer> exploreNeighborhood(int index, int depth) {
        return exploreNeighborhoodHelper(index, depth, depth, new HashMap<>());
    }

    // Recursively walk the atom's neighborhood.
    private Map<Integer, Integer> exploreNeighborhoodHelper(int index, int baseDepth, int depth,
            Map<Integer, Integer> atomsAndDepths) {
        if (!atomsAndDepths.containsKey(index)) {
            atomsAndDepths.put(index, baseDepth - depth);
        }

        if (depth <= 0) {
            return atomsAndDepths;
        }

        MolAtom d1 = mol.getAtom(index);
        MolBond[] d1bonds = d1.getBondArray();
        for (MolBond b : d1bonds) {
            MolAtom dest;
            if (b.getAtom1().equals(d1)) {
                dest = b.getAtom2();
            } else {
                dest = b.getAtom1();
            }

            int desti = atomToIndexMap.get(dest);

            if (!atomsAndDepths.containsKey(desti)) {
                atomsAndDepths = exploreNeighborhoodHelper(desti, baseDepth, depth - 1, atomsAndDepths);
            }
        }
        return atomsAndDepths;
    }

    public static final Double MIN_AND_MAX_LOG_P_LONGEST_VECTOR_BOOST = 0.00001;

    /**
     * Walk bonds from the lv endpoints and min/max logP atoms, computing stats about their makeup.
     *
     * @return A map of features to numeric values for extreme-neighborhood type attributes (NBH_*).
     */
    public Map<FEATURES, Double> exploreExtremeNeighborhoods() {
        Integer vMax = null, vMin = null;
        double lpMax = 0.0, lpMin = 0.0;
        for (int i = 0; i < mol.getAtomCount(); i++) {
            double lp = plugin.getAtomlogPIncrement(i);
            if (i == lvIndex1 || i == lvIndex2) {
                // Boost the most distant points by a little bit to break ties.
                lp = lp > 0.0 ? lp + MIN_AND_MAX_LOG_P_LONGEST_VECTOR_BOOST
                        : lp - MIN_AND_MAX_LOG_P_LONGEST_VECTOR_BOOST;
            }
            if (vMax == null || lp > lpMax) {
                vMax = i;
                lpMax = lp;
            }

            if (vMin == null || lp < lpMin) {
                vMin = i;
                lpMin = lp;
            }
        }

        maxLogPIndex = vMax;
        minLogPIndex = vMin;

        Map<Integer, Integer> maxNeighborhood = exploreNeighborhood(vMax, 2);
        Map<Integer, Integer> minNeighborhood = exploreNeighborhood(vMin, 2);

        Map<Integer, Integer> v1Neighborhood = exploreNeighborhood(lvIndex1, 2);
        Map<Integer, Integer> v2Neighborhood = exploreNeighborhood(lvIndex2, 2);

        boolean maxAndMinInSimilarNeighborhood = maxNeighborhood.containsKey(vMin);
        boolean maxInV1N = v1Neighborhood.containsKey(vMax);
        boolean maxInV2N = v2Neighborhood.containsKey(vMax);
        boolean minInV1N = v1Neighborhood.containsKey(vMin);
        boolean minInV2N = v2Neighborhood.containsKey(vMin);

        // These odd *_ accumulators are because the vars used in the put() calls for the return value need to be final.
        double maxNSum_ = 0.0;
        int maxNWithPosSign_ = 0;
        for (Integer i : maxNeighborhood.keySet()) {
            double logp = plugin.getAtomlogPIncrement(i);
            maxNSum_ += logp;
            if (logp >= 0.0) {
                maxNWithPosSign_++;
            }
        }
        double maxNSum = maxNSum_;
        double maxNWithPosSign = Integer.valueOf(maxNWithPosSign_).doubleValue();

        double minNSum_ = 0.0;
        int minNWithNegSign_ = 0;
        for (Integer i : minNeighborhood.keySet()) {
            double logp = plugin.getAtomlogPIncrement(i);
            minNSum_ += logp;
            if (logp <= 0.0) {
                minNWithNegSign_++;
            }
        }
        double minNSum = minNSum_;
        double minNWithNegSign = Integer.valueOf(minNWithNegSign_).doubleValue();

        return new HashMap<FEATURES, Double>() {
            {
                put(FEATURES.NBH_MAX_AND_MIN_TOGETHER, maxAndMinInSimilarNeighborhood ? 1.0 : 0);
                put(FEATURES.NBH_MAX_IN_V1, maxInV1N ? 1.0 : 0); // Boolean -> float makes this friendly to downstream analysis.
                put(FEATURES.NBH_MAX_IN_V2, maxInV2N ? 1.0 : 0);
                put(FEATURES.NBH_MIN_IN_V1, minInV1N ? 1.0 : 0);
                put(FEATURES.NBH_MIN_IN_V2, minInV2N ? 1.0 : 0);
                put(FEATURES.NBH_MAX_N_MEAN, maxNSum / Integer.valueOf(maxNeighborhood.size()).doubleValue());
                put(FEATURES.NBH_MIN_N_MEAN, minNSum / Integer.valueOf(maxNeighborhood.size()).doubleValue());
                put(FEATURES.NBH_MAX_POS_RATIO,
                        maxNWithPosSign / Integer.valueOf(maxNeighborhood.size()).doubleValue());
                put(FEATURES.NBH_MIN_NEG_RATIO,
                        minNWithNegSign / Integer.valueOf(minNeighborhood.size()).doubleValue());
            }
        };
    }

    /**
     * Perform linear regression over atoms' projection onto `lv` using their logP contributions as y-axis values.
     *
     * @return The slope of the regression line computed over the `lv`-projection.
     */
    public Double performRegressionOverLVProjectionOfLogP() {
        SimpleRegression regression = new SimpleRegression();
        for (int i = 0; i < mol.getAtomCount(); i++) {
            Double x = distancesAlongLongestVector.get(i);
            Double y = plugin.getAtomlogPIncrement(i);
            regression.addData(x, y);
        }
        regression.regress();
        return regression.getSlope();
    }

    /**
     * Perform linear regression over a list of X/Y coordinates
     * @param coords A set of coordinates over which to perform linear regression.
     * @return The slope and intercept of the regression line.
     */
    public Pair<Double, Double> performRegressionOverXYPairs(List<Pair<Double, Double>> coords) {
        SimpleRegression regression = new SimpleRegression(true);
        for (Pair<Double, Double> c : coords) {
            regression.addData(c.getLeft(), c.getRight());
        }
        // Note: the regress() call can raise an exception for small molecules.  We should probably handle that gracefully.
        RegressionResults result = regression.regress();
        return Pair.of(regression.getSlope(), regression.getIntercept());
    }

    /**
     * Computes plane-split (PS_*_) features for a list of AtomSplit objects, and returns the one that best separates
     * positivie and negative logP-contributing atoms.
     * @param atomSplits A list of atom splits for which to compute features.
     * @return A pair of the best AtomSplit and its features.
     */
    public Pair<AtomSplit, Map<FEATURES, Double>> findBestPlaneSplitFeatures(List<AtomSplit> atomSplits) {
        double bestWeightedLogPDiff = 0.0;
        AtomSplit bestAtomSplit = null;
        Map<FEATURES, Double> features = null;
        // Compute a bunch of metrics for every split, and take the one that best partitions the weighted logP delta.
        for (AtomSplit ps : atomSplits) {
            double absLogPDiff = Math.abs(ps.getLeftSum() - ps.getRightSum());
            double absLogPSignDiff = ps.getLeftSum() * ps.getRightSum() < 0.000 ? 1.0 : 0.0;
            double absLogPMinMaxDiff = Math.max(ps.getLeftMax() - ps.getRightMin(),
                    ps.getRightMax() - ps.getLeftMin());
            double weightedLogPDiff = Math.abs(ps.getWeightedLeftSum() - ps.getWeightedRightSum());
            double weightedLogPSignDiff = ps.getWeightedLeftSum() * ps.getWeightedRightSum() < 0.000 ? 1.0 : 0.0;
            int leftSize = ps.getLeftIndices().size();
            int rightSize = ps.getRightIndices().size();
            double lrSetSizeDiffRatio = Math.abs(Integer.valueOf(leftSize - rightSize).doubleValue()
                    / Integer.valueOf(leftSize + rightSize).doubleValue());
            double sizeWeightedLeftSum = ps.getLeftSum() / Integer.valueOf(Math.max(leftSize, 1)).doubleValue();
            double sizeWeightedRightSum = ps.getRightSum() / Integer.valueOf(Math.max(rightSize, 1)).doubleValue();
            double sizeWeightedLeftWeightedSum = ps.getWeightedLeftSum()
                    / Integer.valueOf(Math.max(leftSize, 1)).doubleValue();
            double sizeWeightedRightWeightedSum = ps.getWeightedRightSum()
                    / Integer.valueOf(Math.max(rightSize, 1)).doubleValue();
            double lrPosNegCountRatio1 = Integer.valueOf(ps.getLeftNegCount()).doubleValue()
                    / Integer.valueOf(Math.max(ps.getRightPosCount(), 1)).doubleValue();
            double lrPosNegCountRatio2 = Integer.valueOf(ps.getRightNegCount()).doubleValue()
                    / Integer.valueOf(Math.max(ps.getLeftPosCount(), 1)).doubleValue();
            double leftPosNegRatio = Integer.valueOf(Math.min(ps.getLeftNegCount(), ps.getLeftPosCount()))
                    .doubleValue()
                    / Integer.valueOf(Math.max(ps.getLeftNegCount(), ps.getLeftPosCount())).doubleValue();
            double rightPosNegRatio = Integer.valueOf(Math.min(ps.getRightNegCount(), ps.getRightPosCount()))
                    .doubleValue()
                    / Integer.valueOf(Math.max(ps.getRightNegCount(), ps.getRightPosCount())).doubleValue();

            if (weightedLogPDiff > bestWeightedLogPDiff) {
                bestWeightedLogPDiff = weightedLogPDiff;
                bestAtomSplit = ps;

                // Store the features while they're computed; seems like it'd be more expensive to recompute than store.
                features = new HashMap<FEATURES, Double>() {
                    {
                        put(FEATURES.PS_LEFT_MEAN_LOGP,
                                ps.getLeftSum() / Integer.valueOf(Math.max(leftSize, 1)).doubleValue());
                        put(FEATURES.PS_RIGHT_MEAN_LOGP,
                                ps.getRightSum() / Integer.valueOf(Math.max(rightSize, 1)).doubleValue());
                        put(FEATURES.PS_LR_SIZE_DIFF_RATIO, lrSetSizeDiffRatio);
                        put(FEATURES.PS_LR_POS_NEG_RATIO_1, lrPosNegCountRatio1);
                        put(FEATURES.PS_LR_POS_NEG_RATIO_2, lrPosNegCountRatio2);
                        put(FEATURES.PS_ABS_LOGP_DIFF, absLogPDiff);
                        put(FEATURES.PS_ABS_LOGP_SIGNS_DIFFER, absLogPSignDiff);
                        put(FEATURES.PS_WEIGHTED_LOGP_DIFF, weightedLogPDiff);
                        put(FEATURES.PS_WEIGHTED_LOGP_SIGNS_DIFFER, weightedLogPSignDiff);
                        put(FEATURES.PS_MAX_ABS_DIFF, absLogPMinMaxDiff);
                        put(FEATURES.PS_LEFT_POS_NEG_RATIO, leftPosNegRatio);
                        put(FEATURES.PS_RIGHT_POS_NEG_RATIO, rightPosNegRatio);
                        // TODO: add surface-contribution-based metrics as well.
                    }
                };
            }
        }
        return Pair.of(bestAtomSplit, features);
    }

    /**
     * Compute features related to the logP-labeled molecular surface computed by MarvinSpace.
     * @param jFrame A jFrame to use when running MarvinSpace (seems strange but is requred).
     * @param hydrogensShareNeighborsLogP Set to true if hydrogen atoms should share their neighbor's logP value.
     * @return A map of features related to and depending on the computed molecular surface.
     * @throws Exception
     */
    public Map<FEATURES, Double> computeSurfaceFeatures(JFrame jFrame, boolean hydrogensShareNeighborsLogP)
            throws Exception {
        // TODO: use the proper marvin sketch scene to get better rendering control instead of MSpaceEasy.
        MSpaceEasy mspace = new MSpaceEasy(1, 2, true);
        mspace.addCanvas(jFrame.getContentPane());
        mspace.setSize(1200, 600);

        ArrayList<Double> logPVals = new ArrayList<>();
        ArrayList<Double> hValues = new ArrayList<>();
        // Store a list of ids so we can label the atoms in the surface rendering (otherwise we won't know what's what).
        ArrayList<Integer> ids = new ArrayList<>();
        MolAtom[] atoms = mol.getAtomArray();
        for (int i = 0; i < mol.getAtomCount(); i++) {
            ids.add(i);
            Double logP = plugin.getAtomlogPIncrement(i);
            logPVals.add(logP);

            /* The surface renderer requires that we specify logP values for all hydrogens, which don't appear to have logP
             * contributions calculated for them, in addition to non-hydrogen atoms.  We fake this by either borrowing the
             * hydrogen's neighbor's logP value, or setting it to 0.0.
             * TODO: figure out what the command-line marvin sketch logP renderer does and do that instead.
             * */
            MolAtom molAtom = mol.getAtom(i);
            for (int j = 0; j < molAtom.getImplicitHcount(); j++) {
                // Note: the logPPlugin's deprecated getAtomlogPHIncrement method just uses the non-H neighbor's logP, as here.
                // msketch seems to do something different, but it's unclear what that is.
                hValues.add(hydrogensShareNeighborsLogP ? logP : 0.0);
            }
        }
        /* Tack the hydrogen's logP contributions on to the list of proper logP values.  The MSC renderer seems to expect
         * the hydrogen's values after the non-hydrogen's values, so appending appears to work fine. */
        logPVals.addAll(hValues);

        // Compute the planes before rendering to avoid the addition of implicit hydrogens in the calculation.
        // TODO: re-strip hydrogens after rendering to avoid these weird issues in general.
        Map<Integer, Pair<List<Integer>, List<Integer>>> splitPlanes = splitAtomsByNormalPlanes();

        MoleculeComponent mc1 = mspace.addMoleculeTo(mol, 0);
        mspace.getEventHandler().createAtomLabels(mc1, ids);

        // Don't draw hydrogens; it makes the drawing too noisy.
        mspace.setProperty("MacroMolecule.Hydrogens", "false");
        MoleculeComponent mc2 = mspace.addMoleculeTo(mol, 1);
        MolecularSurfaceComponent msc = mspace.computeSurface(mc2);
        SurfaceComponent sc = msc.getSurface();

        // Note: if we call mol.getAtomArray() here, it will contain all the implicit hydrogens.
        Map<Integer, Integer> surfaceComponentCounts = new HashMap<>();
        for (int i = 0; i < atoms.length; i++) {
            surfaceComponentCounts.put(i, 0);
        }
        for (int i = 0; i < sc.getVertexCount(); i++) {
            DPoint3 c = new DPoint3(sc.getVertexX(i), sc.getVertexY(i), sc.getVertexZ(i));
            Double closestDist = null;
            Integer closestAtom = null;
            for (int j = 0; j < atoms.length; j++) {
                double dist = c.distance(atoms[j].getLocation());
                if (closestDist == null || closestDist > dist) {
                    closestDist = dist;
                    closestAtom = j;
                }
            }
            surfaceComponentCounts.put(closestAtom, surfaceComponentCounts.get(closestAtom) + 1);
        }

        // Build a line of (proj(p, lv), logP) pairs.
        List<Pair<Double, Double>> weightedVals = new ArrayList<>();
        for (int i = 0; i < atoms.length; i++) {
            Integer count = surfaceComponentCounts.get(i);
            Double logP = plugin.getAtomlogPIncrement(i);
            Double x = distancesAlongLongestVector.get(i);
            Double y = count.doubleValue() * logP;
            // Ditch non-contributing atoms.
            if (y < -0.001 || y > 0.001) {
                weightedVals.add(Pair.of(x, y));
            }
        }
        Collections.sort(weightedVals);

        Pair<Double, Double> slopeIntercept = performRegressionOverXYPairs(weightedVals);
        double valAtFarthestPoint = distancesAlongLongestVector.get(lvIndex2) * slopeIntercept.getLeft()
                + slopeIntercept.getRight();

        Map<FEATURES, Double> features = new HashMap<>();
        features.put(FEATURES.REG_WEIGHTED_SLOPE, slopeIntercept.getLeft());
        features.put(FEATURES.REG_WEIGHTED_INTERCEPT, slopeIntercept.getRight());
        features.put(FEATURES.REG_VAL_AT_FARTHEST_POINT, valAtFarthestPoint);
        /* Multiply the intercept with the value at the largest point to see if there's a sign change.  If so, we'll
         * get a negative number and know the regression line crosses the axis. */
        features.put(FEATURES.REG_CROSSES_X_AXIS,
                valAtFarthestPoint * slopeIntercept.getRight() < 0.000 ? 1.0 : 0.0);

        // Flatten the list of split planes and find the "best" one (i.e. the one that maximizes the weighted logP delta).
        List<AtomSplit> allSplitPlanes = new ArrayList<>();
        for (int i = 0; i < atoms.length; i++) {
            if (!splitPlanes.containsKey(i)) {
                continue;
            }
            Pair<List<Integer>, List<Integer>> splitAtoms = splitPlanes.get(i);
            List<Integer> leftAtoms = splitAtoms.getLeft();
            List<Integer> rightAtoms = splitAtoms.getRight();
            Pair<AtomSplit, AtomSplit> splitVariants = AtomSplit.computePlaneSplitsForIntersectingAtom(leftAtoms,
                    rightAtoms, i, plugin, surfaceComponentCounts);

            AtomSplit l = splitVariants.getLeft();
            AtomSplit r = splitVariants.getRight();
            allSplitPlanes.add(l);
            allSplitPlanes.add(r);
        }
        Pair<AtomSplit, Map<FEATURES, Double>> bestPsRes = findBestPlaneSplitFeatures(allSplitPlanes);
        features.putAll(bestPsRes.getRight());

        msc.setPalette(SurfaceColoring.COLOR_MAPPER_BLUE_TO_RED);
        msc.showVolume(true);
        // These parameters were selected via experimentation.
        msc.setSurfacePrecision("High");
        msc.setSurfaceType("van der Waals");
        msc.setDrawProperty("Surface.DrawType", "Dot");
        msc.setDrawProperty("Surface.Quality", "High");
        msc.setAtomPropertyList(logPVals);
        msc.setDrawProperty("Surface.ColorType", "AtomProperty");

        // Don't display here--leave that to the owner of the JFrame.
        return features;
    }

    public static final double[] SOLUBILITY_PHS = new double[] { 2.5, 3.0, 3.5 };

    /**
     * Calculate whole-molecule fatures used in post-processing and filtering.
     * @return A map of whole-molecule features.
     * @throws Exception
     */
    public Map<FEATURES, Double> calculateAdditionalFilteringFeatures() throws Exception {
        SolubilityCalculator sc = new SolubilityCalculator();
        SolubilityResult[] solubility = sc.calculatePhDependentSolubility(mol, SOLUBILITY_PHS);

        HlbPlugin hlb = HlbPlugin.Builder.createNew();
        hlb.setMolecule(mol);
        hlb.run();
        double hlbVal = hlb.getHlbValue();

        pKaPlugin pka = new pKaPlugin();
        // From the documentation.  Not sure what these knobs do...
        pka.setBasicpKaLowerLimit(-5.0);
        pka.setAcidicpKaUpperLimit(25.0);
        pka.setpHLower(2.5); // for ms distr
        pka.setpHUpper(3.5); // for ms distr
        pka.setpHStep(0.5); // for ms distr
        pka.setMolecule(mol);
        pka.run();

        double[] pkaAcidVals = new double[3];
        int[] pkaAcidIndices = new int[3];

        double[] pkaBasicVals = new double[3];
        int[] pkaBasicIndices = new int[3];

        // Also not sure these are the values we're interested in.
        pka.getMacropKaValues(pKaPlugin.ACIDIC, pkaAcidVals, pkaAcidIndices);
        pka.getMacropKaValues(pKaPlugin.BASIC, pkaBasicVals, pkaBasicIndices);

        // TODO: compute carbon chain length.
        return new HashMap<FEATURES, Double>() {
            {
                put(FEATURES.SOL_MG_ML_25, solubility[0].getSolubility(SolubilityUnit.MGPERML));
                put(FEATURES.SOL_MG_ML_30, solubility[1].getSolubility(SolubilityUnit.MGPERML));
                put(FEATURES.SOL_MG_ML_35, solubility[2].getSolubility(SolubilityUnit.MGPERML));

                put(FEATURES.PKA_ACID_1, pkaAcidVals[0]);
                put(FEATURES.PKA_ACID_1_IDX, Integer.valueOf(pkaAcidIndices[0]).doubleValue());
                put(FEATURES.PKA_ACID_2, pkaAcidVals[1]);
                put(FEATURES.PKA_ACID_2_IDX, Integer.valueOf(pkaAcidIndices[1]).doubleValue());
                put(FEATURES.PKA_ACID_3, pkaAcidVals[2]);
                put(FEATURES.PKA_ACID_3_IDX, Integer.valueOf(pkaAcidIndices[2]).doubleValue());

                put(FEATURES.PKA_BASE_1, pkaBasicVals[0]);
                put(FEATURES.PKA_BASE_1_IDX, Integer.valueOf(pkaBasicIndices[0]).doubleValue());
                put(FEATURES.PKA_BASE_2, pkaBasicVals[1]);
                put(FEATURES.PKA_BASE_2_IDX, Integer.valueOf(pkaBasicIndices[1]).doubleValue());
                put(FEATURES.PKA_BASE_3, pkaBasicVals[2]);
                put(FEATURES.PKA_BASE_3_IDX, Integer.valueOf(pkaBasicIndices[2]).doubleValue());

                put(FEATURES.HLB_VAL, hlbVal);
            }
        };
    }

    public String getInchi() {
        return inchi;
    }

    public logPPlugin getPlugin() {
        return plugin;
    }

    public Molecule getMol() {
        return mol;
    }

    public Map<MolAtom, Integer> getAtomToIndexMap() {
        return atomToIndexMap;
    }

    public Integer getLvIndex1() {
        return lvIndex1;
    }

    public Integer getLvIndex2() {
        return lvIndex2;
    }

    public List<DPoint3> getNormalizedCoordinates() {
        return normalizedCoordinates;
    }

    public MajorMicrospeciesPlugin getMicrospeciesPlugin() {
        return microspeciesPlugin;
    }

    public Map<Integer, Double> getDistancesFromLongestVector() {
        return distancesFromLongestVector;
    }

    public Map<Integer, Double> getDistancesAlongLongestVector() {
        return distancesAlongLongestVector;
    }

    public Map<Integer, Plane> getNormalPlanes() {
        return normalPlanes;
    }

    // TODO: add greedy high/low logP neighborhood picking, compute bounding balls, and calc intersection (spherical cap).
    // TODO: restructure this class to make the analysis steps more modular (now they're coupled to surface computation).
    /**
     * Perform all analysis for a molecule, returning a map of all available features.
     * @param inchi The molecule to analyze.
     * @param display True if the molecule should be displayed; set to false for non-interactive analysis.
     * @return A map of all features for this molecule.
     * @throws Exception
     */
    public static Map<FEATURES, Double> performAnalysis(String inchi, boolean display) throws Exception {
        SurfactantAnalysis surfactantAnalysis = new SurfactantAnalysis();
        surfactantAnalysis.init(inchi);

        // Start with simple structural analyses.
        Pair<Integer, Integer> farthestAtoms = surfactantAnalysis.findFarthestContributingAtomPair();
        Double longestVectorLength = surfactantAnalysis.computeDistance(farthestAtoms.getLeft(),
                farthestAtoms.getRight());

        // Then compute the atom distances to the longest vector (lv) and produce lv-normal planes at each atom.
        Pair<Map<Integer, Double>, Map<Integer, Plane>> results = surfactantAnalysis
                .computeAtomDistanceToLongestVectorAndNormalPlanes();
        // Find the max distance so we can calculate the maxDist/|lv| ratio, or "skinny" factor.
        double maxDistToLongestVector = 0.0;
        Map<Integer, Double> distancesToLongestVector = results.getLeft();
        for (Map.Entry<Integer, Double> e : distancesToLongestVector.entrySet()) {
            maxDistToLongestVector = Math.max(maxDistToLongestVector, e.getValue());
        }

        // A map of the molecule features we'll eventually output.
        Map<FEATURES, Double> features = new HashMap<>();

        // Explore the lv endpoint and min/max logP atom neighborhoods, and merge those features into the complete map.
        Map<FEATURES, Double> neighborhoodFeatures = surfactantAnalysis.exploreExtremeNeighborhoods();
        features.putAll(neighborhoodFeatures);

        /* Perform regression analysis on the projection of the molecules onto lv, where their y-axis is their logP value.
         * Higher |slope| may mean more extreme logP differences at the ends. */
        Double slope = surfactantAnalysis.performRegressionOverLVProjectionOfLogP();

        /* Compute the logP surface of the molecule (seems to require a JFrame?), and collect those features.  We consider
         * the number of closest surface components to each atom so we can guess at how much interior atoms actually
         * contribute to the molecule's solubility. */
        JFrame jFrame = new JFrame();
        jFrame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        Map<FEATURES, Double> surfaceFeatures = surfactantAnalysis.computeSurfaceFeatures(jFrame, true);
        features.putAll(surfaceFeatures);

        features.put(FEATURES.LOGP_TRUE, surfactantAnalysis.plugin.getlogPTrue()); // Save absolute logP since we calculated it.
        features.put(FEATURES.GEO_LV_FD_RATIO, maxDistToLongestVector / longestVectorLength);
        features.put(FEATURES.REG_ABS_SLOPE, slope);

        Map<FEATURES, Double> additionalFeatures = surfactantAnalysis.calculateAdditionalFilteringFeatures();
        features.putAll(additionalFeatures);

        List<FEATURES> sortedFeatures = new ArrayList<>(features.keySet());
        Collections.sort(sortedFeatures);

        // Print these for easier progress tracking.
        System.out.format("features:\n");
        for (FEATURES f : sortedFeatures) {
            System.out.format("  %s = %f\n", f, features.get(f));
        }

        if (display) {
            jFrame.pack();
            jFrame.setVisible(true);
        }

        return features;
    }
}