diff --git a/CHANGELOG b/CHANGELOG
index e83fc8600d486f04ba3a8f6cd0de7f3468855d4d..24a36a9d5d65a22fa5a7a8c4227e63608e3a4936 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,14 @@
 Changelog
 ================================================================================
 
+
+Release 3.x.x
+--------------------------------------------------------------------------------
+
+* Check for and report non-planar rings in the modelling result. This check is
+  also incorporated in energy minimization, potentially increasing the number
+  of minimization steps.
+
 Release 3.2.1
 --------------------------------------------------------------------------------
 
diff --git a/modelling/doc/model_checking.rst b/modelling/doc/model_checking.rst
index ab5b5eb9eec534e44b685fb09571c30ce77ed2a7..400f3737f7389d157300c6bce938acf6abc68353 100644
--- a/modelling/doc/model_checking.rst
+++ b/modelling/doc/model_checking.rst
@@ -35,6 +35,15 @@ Detecting Ring Punches
 
 .. autofunction:: FilterCandidatesWithSC
 
+
+Detecting Non-Planar Rings
+--------------------------------------------------------------------------------
+
+.. autofunction:: GetNonPlanarRings
+
+.. autofunction:: HasNonPlanarRings
+
+
 Model Checking With MolProbity
 --------------------------------------------------------------------------------
 
diff --git a/modelling/pymod/CMakeLists.txt b/modelling/pymod/CMakeLists.txt
index 58e2665c98f6d6ac1e81901a956547ddaa3a2836..c58f17c9c50a9433ad97832afc00e1677541dbcc 100644
--- a/modelling/pymod/CMakeLists.txt
+++ b/modelling/pymod/CMakeLists.txt
@@ -27,6 +27,7 @@ set(MODELLING_PYMOD
   _mhandle_helper.py
   _alignment_fiddling.py
   _raw_model.py
+  _planar_rings.py
 )
 
 pymod(NAME modelling
diff --git a/modelling/pymod/__init__.py b/modelling/pymod/__init__.py
index 6cf9be125390e77c9307fdff5c346c56a2f540c0..a0eb62b66c12c597a411d19967d500d3f75918b5 100644
--- a/modelling/pymod/__init__.py
+++ b/modelling/pymod/__init__.py
@@ -27,3 +27,4 @@ from ._fragger_handle import *
 from ._monte_carlo import *
 from ._mhandle_helper import *
 from ._raw_model import *
+from ._planar_rings import *
diff --git a/modelling/pymod/_pipeline.py b/modelling/pymod/_pipeline.py
index a4b5e8709578a0a1b79831442c2d8c93e22dc0e9..8b79f214120d3c6bf4d345cdd223f2e7f5f33bac 100644
--- a/modelling/pymod/_pipeline.py
+++ b/modelling/pymod/_pipeline.py
@@ -25,6 +25,7 @@ from ._modelling import *
 from ._reconstruct_sidechains import *
 from ._closegaps import *
 from ._ring_punches import *
+from ._planar_rings import *
 from ._mhandle_helper import *
 # external
 import ost
@@ -363,8 +364,9 @@ def MinimizeModelEnergy(mhandle, max_iterations=12, max_iter_sd=20,
             ost.PopVerbosityLevel()
         # checks above would remove bad atoms
         cur_energy = sim.GetEnergy()
-        if len(temp_ent_stereo_checked.Select("ele!=H").atoms) \
-           == len(temp_ent.Select("ele!=H").atoms):
+        n_checked = len(temp_ent_stereo_checked.Select("ele!=H").atoms)
+        n_tot = len(temp_ent.Select("ele!=H").atoms)
+        if n_checked == n_tot and HasNonPlanarRings(temp_ent) == False:
             ost.LogInfo("No more stereo-chemical problems "
                         "-> final energy: %g" % cur_energy)
             break
@@ -396,6 +398,7 @@ def CheckFinalModel(mhandle):
     - Remaining stereo-chemical problems after energy minimization. The affected
       residues will have the boolean property "stereo_chemical_problem_backbone"
       set to True, if the problem affects backbone atoms.
+    - Residues that contain non-planar rings.
     
     :param mhandle: Modelling handle for which to perform checks.
     :type mhandle:  :class:`ModellingHandle`
@@ -477,6 +480,13 @@ def CheckFinalModel(mhandle):
             ost.LogInfo(msg)
             AddModellingIssue(mhandle, msg, ModellingIssue.Severity.MINOR, [res])
 
+    # report non-planar rings
+    non_planar_rings = GetNonPlanarRings(mhandle.model)
+    for res in non_planar_rings:
+        msg = "Ring of " + str(res) + " is non-planar!"
+        ost.LogWarning(msg)
+        AddModellingIssue(mhandle, msg, ModellingIssue.Severity.MINOR, [res])
+
 def BuildFromRawModel(mhandle, use_amber_ff=False, extra_force_fields=list(),
                       model_termini=False):
     '''Build a model starting with a raw model (see :func:`BuildRawModel`).
diff --git a/modelling/pymod/_planar_rings.py b/modelling/pymod/_planar_rings.py
new file mode 100644
index 0000000000000000000000000000000000000000..dcb4c28771200f0092be647dcfb7daf5d8a28d49
--- /dev/null
+++ b/modelling/pymod/_planar_rings.py
@@ -0,0 +1,113 @@
+# Copyright (c) 2013-2020, SIB - Swiss Institute of Bioinformatics and
+#                          Biozentrum - University of Basel
+# 
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+# 
+#   http://www.apache.org/licenses/LICENSE-2.0
+# 
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+'''Helper functions to deal with non-planar rings'''
+import ost
+from ost import geom
+
+from promod3 import core
+
+def _GetMaxDist(res):
+    """Get max distance of any atom from ring plane
+
+    None is returned if res.one_letter_code is not in  ['Y', 'F', 'W', 'H']
+    or when not all expected ring atoms are present
+
+    :param res:  Residue for which you want to get the max dist to ring plane
+    :type res: :class:`~ost.mol.ResidueHandle` or :class:`~ost.mol.ResidueView`
+    :returns: Max distance of any ring atom to optimal ring plane or None if
+              residue contains no ring or not all expected ring atoms are
+              present.
+    """
+    prof = core.StaticRuntimeProfiler.StartScoped("planar_rings::GetMaxDist")
+    if res.one_letter_code in "YF":
+        atom_names = ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"]
+    elif res.one_letter_code == 'W':
+        atom_names = ["CG", "CD1", "NE1", "CD2", "CE2", "CE3", "CZ2", "CZ3",
+                      "CH2"]
+    elif res.one_letter_code == 'H':
+        atom_names = ["CG", "CD2", "ND1", "CE1", "NE2"]
+    else:
+        return None
+    positions = geom.Vec3List()
+    for atom_name in atom_names:
+        a = res.FindAtom(atom_name)
+        if a.IsValid():
+            positions.append(a.GetPos())
+        else:
+            return None
+    origin=positions.center
+    normal=positions.principal_axes.GetRow(0)
+    plane = geom.Plane(origin, normal)
+    return max([abs(geom.Distance(plane, pos)) for pos in positions])
+
+def GetNonPlanarRings(ent, max_dist_thresh = 0.1):
+    '''Get list of residues with rings that non-planar in the given structure.
+
+    Only residues with res.one_letter_code in ['Y', 'F', 'W', 'H'] that contain
+    all expected ring atoms are considered.
+
+    Planarity is defined by the maximum distance of any ring atom to the optimal
+    plane describing the ring. This plane is constructed by defining a point on
+    the plane, here the geometric center of the ring atoms and a normal. We
+    construct an orthogonal basis [e1, e2, e3]. e1 points in direction with
+    lowest variance of ring atoms and e3 in direction with highest variance.
+    e1 is thus the plane normal. Internally this is done using a singular value
+    decomposition algorithm.
+
+    To put the default threshold of 0.1 in perspective: if you calculate these
+    distances on the same non-redundant set of experimental structures as
+    |project| used to derive its rotamer libraries, 99.9 % of the residues are
+    within 0.065 (HIS), 0.075 (TRP), 0.057 (TYR), 0.060 (PHE).
+
+    :param ent: Structure for which to detect non-planar rings.
+    :type ent:  :class:`~ost.mol.EntityHandle` or :class:`~ost.mol.EntityView`
+    :param max_dist_thresh: A residue that contains a ring is considered
+                            non-planar if the max distance of any ring-atom to
+                            the optimal ring plane is above this threshold.
+    :type max_dist_thresh: :class:`float`
+
+    :return: :class:`list` of residues (:class:`~ost.mol.ResidueHandle`/
+             :class:`~ost.mol.ResidueView`) which have a non-planar ring.
+    '''
+    prof_name = 'ring_punches::GetNonPlanarRings'
+    prof = core.StaticRuntimeProfiler.StartScoped(prof_name)
+    dists = [_GetMaxDist(res) for res in ent.residues]
+    mdt = max_dist_thresh
+    return [r for d, r in zip(dists, ent.residues) if d is not None and d > mdt]
+
+def HasNonPlanarRings(ent, max_dist_thresh = 0.1):
+    '''Check if any ring is non-planar in the given structure.
+
+    Calls :func:`GetNonPlanarRings` with given parameters and returns if any
+    residue is considered non-planar.
+
+    :param ent: Structure for which to detect non-planar rings
+    :type ent:  :class:`~ost.mol.EntityHandle` or :class:`~ost.mol.EntityView`
+    :param max_dist_thresh: A residue that contains a ring is considered
+                            non-planar if the max distance of any ring-atom to
+                            the optimal ring plane is above this threshold.
+    :type max_dist_thresh: :class:`float`
+
+
+    :return: True, if any ring is non-planar
+    :rtype:  :class:`bool`
+    '''
+    prof_name = 'ring_punches::HasNonPlanarRings'
+    prof = core.StaticRuntimeProfiler.StartScoped(prof_name)
+    return len(GetNonPlanarRings(ent, max_dist_thresh)) > 0
+
+# these methods will be exported into module
+__all__ = ('GetNonPlanarRings', 'HasNonPlanarRings')