diff --git a/src/Algorithms/ParallelCyclotronTracker.cpp b/src/Algorithms/ParallelCyclotronTracker.cpp
index 19fb8f4e08c520a91ed2d4ab060436abb87dc13e..015d6e4ce3134107ae0dc09eaf615e18bd6806ae 100644
--- a/src/Algorithms/ParallelCyclotronTracker.cpp
+++ b/src/Algorithms/ParallelCyclotronTracker.cpp
@@ -41,6 +41,7 @@
 #include "AbsBeamline/MultipoleTCurvedVarRadius.h"
 #include "AbsBeamline/MultipoleTStraight.h"
 #include "AbsBeamline/Offset.h"
+#include "AbsBeamline/OutputPlane.h"
 #include "AbsBeamline/PluginElement.h"
 #include "AbsBeamline/Probe.h"
 #include "AbsBeamline/RBend.h"
@@ -670,6 +671,37 @@ void ParallelCyclotronTracker::visitMultipoleTCurvedVarRadius(const MultipoleTCu
     myElements.push_back(dynamic_cast<MultipoleTCurvedVarRadius*>(multTvcurv.clone()));
 }
 
+
+/**
+ *
+ *
+ * @param plane
+ */
+void ParallelCyclotronTracker::visitOutputPlane(const OutputPlane& plane) {
+    if (opalRing_m == nullptr) {
+        throw OpalException("ParallelCylcotronTracker::visitOutputPlane",
+                            "Attempt to place an OutputPlane when Ring not defined");
+    }
+
+    OutputPlane* elptr = dynamic_cast<OutputPlane*>(plane.clone());
+    elptr->setGlobalFieldMap(opalRing_m);
+    myElements.push_back(elptr);
+    elptr->initialise(itsBunch_m);
+
+    double BcParameter[8] = {};
+
+    Vector_t centre = elptr->getCentre();
+    Vector_t norm = elptr->getNormal();
+    double width = elptr->getHorizontalExtent();
+    BcParameter[0] = centre[0]-width*norm[1];
+    BcParameter[1] = centre[0]+width*norm[1];
+    BcParameter[2] = centre[1]-width*norm[0];
+    BcParameter[3] = centre[1]+width*norm[0];
+    BcParameter[4] = Units::mm2m; // thickness, not used
+    buildupFieldList(BcParameter, ElementType::OUTPUTPLANE, elptr);
+}
+
+
 /**
  *
  *
@@ -1173,6 +1205,7 @@ void ParallelCyclotronTracker::execute() {
             CavityCrossData ccd = {cav, cav->getSinAzimuth(), cav->getCosAzimuth(), cav->getPerpenDistance()};
             cavCrossDatas_m.push_back(ccd);
         } else if ( type == ElementType::CCOLLIMATOR ||
+                    type == ElementType::OUTPUTPLANE ||
                     type == ElementType::PROBE       ||
                     type == ElementType::SEPTUM      ||
                     type == ElementType::STRIPPER) {
diff --git a/src/Algorithms/ParallelCyclotronTracker.h b/src/Algorithms/ParallelCyclotronTracker.h
index a338d90310ca7589b776227565e8b07cd1a68d2b..cd1a6580ddc4dfc79496edecaf3f2b9f8c01c9d0 100644
--- a/src/Algorithms/ParallelCyclotronTracker.h
+++ b/src/Algorithms/ParallelCyclotronTracker.h
@@ -143,6 +143,9 @@ public:
     /// Apply the algorithm to a offset (placement).
     virtual void visitOffset(const Offset&);
 
+    /// Apply the algorithm to a outputplane.
+    virtual void visitOutputPlane(const OutputPlane&);
+
     /// Apply the algorithm to a probe.
     virtual void visitProbe(const Probe&);
 
diff --git a/src/Classic/AbsBeamline/BeamlineVisitor.h b/src/Classic/AbsBeamline/BeamlineVisitor.h
index d56131f916a97ce7375c4c4f612dfa8def75b4b2..2eaed611f25d3f29ae2f6211ca007532b14b59cd 100644
--- a/src/Classic/AbsBeamline/BeamlineVisitor.h
+++ b/src/Classic/AbsBeamline/BeamlineVisitor.h
@@ -55,6 +55,7 @@ class MultipoleTStraight;
 class MultipoleTCurvedConstRadius;
 class MultipoleTCurvedVarRadius;
 class Offset;
+class OutputPlane;
 class Probe;
 class RBend;
 class RBend3D;
@@ -136,7 +137,10 @@ public:
 
     /// Apply the algorithm to an offset (placement).
     virtual void visitOffset(const Offset &) = 0;
-    
+
+    /// Apply the algorithm to an outputplane.
+    virtual void visitOutputPlane(const OutputPlane &) = 0;
+
     /// Apply the algorithm to a probe.
     virtual void visitProbe(const Probe &) = 0;
 
diff --git a/src/Classic/AbsBeamline/CMakeLists.txt b/src/Classic/AbsBeamline/CMakeLists.txt
index 4f30c20d974799cfaf3a9c935f20b3ffa87032a6..46509618ef7f494a1b4788840ad35832be06af7d 100644
--- a/src/Classic/AbsBeamline/CMakeLists.txt
+++ b/src/Classic/AbsBeamline/CMakeLists.txt
@@ -23,6 +23,7 @@ set (_SRCS
     MultipoleTCurvedConstRadius.cpp
     MultipoleTCurvedVarRadius.cpp
     Offset.cpp
+    OutputPlane.cpp
     PluginElement.cpp
     Probe.cpp
     RBend.cpp
@@ -65,6 +66,7 @@ set (HDRS
     MultipoleTCurvedConstRadius.h
     MultipoleTCurvedVarRadius.h
     Offset.h
+    OutputPlane.h
     PluginElement.h
     Probe.h
     RBend.h
diff --git a/src/Classic/AbsBeamline/ElementBase.cpp b/src/Classic/AbsBeamline/ElementBase.cpp
index 08e40dcf069f1b2d4e404111806d0f2cec6e3f00..869d23628c5e76d5f562b5e2836d64e0de75fa68 100644
--- a/src/Classic/AbsBeamline/ElementBase.cpp
+++ b/src/Classic/AbsBeamline/ElementBase.cpp
@@ -85,6 +85,7 @@ const std::map<ElementType, std::string> ElementBase::elementTypeToString_s = {
     {ElementType::MULTIPOLE,          "Multipole"},
     {ElementType::MULTIPOLET,         "MultipoleT"},
     {ElementType::OFFSET,             "Offset"},
+    {ElementType::OUTPUTPLANE,        "OutputPlane"},
     {ElementType::PROBE,              "Probe"},
     {ElementType::RBEND,              "RBend"},
     {ElementType::RBEND3D,            "RBend3D"},
diff --git a/src/Classic/AbsBeamline/ElementBase.h b/src/Classic/AbsBeamline/ElementBase.h
index 498bf8470e52d9b9e596fdc20f35e9b00b90fd15..10f1774305cf6f1e459ebbdb9db6e7e80c8dae12 100644
--- a/src/Classic/AbsBeamline/ElementBase.h
+++ b/src/Classic/AbsBeamline/ElementBase.h
@@ -100,6 +100,7 @@ enum class ElementType: unsigned short {
     MULTIPOLE,
     MULTIPOLET,
     OFFSET,
+    OUTPUTPLANE,
     PROBE,
     RBEND,
     RBEND3D,
diff --git a/src/Classic/AbsBeamline/MultipoleT.cpp b/src/Classic/AbsBeamline/MultipoleT.cpp
index 0d47f805bf4c997056a3dfb14ac41e893cea25e5..81f9390760dd63eb797b3c1fe087688bb91aafd3 100644
--- a/src/Classic/AbsBeamline/MultipoleT.cpp
+++ b/src/Classic/AbsBeamline/MultipoleT.cpp
@@ -1,6 +1,7 @@
 /*
  *  Copyright (c) 2017, Titus Dascalu
  *  Copyright (c) 2018, Martin Duy Tat
+ *  Copyright (c) 2019-2023, Chris Rogers
  *  All rights reserved.
  *  Redistribution and use in source and binary forms, with or without
  *  modification, are permitted provided that the following conditions are met:
diff --git a/src/Classic/AbsBeamline/OutputPlane.cpp b/src/Classic/AbsBeamline/OutputPlane.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..90e8d056a42a2876369ff2a4502e995321db3d01
--- /dev/null
+++ b/src/Classic/AbsBeamline/OutputPlane.cpp
@@ -0,0 +1,322 @@
+// Copyright (c) 2023, Chris Rogers
+// All rights reserved
+//
+// This file is part of OPAL.
+//
+// OPAL 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.
+//
+// You should have received a copy of the GNU General Public License
+// along with OPAL. If not, see <https://www.gnu.org/licenses/>.
+//
+
+#include "AbsBeamline/OutputPlane.h"
+
+#include "AbsBeamline/BeamlineVisitor.h"
+#include "Algorithms/PartBunchBase.h"
+#include "Physics/Physics.h"
+#include "Structure/LossDataSink.h"
+#include "Physics/Units.h"
+#include "Utilities/GeneralClassicException.h"
+
+extern Inform *gmsg;
+
+OutputPlane::OutputPlane() : OutputPlane("")
+{}
+
+OutputPlane::OutputPlane(const std::string& name):
+    PluginElement(name),
+    maxIterations_m(10),
+    algorithm_m(algorithm::INTERPOLATION),
+    verbose_m(0) {
+}
+
+OutputPlane::OutputPlane(const OutputPlane& right):
+    PluginElement(right),
+    maxIterations_m(10) {
+
+    field_m = right.field_m;
+    normal_m = right.normal_m;
+    centre_m = right.centre_m;
+    radialExtent_m = right.radialExtent_m;
+    verticalExtent_m = right.verticalExtent_m;
+    horizontalExtent_m = right.horizontalExtent_m;
+    maxIterations_m = right.maxIterations_m;
+    tolerance_m = right.tolerance_m;
+    nullfield_m = right.nullfield_m;
+    geom_m = right.geom_m;
+    recentre_m = right.recentre_m;
+    algorithm_m = right.algorithm_m;
+    verbose_m = right.verbose_m;
+}
+
+OutputPlane::~OutputPlane()
+{}
+
+ElementBase* OutputPlane::clone() const {
+    ElementBase* element = dynamic_cast<ElementBase*>(new OutputPlane(*this));
+    return element;
+}
+
+void OutputPlane::accept(BeamlineVisitor& visitor) const {
+    visitor.visitOutputPlane(*this);
+}
+
+void OutputPlane::doInitialise(PartBunchBase<double, 3>* /*bunch*/) {
+    //void OutputPlane::doInitialise() {
+    *gmsg << "* Initialize OutputPlane at " << centre_m << " with normal " << normal_m << "\n*";
+    if (radialExtent_m >= 0) {
+        *gmsg << " Radial extent " << radialExtent_m;
+    }
+    if (horizontalExtent_m >= 0) {
+        *gmsg << " Horizontal extent " << horizontalExtent_m;
+    }
+    if (verticalExtent_m >= 0) {
+        *gmsg << " Vertical extent " << verticalExtent_m;
+    }
+    if (field_m != nullptr) {
+        *gmsg << " Using field map " << field_m->getName();
+    } else {
+        *gmsg << " Using empty field map";
+    }
+    if (recentre_m >= 0) {
+        *gmsg << " Recentre event " << recentre_m;
+    }
+    *gmsg << endl;
+}
+
+void OutputPlane::doGoOffline() {
+    *gmsg << "* OutputPlane goes offline " << getName() << endl;
+}
+
+void OutputPlane::RK4Step(
+                     const double& tStep,
+                     const double& chargeToMass,
+                     const double& t,
+                     Vector_t& R1,
+                     Vector_t& P1) const {
+    double thalfStep = tStep / 2.;
+    double tPlusHalf = t + thalfStep;
+    double tPlusStep = t + tStep;
+    // f = dy/dt
+    // f1 = f(x,t)
+    double  deriv1[6];
+
+    getDerivatives(R1, P1, t, chargeToMass, deriv1);
+    // f2 = f(x+dt*f1/2, t+dt/2 )
+    double  deriv2[6];
+    Vector_t R2(R1[0] + thalfStep * deriv1[0], R1[1] + thalfStep * deriv1[1], R1[2] + thalfStep * deriv1[2]);
+    Vector_t P2(P1[0] + thalfStep * deriv1[3], P1[1] + thalfStep * deriv1[4], P1[2] + thalfStep * deriv1[5]);
+    getDerivatives(R2, P2, tPlusHalf, chargeToMass, deriv2);
+
+    // f3 = f( x+dt*f2/2, t+dt/2 )
+    double  deriv3[6];
+    Vector_t R3(R1[0] + thalfStep * deriv2[0], R1[1] + thalfStep * deriv2[1], R1[2] + thalfStep * deriv2[2]);
+    Vector_t P3(P1[0] + thalfStep * deriv2[3], P1[1] + thalfStep * deriv2[4], P1[2] + thalfStep * deriv2[5]);
+    getDerivatives(R3, P3, tPlusHalf, chargeToMass, deriv3);
+
+    // f4 = f(x+dt*f3, t+dt ).
+    double  deriv4[6];
+    Vector_t R4(R1[0] + tStep * deriv3[0], R1[1] + tStep * deriv3[1], R1[2] + tStep * deriv3[2]);
+    Vector_t P4(P1[0] + tStep * deriv3[3], P1[1] + tStep * deriv3[4], P1[2] + tStep * deriv3[5]);
+    getDerivatives(R4, P4, tPlusStep, chargeToMass, deriv4);
+
+    // Return x(t+dt) computed from fourth-order R-K.
+    R1[0] += (deriv1[0] + deriv4[0] + 2. * (deriv2[0] + deriv3[0])) * tStep / 6.;
+    R1[1] += (deriv1[1] + deriv4[1] + 2. * (deriv2[1] + deriv3[1])) * tStep / 6.;
+    R1[2] += (deriv1[2] + deriv4[2] + 2. * (deriv2[2] + deriv3[2])) * tStep / 6.;
+    P1[0] += (deriv1[3] + deriv4[3] + 2. * (deriv2[3] + deriv3[3])) * tStep / 6.;
+    P1[1] += (deriv1[4] + deriv4[4] + 2. * (deriv2[4] + deriv3[4])) * tStep / 6.;
+    P1[2] += (deriv1[5] + deriv4[5] + 2. * (deriv2[5] + deriv3[5])) * tStep / 6.;
+}
+
+void OutputPlane::getDerivatives(const Vector_t& R,
+                            const Vector_t& P,
+                            const double& t,
+                            const double& chargeToMass,
+                            double* yp) const {
+
+    double gamma = std::sqrt(1 + (P[0] * P[0] + P[1] * P[1] + P[2] * P[2]));
+
+    Vector_t beta = P / gamma;
+    double betax = beta[0];
+    double betay = beta[1];
+    double betaz = beta[2];
+
+    yp[0] = Physics::c * betax;
+    yp[1] = Physics::c * betay;
+    yp[2] = Physics::c * betaz;
+
+    if (field_m == nullptr) {
+        throw GeneralClassicException("OutputPlane::getDerivatives",
+                                      "Field was null");
+    }
+    Vector_t externalB, externalE;
+    field_m->apply(R, P, t, externalE, externalB);
+    //double kiloGaussToTesla = 0.1;
+    externalB *= Units::kG2T;
+    externalE *= Units::kV2V / Units::mm2m / Physics::c;
+
+    yp[3] = chargeToMass * (externalB(2) * betay - externalB(1) * betaz+externalE(0));
+    yp[4] = chargeToMass * (externalB(0) * betaz - externalB(2) * betax + externalE(1));
+    yp[5] = chargeToMass * (externalB(1) * betax - externalB(0) * betay + externalE(2));
+}
+
+bool OutputPlane::checkOne(const int index, const double tstep, double chargeToMass, 
+                           double& t, Vector_t& R, Vector_t& P) {
+
+    // distance from particle to the output plane
+    // time units are ns
+    Vector_t delta = R-centre_m;
+    double distance = dot(normal_m, delta);
+
+    // maximum step, assuming no curvature, rough guess for relativistic beta
+    double betaEstimate = euclidean_norm(P);
+    if (betaEstimate > 1) {
+        betaEstimate = 1.0;
+    }
+    double sStep = tstep*betaEstimate*Physics::c;
+    if (verbose_m > 3) {
+        *gmsg << "* First check crossing of plane " << getName() << " at " << centre_m << " with normal " << normal_m << endl;
+        *gmsg << "    Particle " << index << " with R " << R << " P " << P << " t0 " << t << " tstep " << tstep << endl;
+        *gmsg << "    Distance prestep " << distance << " compared to s step length " << sStep << endl;
+    }
+    if (std::abs(distance) > sStep) {
+        // we can't cross the plane
+        return false;
+    }
+
+    Vector_t rTest(R);
+    Vector_t pTest(P);
+    RK4Step(tstep, chargeToMass, t, rTest, pTest);
+    double distanceTest = dot(normal_m, (rTest-centre_m));
+    if (verbose_m > 2) {
+        *gmsg << "* Second check crossing of plane " << getName() << " at " << centre_m << " with normal " << normal_m << endl;
+        *gmsg << "    Particle " << index << " with R " << R << " P " << P << " tstep " << tstep << endl;
+        *gmsg << "    After RK4 " << rTest << " " << pTest << endl;
+        *gmsg << "    Step distance " << distanceTest << " compared to " << distance << endl;
+    }
+
+    if (distance != 0 && distanceTest/distance > 0) {
+        // step does not cross the plane - give up
+        // note that particle could cross and cross back the plane in a single
+        // time-step; in this case the particle is not registered
+        return false;
+    }
+
+    if (algorithm_m == algorithm::RK4STEP) {
+        rk4Test(tstep, chargeToMass, t, R, P);
+    } else if (algorithm_m == algorithm::INTERPOLATION) {
+        interpolation(t, R, P);
+    }
+
+    delta = R-centre_m;
+    if (verbose_m > 1) {
+        *gmsg << "* Track estimate RK4? " << bool(algorithm_m == algorithm::RK4STEP)
+              << " INTERPOLATION? " << bool(algorithm_m == algorithm::INTERPOLATION)
+              << " R " << R << " P " << P << " t " << t << endl
+              << " delta " << delta << endl;
+    }
+    if (horizontalExtent_m > 0 && delta[0] * delta[0] + delta[1] * delta[1] > horizontalExtent_m*horizontalExtent_m) {
+        // out of horizontal extent. Again, defined in global coordinates
+        return false;
+    }
+    if (verticalExtent_m > 0 && abs(delta[2]) > verticalExtent_m) {
+        // out of vertical extent; note that this is defined in global coordinates
+        // not in the coordinates of the plane
+        return false;
+    }
+    if (radialExtent_m > 0 &&
+        delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2] > radialExtent_m * radialExtent_m) {
+        // out of radial extent
+        return false;
+    }
+    if (recentre_m == index) {
+        recentre(R, P);
+        *gmsg << "* Recentred output plane to " << centre_m
+            << " with normal " << normal_m << " by event " << index << endl;
+    }
+    if (verbose_m > 0) {
+        *gmsg << "* Found track" << endl;
+    }
+    return true;
+}
+
+void OutputPlane::rk4Test(double tstep, double chargeToMass,
+                          double& t, Vector_t& R, Vector_t& P) {
+    double preStepDistance = 0.0;
+    double postStepDistance = dot(normal_m, (R-centre_m));
+    size_t iteration = 0;
+    while (std::abs(postStepDistance) > tolerance_m && iteration < maxIterations_m) {
+        preStepDistance = postStepDistance;
+        RK4Step(tstep, chargeToMass, t, R, P); // this updates R and P
+        if (verbose_m > 2) {
+            *gmsg << "    RK4 iteration " << iteration << " step distance " << preStepDistance << " R " << R << " P " << P
+                  << " centre " << centre_m << " d: " << R-centre_m << " t: " << t << " dt: " << tstep << endl;
+            Vector_t externalB, externalE;
+            field_m->apply(R, P, t, externalE, externalB);
+            *gmsg << "        B " << externalB << " [kG] E " << externalE << " [MV/m] " << endl;
+        }
+        t += tstep;
+        postStepDistance = dot(normal_m, (R - centre_m));
+        if (postStepDistance/preStepDistance < 0) { // straddling the plane
+            // we stepped too far; step in opposite direction
+            // step length in ratio of distance to plane
+            tstep *= -abs(postStepDistance)/abs(postStepDistance - preStepDistance);
+        } else {
+            // we didn't step far enough; step in same direction
+            tstep *= abs(postStepDistance)/abs(postStepDistance - preStepDistance);
+        }
+        iteration++;
+    }
+}
+
+void OutputPlane::interpolation(double& t, Vector_t& R, Vector_t& P) {
+    // trajectory R = R0 + V dt 
+    // plane = (X-X0).n
+    // intersection time t0 = (X0-R0).N / (V.N)
+    // intersection position = R0 + V dt
+    // relativistic gamma:
+    double gamma = std::sqrt(1 + P[0] * P[0] + P[1] * P[1] + P[2] * P[2]);
+    Vector_t velocity = P/gamma*Physics::c; // m/ns
+    double dt = dot((centre_m-R), normal_m) / dot(velocity, normal_m);
+    R += velocity*dt;
+    t += dt;
+}
+
+
+bool OutputPlane::doCheck(PartBunchBase<double, 3> *bunch, const int turnnumber,
+                          const double t, const double tstep) {
+    size_t tempnum = bunch->getLocalNum();
+    for(unsigned int i = 0; i < tempnum; ++i) {
+        if (verbose_m > 2) {
+            *gmsg << "OutputPlane checking at time " << t
+                  << " turn number " << turnnumber << " track id " << i << endl;
+        }
+        Vector_t R(bunch->R[i]);
+        Vector_t P(bunch->P[i]);
+        double t0(t);
+        double chargeToMass = bunch->Q[i] / Physics::q_e * Physics::c * Physics::c
+                             / bunch->M[i]/Units::GeV2eV; // electron charge cancels
+        bool crossing = checkOne(i, tstep, chargeToMass, t0, R, P);
+        if (crossing && lossDs_m) {
+            nHits_m += 1;
+            lossDs_m->addParticle(OpalParticle(bunch->ID[i], R, P,
+                                               t0, bunch->Q[i], bunch->M[i]),
+                                  std::make_pair(turnnumber, bunch->bunchNum[i]));
+        }
+    }
+    return false;
+}
+
+void OutputPlane::recentre(Vector_t R, Vector_t P) {
+    setCentre(R);
+    setNormal(P);
+    recentre_m = -1; // don't recentre again
+}
+
+ElementType OutputPlane::getType() const {
+    return ElementType::OUTPUTPLANE;
+}
diff --git a/src/Classic/AbsBeamline/OutputPlane.h b/src/Classic/AbsBeamline/OutputPlane.h
new file mode 100644
index 0000000000000000000000000000000000000000..28bc5a87d9c2768d232fc45e8b219a82e5e0f8f3
--- /dev/null
+++ b/src/Classic/AbsBeamline/OutputPlane.h
@@ -0,0 +1,357 @@
+// Copyright (c) 2023, Chris Rogers
+// All rights reserved
+//
+// This file is part of OPAL.
+//
+// OPAL 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.
+//
+// You should have received a copy of the GNU General Public License
+// along with OPAL. If not, see <https://www.gnu.org/licenses/>.
+//
+
+#ifndef CLASSIC_OutputPlane_HH
+#define CLASSIC_OutputPlane_HH
+
+#include "AbsBeamline/PluginElement.h"
+#include "BeamlineGeometry/StraightGeometry.h"
+#include "Fields/NullField.h"
+#include "Steppers/Steppers.h"
+#include "Steppers/RK4.h"
+
+#include <string>
+
+class Component;
+
+/** Class OutputPlane
+ *  ------------------------------------------------------------------------
+ *  Class OutputPlane defines an output plane, which enables output of tracking
+ *  data at an arbitrary spatial plane. The crossing of the plane in a given time
+ *  step is detected by (1) checking if the step is within c*dt of the plane and
+ *  then (2) checking if an RK4 step crosses the plane. 
+ * 
+ *  If the track crosses, then we estimate the position of the track either by
+ *  doing a linear interpolation between the initial position and the position
+ *  of the test step; OR we do an iterative RK4 step, reducing the RK4 step size
+ *  each time until we find crossing position to some tolerance or the maximum
+ *  number of iterations is reached.
+ * 
+ *  Additional features - we can recentre the plane on a particular track (for
+ *  example, one might seek to make a plane perpendicular to a reference 
+ *  trajectory); we can limit the extent of the plane so only particles crossing
+ *  within a certain distance are registered.
+ * 
+ *  BUG: if the "Global" Opal tracking is not RK4, then there is a small 
+ *  possibility that the "Global" track and the OutputPlane step will be not
+ *  perfectly aligned; tracks could register crossing twice or not crossing at
+ *  all. An improved algorithm would be to use the "global" stepper or make some
+ *  hack to consider e.g. two steps.
+ */
+
+class OutputPlane: public PluginElement {
+
+public:
+    enum class algorithm {RK4STEP, INTERPOLATION};
+
+    /** Constructor with given name. */
+    explicit OutputPlane(const std::string& name);
+
+    /** Default constructor (initialise to empty */
+    OutputPlane();
+
+    /** Copy constructor */
+    OutputPlane(const OutputPlane&);
+
+    /** Destructor */
+    virtual ~OutputPlane();
+
+    /** Inheritable copy operation */
+    ElementBase* clone() const override;
+    
+    /** Apply visitor to output plane. */
+    virtual void accept(BeamlineVisitor&) const override;
+
+    /** Get the field map */
+    inline Component* getGlobalFieldMap() const;
+    /** Set the field map */
+    inline void setGlobalFieldMap(Component* field);
+
+    /** Get the normal to the plane */
+    inline Vector_t getNormal() const;
+    /** Set the normal to the plane */
+    inline void setNormal(Vector_t normal);
+
+    /** Get the centre of the plane */
+    inline Vector_t getCentre() const;
+    /** Set the centre of the plane */
+    inline void setCentre(Vector_t centre);
+
+    /** Get the tolerance, used when finding intersection with the plane */
+    inline double getTolerance() const;
+    /** Set the tolerance, used when finding intersection with the plane */
+    inline void setTolerance(double tolerance);
+
+    /** Get the maximum allowed iteratiosn when finding intersection */
+    inline size_t getMaxIterations() const;
+    /** Set the maximum allowed iteratiosn when finding intersection */
+    inline void setMaxIterations(size_t max);
+
+    /** Return the index of the recentring particle*/
+    inline int getRecentre() const;
+    /** Set the index of the recentring particle*/
+    inline void setRecentre(int willRecentre);
+
+    /** Get the radial extent from the plane centre */
+    inline double getRadialExtent() const;
+    /** Set the radial extent from the centre */
+    inline void setRadialExtent(double r);
+
+    /** Get the vertical extent from the plane centre
+     */
+    inline double getVerticalExtent() const;
+    /** Set the vertical extent from the plane centre */
+    inline void setVerticalExtent(double z);
+
+    /** Get the horizontal extent from the plane centre
+     */
+    inline double getHorizontalExtent() const;
+    /** Set the horizontal extent from the plane centre */
+    inline void setHorizontalExtent(double width);
+
+    /** Get the algorithm used to interpolate from step to the plane */
+    inline algorithm getAlgorithm() const;
+    /** Set the algorithm used to interpolate from step to the plane */
+    inline void setAlgorithm(algorithm alg);
+
+    /** Get the horizontal extent from the plane centre
+     */
+    inline int getVerboseLevel() const;
+    /** Set the verbose level
+     *
+     *  set to 0 for silent running; 1 when hit detected;
+     *  2 when second check passes; 3 when first check passes;
+     *  4 to output every step
+     **/
+    inline void setVerboseLevel(int verbose);
+
+    /** Check for plane crossing. If crossed fill t, R, P with the intercept.
+     *
+     *  @li index: Particle number of the particle crossing
+     *  @li tstep: Time step used by the global tracking
+     *  @li chargeToMass: charge to mass ratio of the particle, in units of
+     *                    (positron charge)/GeV/c^2
+     *  @li t: time of the initial particle [s]. Unchanged if no crossing is
+     *         found, else filled with the estimated time of the particle at the
+     *         plane crossing point.
+     *  @li R: position three-vector of the initial particle [m]. Unchanged if 
+     *         no crossing is found, else filled with the estimated position of
+     *         the particle at the plane crossing point.
+     *  @li P: (gamma beta) three-vector of the initial particle [].
+     *         Unchanged if no crossing is found, else filled with the estimated
+     *         position of the particle at the plane crossing point.
+     *
+     *  The routine checks for crossing by performing a full RK4 step using the
+     *  user-supplied time step. The distance to the plane is calculated. If the
+     *  distance changes sign, the particle is assumed to cross; if the distance
+     *  does not change sign, the particle is assumed to stay on the same side.
+     *  Note that particles that step across the boundary and back again will
+     *  not be registered as a crossing.
+     * 
+     *  The intercept is found by iteratively doing RK4 steps. Successive step
+     *  sizes are found by linear interpolation based on the distance from the 
+     *  plane and the time step. I could use the Steppers/RK4.h routines but
+     *  these automatically update the global particle information and I don't
+     *  want to do that, I just want a local stepping.
+     *
+     *  If recentre_m is positive then centre_m is set to the position of 
+     *  the n^th crossing particle and normal_m is set to be parallel to the 
+     *  momentum vector. recentre_m is set to -1. The idea is to generate
+     *  output planes in the coordinate system of some trajectory (e.g. for
+     *  studying beam transport/transfer matrix). Normally this will be the
+     *  reference trajectory but it is possible to make it another trajectory if
+     *  required.
+     *
+     *  If radialExtent_m is > 0, crossings more than radialExtent_m from the
+     *  centre are ignored. If horizontalExtent_m is > 0, crossings more than
+     *  horizontalExtent_m from the centre, in global x-y plane, are ignored. If
+     *  verticalExtent_m is > 0, crossings more than verticalExtent_m in 
+     *  direction parallel to global z axis, are ignored.
+     *
+     *  @returns True if the particle crosses the plane; else false.
+     */
+    bool checkOne(const int index, const double tstep, double chargeToMass,
+                  double& t, Vector_t& R, Vector_t& P);
+
+    /** Returns empty field */
+    NullField& getField() {return nullfield_m;}
+    /** Returns empty field */
+    const NullField& getField() const {return nullfield_m;}
+
+    /** Returns empty geometry */
+    StraightGeometry& getGeometry() {return geom_m;}
+    /** Returns empty geometry */
+    const StraightGeometry& getGeometry() const {return geom_m;}
+    /** Make an RK4Step
+     *  @li tstep: time step [s]
+     *  @li chargeToMass: chargeToMass ratio [m^2/s^2/GV]
+     *  @li t: t [s]
+     *  @li R: R [m]
+     *  @li P: beta gamma [] - note dimensionless
+     */
+    void RK4Step(const double& tstep,
+                 const double& chargeToMass,
+                 const double& t,
+                 Vector_t& R,
+                 Vector_t& P) const;
+
+    void operator=(const OutputPlane&) = delete;
+    
+    //ElementBase::ElementType getType() const;
+    ElementType getType() const;
+    void recentre(Vector_t R, Vector_t P);
+
+
+private:
+    /// Initialise peakfinder file
+    virtual void doInitialise(PartBunchBase<double, 3>* /*bunch*/);
+
+    /// Record probe hits when bunch particles pass
+    inline bool doPreCheck(PartBunchBase<double, 3> *bunch) override;
+
+    /// Record probe hits when bunch particles pass
+    virtual bool doCheck(PartBunchBase<double, 3> *bunch,
+                         const int turnnumber, 
+                         const double t, 
+                         const double tstep) override;
+    /// Hook for goOffline
+    virtual void doGoOffline() override;
+    void getDerivatives(const Vector_t& R,
+                        const Vector_t& P,
+                        const double& t,
+                        const double& chargeToMass,
+                        double* yp) const;
+    double distanceToPlane(Vector_t point) const;
+    void rk4Test(double tstep, double chargeToMass,
+                 double& t, Vector_t& R, Vector_t& P);
+    void interpolation(double& t, Vector_t& R, Vector_t& P);
+    bool getFieldsAtPoint(const Vector_t& R, const Vector_t& P, const double& t, Vector_t& Efield, Vector_t& Bfield);
+    
+    Component* field_m = NULL; // field map - this is a borrowed pointer
+    Vector_t normal_m; // normal to the output plane
+    Vector_t centre_m; // centre of the output plane
+    double radialExtent_m = -1.0; // maximum radial extent of the plane (circular)
+    double verticalExtent_m = -1.0; // maximum vertical extent of the plane (rectangular)
+    double horizontalExtent_m = -1.0; // maximum horizontal extent of the plane (rectangular)
+    size_t maxIterations_m = 10; // maximum number of iterations when finding intercept
+    double tolerance_m = 1e-9; // tolerance on distance from plane when finding intercept
+    NullField nullfield_m; // dummy variable for inheritance
+    StraightGeometry geom_m; // dummy variable for inheritance
+    algorithm algorithm_m; // sets interpolation or RK4
+    int recentre_m = -1; // particle index for recentring
+    int nHits_m = 0; // counter for number of hits on the plane
+    int verbose_m = 0; // verbosity
+
+    typedef std::function<bool(const double&,
+                               const size_t&,
+                               Vector_t&,
+                               Vector_t&)> function_t;
+    std::unique_ptr<Stepper<function_t> > stepper_m;
+};
+
+Component* OutputPlane::getGlobalFieldMap() const {
+    return field_m;
+}
+
+void OutputPlane::setGlobalFieldMap(Component* field) {
+    field_m = field;
+}
+
+Vector_t OutputPlane::getNormal() const {
+    return normal_m;
+}
+
+void OutputPlane::setNormal(Vector_t normal) {
+    normal_m = normal;
+    normal_m /= euclidean_norm(normal);
+}
+
+Vector_t OutputPlane::getCentre() const {
+    return centre_m;
+}
+
+void OutputPlane::setCentre(Vector_t centre) {
+    centre_m = centre;
+}
+
+double OutputPlane::getTolerance() const {
+    return tolerance_m;
+}
+
+void OutputPlane::setTolerance(double tolerance) {
+    tolerance_m = tolerance;
+}
+
+size_t OutputPlane::getMaxIterations() const {
+    return maxIterations_m;
+}
+
+void OutputPlane::setMaxIterations(size_t max) {
+    maxIterations_m = max;
+}
+
+double OutputPlane::getHorizontalExtent() const {
+    return horizontalExtent_m;
+}
+
+void OutputPlane::setHorizontalExtent(double width) {
+    horizontalExtent_m = width;
+}
+
+double OutputPlane::getVerticalExtent() const {
+    return verticalExtent_m;
+}
+
+void OutputPlane::setVerticalExtent(double width) {
+    verticalExtent_m = width;
+}
+
+double OutputPlane::getRadialExtent() const {
+    return radialExtent_m;
+}
+
+void OutputPlane::setRadialExtent(double radius) {
+    radialExtent_m = radius;
+}
+
+int OutputPlane::getRecentre() const {
+    return recentre_m;
+}
+
+void OutputPlane::setRecentre(int recentre) {
+    recentre_m = recentre;
+}
+
+OutputPlane::algorithm OutputPlane::getAlgorithm() const {
+    return algorithm_m;
+}
+
+void OutputPlane::setAlgorithm(OutputPlane::algorithm alg) {
+    algorithm_m = alg;
+}
+
+bool OutputPlane::doPreCheck(PartBunchBase<double, 3>* /*bunch*/) {
+    return true;
+}
+
+int OutputPlane::getVerboseLevel() const {
+    return verbose_m;
+}
+
+void OutputPlane::setVerboseLevel(int verbose) {
+    verbose_m = verbose;
+}
+
+
+#endif // CLASSIC_OutputPlane_HH
diff --git a/src/Classic/AbsBeamline/SpecificElementVisitor.h b/src/Classic/AbsBeamline/SpecificElementVisitor.h
index 1d01a43c95709c78cea7cd4b0d084b2513de9dc2..b9dd50d4c8c7798d3a4b911de4d12a0720445f76 100644
--- a/src/Classic/AbsBeamline/SpecificElementVisitor.h
+++ b/src/Classic/AbsBeamline/SpecificElementVisitor.h
@@ -36,6 +36,7 @@
 #include "AbsBeamline/MultipoleTStraight.h"
 #include "AbsBeamline/MultipoleTCurvedConstRadius.h"
 #include "AbsBeamline/MultipoleTCurvedVarRadius.h"
+#include "AbsBeamline/OutputPlane.h"
 #include "AbsBeamline/Probe.h"
 #include "AbsBeamline/RBend.h"
 #include "AbsBeamline/Ring.h"
@@ -136,6 +137,9 @@ public:
     /// Apply the algorithm to an offset (placement).
     virtual void visitOffset(const Offset &);
 
+    /// Apply the algorithm to an output plane.
+    virtual void visitOutputPlane(const OutputPlane &);
+
     /// Apply the algorithm to a probe.
     virtual void visitProbe(const Probe &prob);
 
@@ -314,6 +318,11 @@ void SpecificElementVisitor<ELEM>::visitOffset(const Offset &element) {
     CastsTrait<ELEM, Offset>::apply(allElementsOfTypeE, element);
 }
 
+template<class ELEM>
+void SpecificElementVisitor<ELEM>::visitOutputPlane(const OutputPlane &element) {
+    CastsTrait<ELEM, OutputPlane>::apply(allElementsOfTypeE, element);
+}
+
 template<class ELEM>
 void SpecificElementVisitor<ELEM>::visitProbe(const Probe &element) {
     CastsTrait<ELEM, Probe>::apply(allElementsOfTypeE, element);
diff --git a/src/Classic/Algorithms/DefaultVisitor.cpp b/src/Classic/Algorithms/DefaultVisitor.cpp
index ddc6f79207833c0274a9b846f5a7f371d1990296..f6c36aa6ec5f4d240602d76c77c7344a4fc23160 100644
--- a/src/Classic/Algorithms/DefaultVisitor.cpp
+++ b/src/Classic/Algorithms/DefaultVisitor.cpp
@@ -37,6 +37,7 @@
 #include "AbsBeamline/MultipoleTCurvedVarRadius.h"
 #include "AbsBeamline/MultipoleTStraight.h"
 #include "AbsBeamline/Offset.h"
+#include "AbsBeamline/OutputPlane.h"
 #include "AbsBeamline/Probe.h"
 #include "AbsBeamline/RBend.h"
 #include "AbsBeamline/RBend3D.h"
@@ -144,6 +145,10 @@ void DefaultVisitor::visitOffset(const Offset& off) {
     applyDefault(off);
 }
 
+void DefaultVisitor::visitOutputPlane(const OutputPlane& out) {
+    applyDefault(out);
+}
+
 void DefaultVisitor::visitProbe(const Probe &probe) {
     applyDefault(probe);
 }
diff --git a/src/Classic/Algorithms/DefaultVisitor.h b/src/Classic/Algorithms/DefaultVisitor.h
index 94fbe608526eefb7a33bfc89a065411308ac56ed..0f8e82d32be4e3b13416c991fd11b900431c7fbf 100644
--- a/src/Classic/Algorithms/DefaultVisitor.h
+++ b/src/Classic/Algorithms/DefaultVisitor.h
@@ -96,6 +96,9 @@ public:
     /// Apply the algorithm to an offset (placement).
     virtual void visitOffset(const Offset &);
 
+    /// Apply the algorithm to an output plane.
+    virtual void visitOutputPlane(const OutputPlane &);
+
     /// Apply the algorithm to a probe.
     virtual void visitProbe(const Probe &prob);
 
diff --git a/src/Elements/CMakeLists.txt b/src/Elements/CMakeLists.txt
index 4874230ae30c4ea125ada726fd5d14e37b4e7c75..f83f37b73c7d87fc278148c6b2553f09fe456676 100644
--- a/src/Elements/CMakeLists.txt
+++ b/src/Elements/CMakeLists.txt
@@ -25,6 +25,7 @@ set (_SRCS
     OpalOffset/OpalLocalCartesianOffset.cpp
     OpalOffset/OpalGlobalCylindricalOffset.cpp
     OpalOffset/OpalGlobalCartesianOffset.cpp
+    OpalOutputPlane.cpp
     OpalProbe.cpp
     OpalPepperPot.cpp
     OpalPolynomialTimeDependence.cpp
diff --git a/src/Elements/OpalOutputPlane.cpp b/src/Elements/OpalOutputPlane.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..da90c7fbb041a3a330adb54ae41226dd602ea303
--- /dev/null
+++ b/src/Elements/OpalOutputPlane.cpp
@@ -0,0 +1,174 @@
+// Copyright (c) 2023, Chris Rogers
+// All rights reserved
+//
+// This file is part of OPAL.
+//
+// OPAL 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.
+//
+// You should have received a copy of the GNU General Public License
+// along with OPAL. If not, see <https://www.gnu.org/licenses/>.
+//
+
+#include "Elements/OpalOutputPlane.h"
+#include "Classic/AbsBeamline/OutputPlane.h"
+#include "AbstractObjects/Attribute.h"
+#include "Attributes/Attributes.h"
+#include "Utilities/OpalException.h"
+
+const std::string docstring =
+    std::string("The \"OUTPUTPLANE\" element writes out position at which")+
+    std::string("trajectories cross a given plane.");
+
+OpalOutputPlane::OpalOutputPlane():
+    OpalElement(SIZE, "OUTPUTPLANE", docstring.c_str()) {
+
+    itsAttr[CENTRE] = Attributes::makeRealArray
+                      ("CENTRE", "3-vector position of the plane centre [m]");
+    itsAttr[NORMAL] = Attributes::makeRealArray
+                      ("NORMAL", "3-vector normal to the plane");
+    itsAttr[REFERENCE_ALIGNMENT_PARTICLE] = Attributes::makeReal
+                      ("REFERENCE_ALIGNMENT_PARTICLE",
+                       "Set to a particle number (usually 0, the reference particle). "
+                       "The first time that the particle crosses the reference plane, then "
+                       "the plane will be moved to centre on that particle and point in S of the particle.", -1);
+    itsAttr[TOLERANCE] = Attributes::makeReal
+                      ("TOLERANCE", "Tolerance on position of track intercept [m]", 1e-6);
+    itsAttr[WIDTH] = Attributes::makeReal
+                      ("WIDTH", "Full width of the output plane [m], defined in the lab coordinate system (*not* the output plane coordinate system).");
+    itsAttr[HEIGHT] = Attributes::makeReal
+                      ("HEIGHT", "Full height of the output plane [m], defined in the lab coordinate system (*not* the output plane coordinate system)");
+    itsAttr[RADIUS] = Attributes::makeReal
+                      ("RADIUS", "Maximum distance from centre of plane for crossings [m].");
+    itsAttr[ALGORITHM] = Attributes::makePredefinedString
+                      ("ALGORITHM",
+                       "The algorithm used to step from the track point to the plane", {"INTERPOLATION", "RK4"}, "RK4");
+    itsAttr[XSTART] = Attributes::makeReal
+                      ("XSTART", "Define a plane with horizontal extent [m] from XSTART to XEND and vertical extent from YSTART to YEND", 0.0);
+    itsAttr[YSTART] = Attributes::makeReal
+                      ("YSTART", "Define a plane with horizontal extent [m] from XSTART to XEND and vertical extent from YSTART to YEND", 1.0);
+    itsAttr[XEND] = Attributes::makeReal
+                      ("XEND", "Define a plane with horizontal extent [m] from XSTART to XEND and vertical extent from YSTART to YEND", 0.0);
+    itsAttr[YEND] = Attributes::makeReal
+                      ("YEND", "Define a plane with horizontal extent [m] from XSTART to XEND and vertical extent from YSTART to YEND", 0.0);
+
+    itsAttr[PLACEMENT_STYLE] = Attributes::makePredefinedString("PLACEMENT_STYLE",
+                     "Set to PROBE to define the plane using XSTART, XEND, YSTART, YEND or CENTRE_NORMAL to define the plane using centre and normal", {"CENTRE_NORMAL", "PROBE"}, "PROBE");
+    itsAttr[VERBOSE] = Attributes::makeReal("VERBOSE",
+                     "Set to 0 for minimal output up to 4 to output diagnostics on every track step. Output is sent to OPAL", 0);
+    registerOwnership();
+    setElement(new OutputPlane("OUTPUTPLANE"));
+}
+
+
+OpalOutputPlane::OpalOutputPlane(const std::string &name, OpalOutputPlane *parent):
+    OpalElement(name, parent) {
+    OutputPlane* plane = dynamic_cast<OutputPlane*>(parent->getElement());
+    setElement(new OutputPlane(*plane));
+}
+
+
+OpalOutputPlane::~OpalOutputPlane() {
+}
+
+
+OpalOutputPlane *OpalOutputPlane::clone(const std::string &name) {
+    return new OpalOutputPlane(name, this);
+}
+
+void OpalOutputPlane::update() {
+    OpalElement::update();
+    std::string placementStyle = Attributes::getString(itsAttr[PLACEMENT_STYLE]);
+    OutputPlane *output = dynamic_cast<OutputPlane *>(getElement());
+    Vector_t centre;
+    Vector_t normal;
+    double tolerance = Attributes::getReal(itsAttr[TOLERANCE]);
+    output->setTolerance(tolerance);
+
+    if (placementStyle == "CENTRE_NORMAL") {
+        std::vector<double> centreVec = Attributes::getRealArray(itsAttr[CENTRE]);
+        if (centreVec.size() != 3) {
+            throw OpalException("OpalOutputPlane::update()",
+                "OutputPlane centre should be a 3-vector");
+        }
+        centre = Vector_t(centreVec[0], centreVec[1], centreVec[2]);
+        std::vector<double> normalVec = Attributes::getRealArray(itsAttr[NORMAL]);
+        if (normalVec.size() != 3) {
+            throw OpalException("OpalOutputPlane::update()",
+                "OutputPlane normal should be a 3-vector");
+        }
+        normal = Vector_t(normalVec[0], normalVec[1], normalVec[2]);
+        if (euclidean_norm(normal) < tolerance) {
+            throw OpalException("OpalOutputPlane::update()",
+                "OutputPlane normal was not defined or almost zero");
+        }
+    } else if (placementStyle == "PROBE") {
+        centre = Vector_t(
+            Attributes::getReal(itsAttr[XSTART])+Attributes::getReal(itsAttr[XEND]),
+            Attributes::getReal(itsAttr[YSTART])+Attributes::getReal(itsAttr[YEND]),
+            0.0
+        );
+        centre *= 0.5; // average
+        normal = Vector_t(
+            Attributes::getReal(itsAttr[YSTART])-Attributes::getReal(itsAttr[YEND]),
+            Attributes::getReal(itsAttr[XEND])-Attributes::getReal(itsAttr[XSTART]),
+            0.0
+        );
+        double width = std::sqrt(normal[0]*normal[0]+normal[1]*normal[1]);
+        if (width < tolerance) {
+            throw OpalException("OpalOutputPlane::update()",
+                "OutputPlane had very small width or size was not defined");
+        }
+        output->setHorizontalExtent(width/2.0);
+    }
+    output->setCentre(centre);
+    output->setNormal(normal);
+
+
+    if (itsAttr[WIDTH]) {
+        double width = Attributes::getReal(itsAttr[WIDTH]);
+        if (width < 0) {
+            throw OpalException("OpalOutputPlane::update()",
+                "OutputPlane had negative width");
+        }
+        output->setHorizontalExtent(width/2.0);
+    }
+    if (itsAttr[HEIGHT]) {
+        double height = Attributes::getReal(itsAttr[HEIGHT]);
+        if (height < 0) {
+            throw OpalException("OpalOutputPlane::update()",
+                "OutputPlane had negative height");
+        }
+        output->setVerticalExtent(height/2.0);
+    }
+    if (itsAttr[RADIUS]) {
+        double radius = Attributes::getReal(itsAttr[RADIUS]);
+        if (radius < 0) {
+            throw OpalException("OpalOutputPlane::update()",
+                "OutputPlane had negative radius");
+        }
+        output->setRadialExtent(radius);
+    }
+
+    if (itsAttr[REFERENCE_ALIGNMENT_PARTICLE]) {
+        double ref = Attributes::getReal(itsAttr[REFERENCE_ALIGNMENT_PARTICLE]);
+        int refAlign = std::floor(ref + 0.5);
+        if (refAlign >= 0) { // disabled if negative
+            output->setRecentre(refAlign);
+        }
+    } else {
+        output->setRecentre(-1);
+    }
+    std::string algorithm = Attributes::getString(itsAttr[ALGORITHM]);
+    if (algorithm == "RK4") {
+        output->setAlgorithm(OutputPlane::algorithm::RK4STEP);
+    } else if (algorithm == "INTERPOLATION") {
+        output->setAlgorithm(OutputPlane::algorithm::INTERPOLATION);
+    }
+    output->setVerboseLevel(Attributes::getReal(itsAttr[VERBOSE]));
+    output->setOutputFN(Attributes::getString(itsAttr[OUTFN]));
+    // Transmit "unknown" attributes.
+    OpalElement::updateUnknown(output);
+}
\ No newline at end of file
diff --git a/src/Elements/OpalOutputPlane.h b/src/Elements/OpalOutputPlane.h
new file mode 100644
index 0000000000000000000000000000000000000000..15b6bf6a337e5c0f7537a78f3aa74421becfe546
--- /dev/null
+++ b/src/Elements/OpalOutputPlane.h
@@ -0,0 +1,75 @@
+// Copyright (c) 2023, Chris Rogers
+// All rights reserved
+//
+// This file is part of OPAL.
+//
+// OPAL 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.
+//
+// You should have received a copy of the GNU General Public License
+// along with OPAL. If not, see <https://www.gnu.org/licenses/>.
+//
+
+#ifndef OPAL_OpalOutputPlane_H
+#define OPAL_OpalOutputPlane_H
+
+#include "Elements/OpalElement.h"
+
+// Class OpalOutputPlane
+// ------------------------------------------------------------------------
+/// Interface for output plane element.
+
+
+class OpalOutputPlane: public OpalElement {
+
+public:
+
+    /// The attributes of class OpalOutputPlane.
+    enum {
+        CENTRE = COMMON, // plane centre position (3-vector)
+        NORMAL,          // plane normal (3-vector)
+        WIDTH,           // maximum allowed horizontal displacement from centre, in global x-y plane
+        HEIGHT,          // maximum allowed vertical displacement from centre, in global vertical direction
+        RADIUS,          // maximum allowed radial displacement from centre
+
+        XSTART,            // "PROBE"-style placement - horizontal start position
+        XEND,              // "PROBE"-style placement - horizontal end position
+        YSTART,            // "PROBE"-style placement - vertical start position
+        YEND,              // "PROBE"-style placement - vertical end position
+
+        PLACEMENT_STYLE,   // "PROBE" or "CENTRE_NORMAL"
+
+        ALGORITHM,         // algorithm used to find crossing-point
+        TOLERANCE,         // tolerance on position estimate
+        REFERENCE_ALIGNMENT_PARTICLE, // if true, centres on reference particle
+        VERBOSE,           // set to 0 - 4 to get more verbose output
+
+        SIZE
+    };
+    /// Exemplar constructor.
+    OpalOutputPlane();
+
+    virtual ~OpalOutputPlane();
+
+    /// Make clone.
+    virtual OpalOutputPlane *clone(const std::string &name);
+
+    /// Fill in all registered attributes.
+    //virtual void fillRegisteredAttributes(const ElementBase &, ValueFlag);
+
+    /// Update the embedded CLASSIC septum.
+    virtual void update();
+
+private:
+
+    // Not implemented.
+    OpalOutputPlane(const OpalOutputPlane &);
+    void operator=(const OpalOutputPlane &);
+
+    // Clone constructor.
+    OpalOutputPlane(const std::string &name, OpalOutputPlane *parent);
+};
+
+#endif // OPAL_OpalOutputPlane_H
diff --git a/src/OpalConfigure/Configure.cpp b/src/OpalConfigure/Configure.cpp
index f1d19cb055ab2d57bffb5ed317a0dbdaf6887433..74bc38b353509c1edb91e72f3618586ca66e33c8 100644
--- a/src/OpalConfigure/Configure.cpp
+++ b/src/OpalConfigure/Configure.cpp
@@ -89,6 +89,7 @@
 #include "Elements/OpalOffset/OpalLocalCylindricalOffset.h"
 #include "Elements/OpalOffset/OpalGlobalCartesianOffset.h"
 #include "Elements/OpalOffset/OpalGlobalCylindricalOffset.h"
+#include "Elements/OpalOutputPlane.h"
 #include "Elements/OpalPepperPot.h"
 #include "Elements/OpalPolynomialTimeDependence.h"
 #include "Elements/OpalProbe.h"
@@ -212,6 +213,7 @@ namespace {
 //        opal->create(new OpalOffset::OpalLocalCylindricalOffset());
 //        opal->create(new OpalOffset::OpalGlobalCartesianOffset());
 //        opal->create(new OpalOffset::OpalGlobalCylindricalOffset());
+        opal->create(new OpalOutputPlane());
         opal->create(new OpalPepperPot());
         opal->create(new OpalPolynomialTimeDependence());
         opal->create(new OpalProbe());
diff --git a/src/PyOpal/PyElements/CMakeLists.txt b/src/PyOpal/PyElements/CMakeLists.txt
index 5796f9e418abd600ac7b49350b0819e35b1b00dc..a8888e3de0f295f30d31ab2fcca08b3d2ef4d77e 100644
--- a/src/PyOpal/PyElements/CMakeLists.txt
+++ b/src/PyOpal/PyElements/CMakeLists.txt
@@ -38,3 +38,6 @@ python_module(variable_rf_cavity_fringe_field "${VARIABLERFCAVITYFRINGEFIELD_SRC
 
 set (POLYNOMIALTIMEDEPENDENCE_SRCS PyPolynomialTimeDependence.cpp $<TARGET_OBJECTS:libPyOPALPyCore>)
 python_module(polynomial_time_dependence "${POLYNOMIALTIMEDEPENDENCE_SRCS}" "elements")
+
+set (OUTPUTPLANE_SRCS PyOutputPlane.cpp $<TARGET_OBJECTS:libPyOPALPyCore>)
+python_module(output_plane "${OUTPUTPLANE_SRCS}" "elements")
diff --git a/src/PyOpal/PyElements/PyOutputPlane.cpp b/src/PyOpal/PyElements/PyOutputPlane.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..ff963db4ac3a67fcac98906139040c2ab544dadf
--- /dev/null
+++ b/src/PyOpal/PyElements/PyOutputPlane.cpp
@@ -0,0 +1,58 @@
+// Copyright (c) 2023, Chris Rogers
+// All rights reserved
+//
+// This file is part of OPAL.
+//
+// OPAL 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.
+//
+// You should have received a copy of the GNU General Public License
+// along with OPAL. If not, see <https://www.gnu.org/licenses/>.
+//
+
+#include "PyOpal/PyCore/ExceptionTranslation.h"
+#include "PyOpal/PyCore/PyOpalObject.h"
+#include "PyOpal/PyCore/Globals.h"
+
+#include "Elements/OpalOutputPlane.h"
+
+namespace PyOpal {
+namespace PyOutputPlane {
+
+const char* module_docstring = 
+    "output_plane contains the OutputPlane class";
+
+template <>
+std::vector<PyOpalObjectNS::AttributeDef> PyOpalObjectNS::PyOpalObject<OpalOutputPlane>::attributes = {
+    {"CENTRE", "centre", "", PyOpalObjectNS::FLOAT_LIST},
+    {"NORMAL", "normal", "", PyOpalObjectNS::FLOAT_LIST},
+    {"XSTART", "x_start", "", PyOpalObjectNS::DOUBLE},
+    {"XEND", "x_end", "", PyOpalObjectNS::DOUBLE},
+    {"YSTART", "y_start", "", PyOpalObjectNS::DOUBLE},
+    {"YEND", "y_end", "", PyOpalObjectNS::DOUBLE},
+    {"PLACEMENT_STYLE", "placement_style", "", PyOpalObjectNS::PREDEFINED_STRING},
+    {"ALGORITHM", "algorithm", "", PyOpalObjectNS::PREDEFINED_STRING},
+    {"TOLERANCE", "tolerance", "", PyOpalObjectNS::DOUBLE},
+    {"REFERENCE_ALIGNMENT_PARTICLE", "reference_alignment_particle", "", PyOpalObjectNS::INT},
+    {"OUTFN", "output_filename", "", PyOpalObjectNS::STRING}, // OUTFN comes from OpalElement (yes, all elements can have a filename!)
+    {"VERBOSE", "verbose_level", "", PyOpalObjectNS::INT},
+    {"WIDTH", "width", "", PyOpalObjectNS::DOUBLE},
+    {"HEIGHT", "height", "", PyOpalObjectNS::DOUBLE},
+    {"RADIUS", "radius", "", PyOpalObjectNS::DOUBLE}
+};
+
+template <>
+std::string PyOpalObjectNS::PyOpalObject<OpalOutputPlane>::classDocstring =
+"OutputPlane is used to generate output data based on particle tracks crossing a plane.";
+
+BOOST_PYTHON_MODULE(output_plane) {
+    PyOpal::Globals::Initialise();
+    ExceptionTranslation::registerExceptions();
+    PyOpalObjectNS::PyOpalObject<OpalOutputPlane> element;
+    auto elementClass = element.make_element_class("OutputPlane");
+}
+
+}
+}
diff --git a/src/ValueDefinitions/StringConstant.cpp b/src/ValueDefinitions/StringConstant.cpp
index 9ec2897ff0d8be0db3548fde6c4e7e1ba70f9a2e..ab1a9a6c26fef115051633b4bf844012d7bfb65a 100644
--- a/src/ValueDefinitions/StringConstant.cpp
+++ b/src/ValueDefinitions/StringConstant.cpp
@@ -125,6 +125,14 @@ StringConstant::StringConstant():
     CREATE_STRINGCONSTANT("AC");
     CREATE_STRINGCONSTANT("DC");
 
+    // OutputPlane / PLACEMENT_STYLE
+    CREATE_STRINGCONSTANT("CENTRE_NORMAL");
+    CREATE_STRINGCONSTANT("PROBE");
+
+    // OutputPlane / ALGORITHM
+    CREATE_STRINGCONSTANT("INTERPOLATION");
+    // CREATE_STRINGCONSTANT("RK4");
+
     // ParticleMatterInteraction / TYPE
     CREATE_STRINGCONSTANT("SCATTERING");
     CREATE_STRINGCONSTANT("BEAMSTRIPPING");
diff --git a/tests/classic_src/AbsBeamline/CMakeLists.txt b/tests/classic_src/AbsBeamline/CMakeLists.txt
index 06c6230bdef5073b6ea3a51798e4f98f61a9b013..35ec3b6c5bd452b5ffeee12d194426e5c14f90ed 100644
--- a/tests/classic_src/AbsBeamline/CMakeLists.txt
+++ b/tests/classic_src/AbsBeamline/CMakeLists.txt
@@ -4,6 +4,7 @@ set (_SRCS
     DipoleFieldTest.cpp
     MultipoleTTest.cpp
     OffsetTest.cpp
+    OutputPlaneTest.cpp
     PolynomialTest.cpp
     RingTest.cpp
     SBend3DTest.cpp
diff --git a/tests/classic_src/AbsBeamline/OutputPlaneTest.cpp b/tests/classic_src/AbsBeamline/OutputPlaneTest.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a0a6b80e389ca94795995146dbca97dc5dad33fe
--- /dev/null
+++ b/tests/classic_src/AbsBeamline/OutputPlaneTest.cpp
@@ -0,0 +1,352 @@
+// Copyright (c) 2023, Chris Rogers
+// All rights reserved
+//
+// This file is part of OPAL.
+//
+// OPAL 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.
+//
+// You should have received a copy of the GNU General Public License
+// along with OPAL. If not, see <https://www.gnu.org/licenses/>.
+//
+
+#include <cmath>
+#include <fstream>
+#include <sstream>
+
+#include "gtest/gtest.h"
+#include "opal_test_utilities/SilenceTest.h"
+
+#include "Classic/Physics/Physics.h"
+#include "Classic/AbsBeamline/OutputPlane.h"
+#include "Classic/AbsBeamline/Component.h"
+
+
+class MockDipole : public Component {
+public:
+    MockDipole(const std::string /*&name*/) : bfield_m(0, 1, 0), efield_m() {}
+    NullField &getField() {return nullfield_m;}
+    const NullField &getField() const {return nullfield_m;}
+    void accept(BeamlineVisitor& /*&visitor*/) const {}
+    void initialise(PartBunchBase<double, 3>* /*bunch*/, double& /*start*/, double& /*end*/) {}
+    void finalise() {}
+    bool bends() const {return true;}
+    void getDimensions(double& /*zBegin*/, double& /*zEnd*/) const {}
+    StraightGeometry& getGeometry() {return geom_m;}
+    const StraightGeometry& getGeometry() const {return geom_m;}
+    ElementBase* clone() const {return NULL;}
+
+    void setField(Vector_t bfield, Vector_t efield) {bfield_m = bfield; efield_m = efield;}
+
+    bool apply(const Vector_t& /*R*/, const Vector_t& /*P*/, const double& /*t*/, Vector_t& E, Vector_t& B) override {
+        B = bfield_m;
+        E = efield_m;
+        return false;
+    }
+private:
+    NullField nullfield_m;
+    StraightGeometry geom_m;
+    Vector_t bfield_m;
+    Vector_t efield_m;
+};
+
+// Set also - lots of testing done in pyopal level
+class OutputPlaneTest : public ::testing::Test {
+public:
+    OutputPlaneTest() : dipole_m("") {
+    }
+
+    void SetUp( ) {
+        output_m.reset(new OutputPlane());
+        output_m->setRecentre(-1);
+
+    }
+
+    void TearDown( ) {
+    }
+
+    ~OutputPlaneTest() {
+        output_m.release();
+    }
+
+    std::unique_ptr<OutputPlane> output_m;
+    MockDipole dipole_m;
+    const double q2m = 1/Physics::c/Physics::c/Physics::m_p;
+
+    // OpalTestUtilities::SilenceTest silencer_m;
+};
+
+TEST_F(OutputPlaneTest, TestSetGet) {
+    // test basic set and get methods
+    output_m->setGlobalFieldMap(&dipole_m);
+    EXPECT_EQ(output_m->getGlobalFieldMap(), &dipole_m);
+
+    output_m->setNormal(Vector_t(1.0, 2.0, 3.0));
+    Vector_t ref = Vector_t(1.0, 2.0, 3.0)/std::sqrt(14);
+    EXPECT_NEAR(output_m->getNormal()[0], ref[0], 1e-9);
+    EXPECT_NEAR(output_m->getNormal()[1], ref[1], 1e-9);
+    EXPECT_NEAR(output_m->getNormal()[2], ref[2], 1e-9);
+
+    output_m->setCentre(Vector_t(4.0, 5.0, 6.0));
+    EXPECT_EQ(output_m->getCentre(), Vector_t(4.0, 5.0, 6.0));
+
+    output_m->setTolerance(1e-6);
+    EXPECT_EQ(output_m->getTolerance(), 1e-6);
+}
+
+
+TEST_F(OutputPlaneTest, TestCheckOne_FieldOff) {
+    //checkOne(const double tstep, const double chargeToMass,
+    //         double& t, Vector_t& R, Vector_t& P)
+    output_m->setGlobalFieldMap(&dipole_m);
+    output_m->setCentre(Vector_t(0.0, 0.0, 1.0));
+    output_m->setNormal(Vector_t(1.0, 0.0, 1.0));
+    output_m->setTolerance(1e-9);
+
+    Vector_t R, P;
+    bool crossing;
+    double t = 0.0;
+
+    R = Vector_t(1.0, 0.0, 1.0);
+    P = Vector_t(0.1, 0.0, 0.0);
+    // c = 0.3 m/ns and particle is 1.0 m away; so should fail without doing RK4
+    // step at all
+    crossing = output_m->checkOne(0, 1.0e-9, 0.0, t, R, P);
+    EXPECT_EQ(R, Vector_t(1.0, 0.0, 1.0));
+    EXPECT_EQ(P, Vector_t(0.1, 0.0, 0.0));
+    EXPECT_FALSE(crossing);
+    // betagamma = 0.1 and particle is ~0.07 m away; so should fail after trying
+    // a single RK4 step (which goes about 0.03 m)
+    R = Vector_t(1-0.05, 0.0, -0.05);
+    crossing = output_m->checkOne(0, 1.0e-9, 0.0, t, R, P);
+    EXPECT_EQ(R, Vector_t(1.0-0.05, 0.0, -0.05));
+    EXPECT_EQ(P, Vector_t(0.1, 0.0, 0.0));
+    EXPECT_FALSE(crossing);
+    // betagamma = 0.1 and particle is 0.014 m away; so should succeed after a
+    // couple of RK4 step (which goes about 0.03 m)
+    R = Vector_t(1.0-0.01, 0.0, -0.01);
+    crossing = output_m->checkOne(0, 1.0, 0.0, t, R, P);
+    EXPECT_NEAR(R[0], 1.01, 1e-5);
+    EXPECT_NEAR(R[1], 0.0, 1e-5);
+    EXPECT_NEAR(R[2], -0.01, 1e-5);
+    EXPECT_EQ(P, Vector_t(0.1, 0.0, 0.0));
+    EXPECT_TRUE(crossing);
+    // particle is past the plane but close; should fail after trying a single
+    // RK4 step
+    R = Vector_t(1.0+0.01, 0.0, +0.01);
+    crossing = output_m->checkOne(0, 1.0, 0.0, t, R, P);
+    EXPECT_NEAR(R[0], 1.0+0.01, 1e-5);
+    EXPECT_NEAR(R[1], 0.0, 1e-5);
+    EXPECT_NEAR(R[2], +0.01, 1e-5);
+    EXPECT_EQ(P, Vector_t(0.1, 0.0, 0.0));
+    EXPECT_FALSE(crossing);
+    // particle is past the plane but travelling backwards; should pass
+    R = Vector_t(1.0+0.01, 0.0, +0.01);
+    P = Vector_t(-0.1, 0.0, 0.0);
+    crossing = output_m->checkOne(0, 1.0, 0.0, t, R, P);
+    EXPECT_NEAR(R[0], 1.0-0.01, 1e-5);
+    EXPECT_NEAR(R[1], 0.0, 1e-5);
+    EXPECT_NEAR(R[2], +0.01, 1e-5);
+    EXPECT_EQ(P, Vector_t(-0.1, 0.0, 0.0));
+    EXPECT_TRUE(crossing);
+}
+
+double magnitude(Vector_t v) {
+    return std::sqrt(v[0]*v[0]+v[1]*v[1]+v[2]*v[2]);
+}
+
+
+// ps: trying to figure out OPAL units, I put some print statements in ParallelCyclotronTracker
+//
+// itsBunch_m->Q[i]/itsBunch_m->M[i]*Physics::c*Physics::c/Physics::q_e/1e3 = 9.57883e+13
+// std::cerr << "CHARGE TO MASS " << i << " Qi " << itsBunch_m->Q[i] << " Mi "
+//           << itsBunch_m->M[i] << " c " << Physics::c << " qe " << Physics::q_e << std::endl;
+// CHARGE TO MASS 0 Qi 1.60218e-19 Mi 0.938272 c 2.99792e+08 qe 1.60218e-19
+
+TEST_F(OutputPlaneTest, TestRK4StepNoField) {
+	Vector_t pVecRef(-1.0, -0.0, -0.4); // moving at beta gamma = 1.414
+	Vector_t rVecRef(2., 3., 4.);
+    Vector_t rVec = rVecRef;
+    Vector_t pVec = pVecRef;
+	double time = 2.0e-9;
+    double tstep = 1.0e-9;
+    double gammabeta = magnitude(pVecRef);
+    double beta = gammabeta/std::sqrt(1+gammabeta*gammabeta);
+    double gamma = gammabeta/beta;
+
+
+    // field not set
+    EXPECT_THROW(output_m->RK4Step(tstep, q2m, time, rVec, pVec), GeneralClassicException);
+
+    // zero field
+    Vector_t speed = pVecRef/gamma*Physics::c; // (beta gamma)* c / gamma = velocity
+    rVecRef = speed*tstep+rVec;
+    std::shared_ptr<MockDipole> testDipole;
+    testDipole.reset(new MockDipole("mock"));
+    testDipole->setField(Vector_t(), Vector_t());
+
+    output_m->setGlobalFieldMap(testDipole.get());
+    output_m->RK4Step(tstep, q2m, time, rVec, pVec);
+    EXPECT_EQ(pVec, pVecRef);
+    for (size_t i = 0; i < 3; ++i) {
+        EXPECT_NEAR(rVec[i], rVecRef[i], 1e-6);
+    }
+}
+
+TEST_F(OutputPlaneTest, TestRK4StepDipole) {
+    // dipole in z direction
+    // B = 2.0 Tesla
+    // p = 1.0 GeV/c
+    // rho = 1e9/2/c
+    double time = 0.0;
+    double tstep = 1.0e-2;
+
+    std::shared_ptr<MockDipole> testDipole;
+    testDipole.reset(new MockDipole("mock"));
+    testDipole->setField(Vector_t(0.0, 0.0, -2.0), Vector_t());
+    Vector_t rVecRef = Vector_t(0.0, 0.0, 0.0);
+    Vector_t pVecRef = Vector_t(0.0, 1.0/Physics::m_p, 1.0);
+    Vector_t rVec = rVecRef;
+    Vector_t pVec = pVecRef;
+    output_m->setGlobalFieldMap(testDipole.get());
+    output_m->RK4Step(tstep, q2m, time, rVec, pVec);
+    EXPECT_NEAR(pVecRef[2], pVec[2], 1e-9);
+    EXPECT_NEAR(pVecRef[0]*pVecRef[0]+pVecRef[1]*pVecRef[1],
+                pVec[0]*pVec[0]+pVec[1]*pVec[1], 1e-8);
+}
+
+TEST_F(OutputPlaneTest, TestRK4StepEField) {
+    // dipole in z direction
+    // B = 2.0 Tesla
+    // p = 1.0 GeV/c
+    // rho = 1e9/2/c
+    double time = 0.0;
+    double tstep = 1.0e-9;
+
+    std::shared_ptr<MockDipole> testDipole;
+    testDipole.reset(new MockDipole("mock"));
+    testDipole->setField(Vector_t(), Vector_t(0.0, 0.0, 2.0));
+    Vector_t rVecRef = Vector_t(1e9/2/Physics::c, 0.0, 0.0);
+    Vector_t pVecRef = Vector_t(0.0, 1.0/0.938272, 0.0);
+    Vector_t rVec = rVecRef;
+    Vector_t pVec = pVecRef;
+    output_m->setGlobalFieldMap(testDipole.get());
+    output_m->RK4Step(tstep, q2m, time, rVec, pVec);
+    EXPECT_NEAR(pVec[0], pVecRef[0], 1e-9);
+    EXPECT_NEAR(pVec[1], pVecRef[1], 1e-9);
+}
+
+TEST_F(OutputPlaneTest, TestCheckOne_FieldOn) {
+    //checkOne(const double tstep, const double chargeToMass,
+    //         double& t, Vector_t& R, Vector_t& P)
+    output_m->setGlobalFieldMap(&dipole_m);
+    output_m->setCentre(Vector_t(0.0, 0.0, 0.0));
+    output_m->setNormal(Vector_t(1.0, 0.0, 0.0));
+    output_m->setTolerance(1e-9);
+    output_m->setAlgorithm(OutputPlane::algorithm::RK4STEP);
+
+    Vector_t R, P;
+    bool crossing;
+    double q2m=1.0*Physics::q_e; // proton mass is 0.938
+    // mass excluded since RK4 solvers works in terms of momentum
+    double t = 0.0;
+
+    // betagamma = 0.1 and particle is 0.02 m away; so should succeed after a
+    // couple of RK4 step (which goes about 0.03 m)
+    R = Vector_t(-0.02, 0.0, 0.0);
+    P = Vector_t(0.1, 0.1, 0.0);
+    crossing = output_m->checkOne(0, 1.0, q2m, t, R, P);
+    
+    EXPECT_NEAR(R[0], 0.0, 1e-5);
+    EXPECT_NEAR(R[1], 0.02, 1e-5);
+    EXPECT_NEAR(R[2], 0.0, 1e-5);
+    EXPECT_NEAR(P[0], 0.1, 1e-5);
+    EXPECT_NEAR(P[1], 0.1, 1e-5);
+    EXPECT_NEAR(P[2], 0.0, 1e-5);
+    
+    EXPECT_TRUE(crossing);
+}
+
+TEST_F(OutputPlaneTest, TestCentre) {
+    // set centre
+    output_m->setCentre(Vector_t(2.0, 3.0, 4.0));
+    output_m->setNormal(Vector_t(1.0, 0.0, 0.0));
+    output_m->setTolerance(1e-9);
+
+    Vector_t R, P, normP, R1, R2, a, b, normaltoplane, unitnormaltoplane;
+    R = Vector_t(-0.02, 5.0, 7.1);
+    P = Vector_t(0.5, 0.8, 0.3);
+
+    //re centre plane
+    output_m->recentre(R, P);
+
+    double Ptot = std::sqrt(P[0]*P[0]+P[1]*P[1]+P[2]*P[2]);
+    normP = Vector_t(P[0]/Ptot, P[1]/Ptot, P[2]/Ptot);
+
+    // check plane is recentred properly
+    EXPECT_EQ(output_m->getCentre(), R);
+    EXPECT_EQ(output_m->getNormal(), normP);
+ }
+
+TEST_F(OutputPlaneTest, TestExtent) {
+    output_m->setCentre(Vector_t(2.0, 3.0, 4.0));
+    output_m->setNormal(Vector_t(1.0, 0.0, 0.0));
+    output_m->setTolerance(1e-9);
+    double halfwidth, halfheight;
+
+    halfheight = 10;
+    halfwidth = 10;
+
+    double horizextent = output_m->getHorizontalExtent();
+    // calling getHorizontalExtent before settingthe horizontal extent returns a value of -1
+    EXPECT_EQ(-1, horizextent);
+
+    output_m->setHorizontalExtent(halfwidth);
+    output_m->setVerticalExtent(halfheight);
+    double vertextent = output_m->getVerticalExtent();
+    horizextent = output_m->getHorizontalExtent();
+
+    EXPECT_EQ( horizextent, halfwidth);
+    EXPECT_EQ( vertextent, halfheight);
+
+}
+
+
+TEST_F(OutputPlaneTest, checkOneTest){
+    OutputPlane* myOutputPlane = new OutputPlane("TestPlane"); 
+    MockDipole* myDipole = new MockDipole("TestDipole");
+    // check that checkOne returns true when the particle is within one time step
+    // of the OutputPlane and false when it is not within one timestep
+    myOutputPlane->setGlobalFieldMap(myDipole);
+
+    Vector_t R, P;
+    Vector_t R1, P1;
+
+    double tStep = 1.0e-9; // s
+
+    Vector_t normal = {1.0, 0.0, 1.0};
+    Vector_t centre = {0.0, 0.0, 1.0};
+    myOutputPlane->setCentre(centre);
+    myOutputPlane->setNormal(normal);
+    myOutputPlane->setTolerance(1e-9);
+
+    R = Vector_t(0.1, 0.0, 0.1);
+    P = Vector_t(0.1, 0.0, 0.0); 
+    double t = 0.0;
+    bool cross;
+    cross = myOutputPlane->checkOne(0, tStep, 0.0, t, R, P);
+    EXPECT_FALSE(cross);
+
+    // opposite case where it is near to the plane
+    // distance from plane
+    R1 = Vector_t(1.0-0.01, 0.0, -0.01);
+    P1 = Vector_t(0.1, 0.0, 0.0); 
+
+    bool cross1;
+    cross1 = myOutputPlane->checkOne(0, tStep, 0.0, t, R1, P1);
+    EXPECT_TRUE(cross1);
+    delete myDipole;
+    delete myOutputPlane;
+}
diff --git a/tests/opal_src/Elements/CMakeLists.txt b/tests/opal_src/Elements/CMakeLists.txt
index 127c036de671623fead20c9a9749d53ab976dbde..18c7fe3b06231a2346ecafcea0d56e9965772cc1 100644
--- a/tests/opal_src/Elements/CMakeLists.txt
+++ b/tests/opal_src/Elements/CMakeLists.txt
@@ -2,6 +2,7 @@ set (_SRCS
     OpalAsymmetricEngeTest.cpp
     OpalEngeTest.cpp
     OpalOffsetTest.cpp
+    OpalOutputPlaneTest.cpp
     OpalPolynomialTimeDependenceTest.cpp
     OpalScalingFFAMagnetTest.cpp
     OpalSplineTimeDependenceTest.cpp
diff --git a/tests/opal_src/Elements/OpalOutputPlaneTest.cpp b/tests/opal_src/Elements/OpalOutputPlaneTest.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..400e5f201212feac377f3d8697902ace9beef475
--- /dev/null
+++ b/tests/opal_src/Elements/OpalOutputPlaneTest.cpp
@@ -0,0 +1,85 @@
+// Copyright (c) 2023, Chris Rogers
+// All rights reserved
+//
+// This file is part of OPAL.
+//
+// OPAL 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.
+//
+// You should have received a copy of the GNU General Public License
+// along with OPAL. If not, see <https://www.gnu.org/licenses/>.
+//
+
+
+#include "gtest/gtest.h"
+
+#include "Attributes/Attributes.h"
+#include "AbsBeamline/Offset.h"
+#include "Elements/OpalOutputPlane.h"
+#include "AbsBeamline/OutputPlane.h"
+
+TEST(OpalOutputPlaneTest, TestCtor) {
+    OpalOutputPlane plane1;
+    EXPECT_EQ((&plane1)->getOpalName(), "OUTPUTPLANE");
+}
+
+
+void setReal(OpalOutputPlane& plane, std::string attName, double value) {
+    Attribute* att = plane.findAttribute(attName);
+    ASSERT_NE(att, nullptr) << "Failed with attribute " << attName;
+    Attributes::setReal(*att, value);
+}
+
+TEST(OpalOutputPlaneTest, TestProbeMode) {
+    OpalOutputPlane plane;
+    Attribute* att = plane.findAttribute("PLACEMENT_STYLE");
+    Attributes::setPredefinedString(*att, "PROBE");
+    setReal(plane, "XSTART", 1.0);
+    setReal(plane, "XEND", 3.0);
+    setReal(plane, "YSTART", -1.0);
+    setReal(plane, "YEND", -3.0);
+    plane.update();
+    OutputPlane *output = dynamic_cast<OutputPlane *>(plane.getElement());
+    double roottwo = std::sqrt(2);
+    EXPECT_NEAR(output->getCentre()[0], 2.0, 1e-12);
+    EXPECT_NEAR(output->getCentre()[1], -2.0, 1e-12);
+    EXPECT_NEAR(output->getCentre()[2], 0.0, 1e-12);
+    EXPECT_NEAR(output->getHorizontalExtent(), roottwo, 1e-12); // that is half width
+    EXPECT_NEAR(output->getNormal()[0], 1/roottwo, 1e-12);
+    EXPECT_NEAR(output->getNormal()[1], 1/roottwo, 1e-12);
+
+    // swap and check normal is reversed
+    setReal(plane, "XSTART", 3.0);
+    setReal(plane, "XEND", 1.0);
+    setReal(plane, "YSTART", -3.0);
+    setReal(plane, "YEND", -1.0);
+    plane.update();
+    EXPECT_NEAR(output->getNormal()[0], -1/roottwo, 1e-12);
+    EXPECT_NEAR(output->getNormal()[1], -1/roottwo, 1e-12);
+}
+
+TEST(OpalOutputPlaneTest, TestPlacement) {
+    OpalOutputPlane plane;
+    Attribute* att0 = plane.findAttribute("PLACEMENT_STYLE");
+    Attributes::setPredefinedString(*att0, "CENTRE_NORMAL");
+    Attribute* att1 = plane.findAttribute("CENTRE");
+    Attributes::setRealArray(*att1, {1.0, 2.0, 3.0});
+    Attribute* att2 = plane.findAttribute("NORMAL");
+    Attributes::setRealArray(*att2, {-4.0, 4.0, 2.0});
+    setReal(plane, "WIDTH", 4.0);
+    setReal(plane, "HEIGHT", 5.0);
+    setReal(plane, "RADIUS", 6.0);
+    plane.update();
+    OutputPlane *output = dynamic_cast<OutputPlane *>(plane.getElement());
+    EXPECT_NEAR(output->getCentre()[0], 1.0, 1e-12);
+    EXPECT_NEAR(output->getCentre()[1], 2.0, 1e-12);
+    EXPECT_NEAR(output->getCentre()[2], 3.0, 1e-12);
+    EXPECT_NEAR(output->getNormal()[0], -2/3.0, 1e-12);
+    EXPECT_NEAR(output->getNormal()[1], 2/3.0, 1e-12);
+    EXPECT_NEAR(output->getNormal()[2], 1.0/3.0, 1e-12);
+    EXPECT_NEAR(output->getHorizontalExtent(), 2.0, 1e-12); // that is half width
+    EXPECT_NEAR(output->getVerticalExtent(), 2.5, 1e-12); // that is half height
+    EXPECT_NEAR(output->getRadialExtent(), 6.0, 1e-12); // that is half height
+}
diff --git a/tests/opal_src/PyOpal/PyElements/test_output_plane.py b/tests/opal_src/PyOpal/PyElements/test_output_plane.py
new file mode 100644
index 0000000000000000000000000000000000000000..f4beddea88c06eb0b59a8f4cbc0c3909e9970954
--- /dev/null
+++ b/tests/opal_src/PyOpal/PyElements/test_output_plane.py
@@ -0,0 +1,265 @@
+# Copyright (c) 2023, Chris Rogers, STFC Rutherford Appleton Laboratory, Didcot, UK
+#
+# This file is part of OPAL.
+#
+# OPAL 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.
+#
+# You should have received a copy of the GNU General Public License
+# along with OPAL.  If not, see <https://www.gnu.org/licenses/>.
+
+import os
+import unittest
+import math
+import pyopal.objects.minimal_runner
+
+import pyopal.objects.field
+import pyopal.objects.option
+import pyopal.elements.output_plane
+import pyopal.elements.multipolet
+import pyopal.elements.global_cartesian_offset
+import pyopal.elements.polynomial_time_dependence
+import pyopal.elements.variable_rf_cavity
+
+
+class OutputPlaneRunner(pyopal.objects.minimal_runner.MinimalRunner):
+    """Class to set up a probe and run it"""
+    def __init__(self, number_of_steps=100, beta_rel=0.001, bz=0.0, algorithm="RK4", verbose=0):
+        """Set up the test"""
+        super().__init__()
+        self.verbose = verbose
+        self.probe = None
+        self.options = None
+        self.r0 = 0.9
+        self.yprobe = 0.1 # that should be in [m]
+        self.algorithm = algorithm
+
+        self.bz = bz
+        # Tracking is set up so the beam will go 0.11 metres
+        self.steps_per_turn = number_of_steps-1
+        self.max_steps = number_of_steps
+        self.time_per_turn = 0.11/(beta_rel*3e8) # [seconds] beam goes 0.11 metres
+        self.momentum = beta_rel/(1-beta_rel**2)*self.mass # [GeV/c]
+        self.distribution_str = """1
+0.0 0.0 0.0 0.0 0.0 0.0
+"""
+        self.setup_plane()
+
+    def make_options(self):
+        """Don't want to force user to have hdf2py - so dump to ASCII"""
+        self.options = pyopal.objects.option.Option()
+        self.options.ascii_dump = True
+        self.options.enable_hdf5 = False
+        self.options.execute()
+
+    def setup_plane(self):
+        """Make a probe, just offset from x axis so should catch first step"""
+        self.output_plane = pyopal.elements.output_plane.OutputPlane()
+        self.output_plane.set_attributes(
+            x_start = 0.1,
+            x_end = self.r0*2,
+            y_start = self.yprobe,
+            y_end = self.yprobe,
+            placement_style = "PROBE",
+            output_filename = "test_plane",
+            algorithm = self.algorithm,
+            verbose_level = self.verbose
+        )
+        self.output_plane.set_opal_name("my_plane")
+
+    def make_dipole(self):
+        dipole = pyopal.elements.multipolet.MultipoleT()
+        dipole.set_attributes(
+            t_p = [self.bz, self.bz*100],
+            left_fringe = 0.01,
+            right_fringe = 0.01,
+            length = 0.5,
+            horizontal_aperture = 100,
+            vertical_aperture = 100,
+            bounding_box_length = 1.0
+        )
+        return [dipole]
+
+    @classmethod
+    def make_time_dependence(cls, name, pol0, pol1):
+        """Make a time dependence"""
+        ptd = pyopal.elements.polynomial_time_dependence.PolynomialTimeDependence()
+        ptd.p0 = pol0
+        ptd.p1 = pol1
+        ptd.set_opal_name(name)
+        ptd.update()
+        return ptd
+
+    def make_rf(self):
+        rf = pyopal.elements.variable_rf_cavity.VariableRFCavity()
+        phase = self.make_time_dependence("phase", math.pi/2, 0.0)
+        # nb: 4 GeV protons; significant energy change in 0.1 m -> O(GV/m)
+        voltage = self.make_time_dependence("voltage", self.bz*1000.0, 0.0)
+        # time step is ~ ns so frequency ~ GHz is appropriate
+        frequency = self.make_time_dependence("frequency", 1e3, 0.0)
+        rf.set_attributes(
+            phase_model = "phase",
+            amplitude_model = "voltage",
+            frequency_model = "frequency",
+            height = 10.0,
+            width = 10.0,
+            length = 100.0,
+        )
+        offset = pyopal.elements.global_cartesian_offset.GlobalCartesianOffset()
+        offset.set_attributes(
+            end_position_x = 0.9,
+            end_position_y = -0.5,
+            end_normal_x = 0.0,
+            end_normal_y = 1.0,
+        )
+        return [offset, rf]
+
+    def make_element_iterable(self):
+        self.make_options()
+        return [self.output_plane]+self.make_dipole()+self.make_rf()
+
+class TestOutputPlane(unittest.TestCase):
+
+    def run_one(self, n_steps, beta_rel, bz, algorithm):
+        plane_runner = OutputPlaneRunner(n_steps, beta_rel, bz, algorithm, self.verbose)
+        plane_runner.execute_fork() # forked process for memory safety
+        return self.read_lines(plane_runner)
+
+    def read_lines(self, plane_runner, name = "test_plane"):
+        plane_file = os.path.join(plane_runner.tmp_dir, name+".loss")
+        try:
+            with open(plane_file) as a_file:
+                lines = [line for line in a_file.readlines()]
+        except FileNotFoundError:
+            print(f"File {plane_file} not found - ls returns:")
+            print("   ", os.listdir(plane_runner.tmp_dir))
+            raise
+        return lines
+
+    def test_no_field_rk4(self):
+        """Simple test - track through a probe and check we get an output"""
+        lines = self.run_one(1000, 0.001, 0.0, "INTERPOLATION")
+        self.assertEqual(len(lines), 2) # header and one track object
+        words = lines[1].split()
+        self.assertAlmostEqual(float(words[0]), 0.9)
+        self.assertAlmostEqual(float(words[1]), 0.1) # output is in [m]
+        self.assertAlmostEqual(float(words[2]), 0.0)
+        self.assertAlmostEqual(float(words[3]), 0.0)
+        self.assertAlmostEqual(float(words[4]), 0.001) # beta_gamma
+        self.assertAlmostEqual(float(words[5]), 0.0)
+
+    def test_field_rk4(self):
+        """
+        Track through a probe and check we get an output; check that RK4 is
+        consistent with linear interpolation in the limit of many interpolation
+        steps. Note fields are set up just to be "reasonably" complicated i.e. I
+        want to check fields in several different directions, time varying, etc
+        all works okay.
+        """
+        int_lines = self.run_one(1000, 0.9, 2.0, "INTERPOLATION")
+        rk4_lines = self.run_one(3, 0.9, 2.0, "RK4")
+        if self.verbose:
+            print(rk4_lines)
+            print("Interpolation\n   ", int_lines[1])
+            print("RK4\n   ", rk4_lines[1])
+        self.assertEqual(len(int_lines), 2) # header and one track object
+        self.assertEqual(len(rk4_lines), 2) # header and one track object
+        rk4_words = rk4_lines[1].split()
+        int_words = int_lines[1].split()
+        self.assertAlmostEqual(float(rk4_words[3]), float(int_words[3]), 4)
+        self.assertAlmostEqual(float(rk4_words[4]), float(int_words[4]), 4) # beta_gamma
+        self.assertAlmostEqual(float(rk4_words[5]), float(int_words[5]), 4)
+        self.assertAlmostEqual(float(rk4_words[0]), float(int_words[0]), 9)
+        self.assertAlmostEqual(float(rk4_words[1]), float(int_words[1]), 9) # output is in [m]
+        self.assertAlmostEqual(float(rk4_words[2]), float(int_words[2]), 9)
+        self.assertAlmostEqual(float(rk4_words[9])*1e9, float(int_words[9])*1e9, 3) # ns
+
+    def test_reference_alignment_particle(self):
+        """Check plane realignment works"""
+        plane_runner = OutputPlaneRunner(100, 0.001, 0.0, "INTERPOLATION", self.verbose)
+        plane_runner.output_plane.reference_alignment_particle = 1
+        plane_runner.output_plane.tolerance = 1e-10
+        # momentum units are eV(!)
+        dpx = 0.0001*0.938272e9 # dxds = 0.1
+        plane_runner.distribution_str = """3
+0.0 0.0 0.0 0.0 0.0 0.0
+0.0 """+str(dpx)+""" 0.0 0.0 0.0 0.0
+-0.01 0.0 0.0 0.0 0.0 0.0
+"""
+        plane_runner.execute_fork() # forked process for memory safety
+        lines = self.read_lines(plane_runner)
+        if self.verbose:
+            print("Reference plane test")
+            for line in lines:
+                print(line[:-1])
+        self.assertEqual(len(lines), 4)
+        dxds = 0.1 # the alignment particle angle
+        dx = 0.02 # alignment particle offset
+        test_particle = lines[3].split()
+        self.assertAlmostEqual(float(test_particle[0]), 0.89)
+        self.assertAlmostEqual(float(test_particle[1])-0.1, dx*dxds)
+
+    def test_rectangular_aperture(self):
+        """Check we cut approporiately with rectangular aperture"""
+        width = 0.01
+        plane_runner = OutputPlaneRunner(100, 0.001, 0.0, "INTERPOLATION", self.verbose)
+        plane_runner.output_plane.placement_style = "CENTRE-NORMAL"
+        plane_runner.output_plane.centre = [0.9, 0.1, 0.0]
+        plane_runner.output_plane.normal = [1.0, 1.0, 0.0]
+        plane_runner.output_plane.height = 0.1
+        plane_runner.output_plane.width = width*2
+        plane_runner.distribution_str = f"""9
+0.0 0.0 0.0 0.0 0.0 0.0
+{(width-0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+{(width+0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+{(-width-0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+{(-width+0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+0.0 0.0 0.0 0.0 0.049 0.0
+0.0 0.0 0.0 0.0 0.051 0.0
+0.0 0.0 0.0 0.0 -0.049 0.0
+0.0 0.0 0.0 0.0 -0.051 0.0
+"""
+
+        plane_runner.execute_fork() # forked process for memory safety
+        lines = self.read_lines(plane_runner)
+        if self.verbose:
+            print("Reference plane test")
+            for line in lines:
+                print(line[:-1])
+        self.assertEqual(len(lines), 6)
+
+    def test_radial_aperture(self):
+        """Check we cut appropriately with radial aperture"""
+        plane_runner = OutputPlaneRunner(100, 0.001, 0.0, "INTERPOLATION", self.verbose)
+        plane_runner.output_plane.placement_style = "CENTRE-NORMAL"
+        plane_runner.output_plane.output_filename = "" # defaults to "my_plane"
+        plane_runner.output_plane.centre = [0.9, 0.1, 0.0]
+        plane_runner.output_plane.normal = [1.0, 1.0, 0.0]
+        plane_runner.output_plane.radius = 0.1
+        width = 0.1
+        plane_runner.distribution_str = f"""9
+0.0 0.0 0.0 0.0 0.0 0.0
+{(width-0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+{(width+0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+{(-width-0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+{(-width+0.001)*0.5**0.5} 0.0 0.0 0.0 0.0 0.0
+0.0 0.0 0.0 0.0 {width-0.001} 0.0
+0.0 0.0 0.0 0.0 {width+0.001} 0.0
+0.0 0.0 0.0 0.0 {-width-0.001} 0.0
+0.0 0.0 0.0 0.0 {-width+0.001} 0.0
+"""
+
+        plane_runner.execute_fork() # forked process for memory safety
+        lines = self.read_lines(plane_runner, "my_plane")
+        if self.verbose:
+            print("Reference plane test")
+            for line in lines:
+                print(line[:-1])
+        self.assertEqual(len(lines), 5)
+
+    verbose = 0
+
+if __name__ == "__main__":
+    unittest.main()
\ No newline at end of file