diff --git a/src/ipa/libipa/agc_mean_luminance.cpp b/src/ipa/libipa/agc_mean_luminance.cpp
index eff639fb67..f0e47cc06d 100644
--- a/src/ipa/libipa/agc_mean_luminance.cpp
+++ b/src/ipa/libipa/agc_mean_luminance.cpp
@@ -328,13 +328,11 @@ int AgcMeanLuminance::parseExposureModes(const ValueNode &tuningData)
 
 	/*
 	 * If we don't have any exposure modes in the tuning data we create an
-	 * ExposureModeHelper using an empty vector of stages. This will result
-	 * in the ExposureModeHelper simply driving the exposure time as high as
-	 * possible before touching gain.
+	 * ExposureModeHelper in "maximize" mode.
 	 */
 	if (exposureModeHelpers_.empty())
 		exposureModeHelpers_.try_emplace(controls::ExposureNormal,
-						 std::vector<ExposureModeHelper::Stage>{});
+						 ExposureModeHelper::Maximize{});
 
 	return 0;
 }
diff --git a/src/ipa/libipa/exposure_mode_helper.cpp b/src/ipa/libipa/exposure_mode_helper.cpp
index cadd1903d8..13dce50c48 100644
--- a/src/ipa/libipa/exposure_mode_helper.cpp
+++ b/src/ipa/libipa/exposure_mode_helper.cpp
@@ -7,6 +7,7 @@
 #include "exposure_mode_helper.h"
 
 #include <algorithm>
+#include <variant>
 
 #include <libcamera/base/log.h>
 
@@ -74,16 +75,55 @@ namespace ipa {
   */
 
 /**
- * \brief Construct an ExposureModeHelper instance
+ * \class ExposureModeHelper::Maximize
+ * \brief Tag type to select mode of operation
+ *
+ * When an instance is constructed with this tag type, it will
+ * be initialized in a mode of operation where it tries to use
+ * as much exposure time as possible when reaching the target.
+ * Consequently, this mode minimizes the use of gain.
+ */
+
+/**
+ * \class ExposureModeHelper::Minimize
+ * \brief Tag type to select mode of operation
+ *
+ * When an instance is constructed with this tag type, it will
+ * be initialized in a mode of operation where it tries to use
+ * as little exposure time as possible when reaching the target.
+ * Consequently, this mode maximizes the use of gain.
+ */
+
+/**
+ * \brief Construct an ExposureModeHelper in Maximize mode
+ */
+ExposureModeHelper::ExposureModeHelper(Maximize)
+	: mode_(Maximize{})
+{
+}
+
+/**
+ * \brief Construct an ExposureModeHelper in Minimize mode
+ */
+ExposureModeHelper::ExposureModeHelper(Minimize)
+	: mode_(Minimize{})
+{
+}
+
+/**
+ * \brief Construct an ExposureModeHelper instance in staged mode
  * \param[in] stages The vector of paired exposure time and gain limits
  *
- * The vector of stages may be empty. In that case, the helper will simply use
- * the runtime limits set through setLimits() instead.
+ * This overload initializes the object in such a mode where it uses the
+ * stages specified in \a stages to decide how much exposure time and gain
+ * should be used to reach a given target exposure.
+ *
+ * The vector of stages must not be empty.
  */
 ExposureModeHelper::ExposureModeHelper(std::vector<Stage> stages)
-	: stages_(std::move(stages)), lineDuration_(1us), minExposureTime_(0us),
-	  maxExposureTime_(0us), minGain_(0), maxGain_(0), sensorHelper_(nullptr)
+	: mode_(std::move(stages))
 {
+	ASSERT(!std::get_if<std::vector<Stage>>(&mode_)->empty());
 }
 
 /**
@@ -182,30 +222,11 @@ double ExposureModeHelper::clampGain(double gain, double *quantizationGain) cons
  * \param[in] exposure Exposure value
  *
  * This function divides a given exposure into exposure time, analogue and
- * digital gain by iterating through stages of exposure time and gain limits.
- * At each stage the current stage's exposure time limit is multiplied by the
- * previous stage's gain limit (or 1.0 initially) to see if the combination of
- * the two can meet the required exposure. If they cannot then the current
- * stage's exposure time limit is multiplied by the same stage's gain limit to
- * see if that combination can meet the required exposure time. If they cannot
- * then the function moves to consider the next stage.
- *
- * When a combination of exposure time and gain _stage_ limits are found that
- * are sufficient to meet the required exposure, the function attempts to reduce
- * exposure time as much as possible whilst fixing gain and still meeting the
- * exposure. If a _runtime_ limit prevents exposure time from being lowered
- * enough to meet the exposure with gain fixed at the stage limit, gain is also
- * lowered to compensate.
- *
- * Once the exposure time and gain values are ascertained, gain is assigned as
- * analogue gain as much as possible, with digital gain only in use if the
- * maximum analogue gain runtime limit is unable to accommodate the exposure
- * value.
+ * digital gain according to the mode of operation configured at construction time:
  *
- * If no combination of exposure time and gain limits is found that meets the
- * required exposure, then the exposure and gain of the last stage will be used,
- * unless there are no stages, in which case first the exposure, then the
- * analogue gain, then the digital gain will be maximized as necessary.
+ *   - ExposureModeHelper::ExposureModeHelper(Maximize)
+ *   - ExposureModeHelper::ExposureModeHelper(Maximize)
+ *   - ExposureModeHelper::ExposureModeHelper(std::vector<Stage>)
  *
  * During the calculations the gain missed due to quantization is recorded and
  * returned as quantization gain. The quantization gain is not included in the
@@ -229,8 +250,8 @@ ExposureModeHelper::splitExposure(utils::Duration exposure) const
 	bool exposureTimeFixed = minExposureTime_ == maxExposureTime_;
 
 	/*
-	 * There's no point entering the loop if we cannot change either gain
-	 * nor exposure time anyway.
+	 * There's no point entering further processing if we can change neither
+	 * gain nor exposure time anyway.
 	 */
 	if (exposureTimeFixed && gainFixed) {
 		exposureTime = clampExposureTime(minExposureTime_, &quantGain);
@@ -241,24 +262,78 @@ ExposureModeHelper::splitExposure(utils::Duration exposure) const
 			 exposure / (exposureTime * gain * quantGain) };
 	}
 
-	const double unitGain = clampGain(1.0);
+	return std::visit([&](const auto &mode) {
+		return splitExposure(exposure, mode);
+	}, mode_);
+}
 
-	/*
-	 * If no stages are present, max out the exposure time, then the
-	 * analogue gain, and use digital gain for the rest.
-	 */
-	if (stages_.empty()) {
-		exposureTime = clampExposureTime(exposure / unitGain, &quantGain);
-		gain = clampGain(exposure / exposureTime, &quantGain2);
-		quantGain *= quantGain2;
+ExposureModeHelper::Result
+ExposureModeHelper::splitExposure(utils::Duration exposure, Maximize) const
+{
+	double quantGain, quantGain2;
 
-		return { exposureTime, gain, quantGain,
-			 exposure / (exposureTime * gain * quantGain) };
-	}
+	utils::Duration exposureTime = clampExposureTime(exposure / clampGain(1.0), &quantGain);
+	double gain = clampGain(exposure / exposureTime, &quantGain2);
+	quantGain *= quantGain2;
+
+	return { exposureTime, gain, quantGain,
+		 exposure / (exposureTime * gain * quantGain) };
+}
+
+ExposureModeHelper::Result
+ExposureModeHelper::splitExposure(utils::Duration exposure, Minimize) const
+{
+	double quantGain, quantGain2;
+
+	double gain = clampGain(exposure / clampExposureTime(minExposureTime_), &quantGain2);
+	utils::Duration exposureTime = clampExposureTime(exposure / gain, &quantGain);
+	quantGain *= quantGain2;
+
+	return { exposureTime, gain, quantGain,
+		 exposure / (exposureTime * gain * quantGain) };
+}
+
+/**
+ * \brief Split exposure into exposure time and gain
+ * \param[in] exposure Exposure value
+ * \param[in] stages The list of exposure stages
+ *
+ * This function divides a given exposure into exposure time, analogue and
+ * digital gain by iterating through stages of exposure time and gain limits.
+ * At each stage the current stage's exposure time limit is multiplied by the
+ * previous stage's gain limit (or 1.0 initially) to see if the combination of
+ * the two can meet the required exposure. If they cannot then the current
+ * stage's exposure time limit is multiplied by the same stage's gain limit to
+ * see if that combination can meet the required exposure time. If they cannot
+ * then the function moves to consider the next stage.
+ *
+ * When a combination of exposure time and gain _stage_ limits are found that
+ * are sufficient to meet the required exposure, the function attempts to reduce
+ * exposure time as much as possible whilst fixing gain and still meeting the
+ * exposure. If a _runtime_ limit prevents exposure time from being lowered
+ * enough to meet the exposure with gain fixed at the stage limit, gain is also
+ * lowered to compensate.
+ *
+ * Once the exposure time and gain values are ascertained, gain is assigned as
+ * analogue gain as much as possible, with digital gain only in use if the
+ * maximum analogue gain runtime limit is unable to accommodate the exposure
+ * value.
+ *
+ * If no combination of exposure time and gain limits is found that meets the
+ * required exposure, then the exposure and gain of the last stage will be used.
+ */
+ExposureModeHelper::Result
+ExposureModeHelper::splitExposure(utils::Duration exposure, std::span<const Stage> stages) const
+{
+	ASSERT(!stages.empty());
 
-	double lastStageGain = unitGain;
+	utils::Duration exposureTime;
+	double gain;
+	double quantGain;
+	double quantGain2;
+	double lastStageGain = clampGain(1.0);
 
-	for (const auto &stage : stages_) {
+	for (const auto &stage : stages) {
 		utils::Duration stageExposureTime = clampExposureTime(stage.exposureTime,
 								      &quantGain);
 
@@ -299,8 +374,8 @@ ExposureModeHelper::splitExposure(utils::Duration exposure) const
 	 * If no stages could satisfy the desired exposure, then use the last
 	 * stage's exposure time and gain.
 	 */
-	exposureTime = clampExposureTime(stages_.back().exposureTime, &quantGain);
-	gain = clampGain(stages_.back().gain, &quantGain2);
+	exposureTime = clampExposureTime(stages.back().exposureTime, &quantGain);
+	gain = clampGain(stages.back().gain, &quantGain2);
 	quantGain *= quantGain2;
 
 	return { exposureTime, gain, quantGain,
diff --git a/src/ipa/libipa/exposure_mode_helper.h b/src/ipa/libipa/exposure_mode_helper.h
index a9398d3b4c..a6cfb83880 100644
--- a/src/ipa/libipa/exposure_mode_helper.h
+++ b/src/ipa/libipa/exposure_mode_helper.h
@@ -7,6 +7,8 @@
 
 #pragma once
 
+#include <span>
+#include <variant>
 #include <vector>
 
 #include <libcamera/base/utils.h>
@@ -24,7 +26,15 @@ public:
 		utils::Duration exposureTime;
 		double gain;
 	};
+	struct Maximize {
+		explicit Maximize() = default;
+	};
+	struct Minimize {
+		explicit Minimize() = default;
+	};
 
+	ExposureModeHelper(Maximize);
+	ExposureModeHelper(Minimize);
 	ExposureModeHelper(std::vector<Stage> stages);
 
 	void configure(utils::Duration lineLength, const CameraSensorHelper *sensorHelper);
@@ -45,14 +55,24 @@ private:
 					  double *quantizationGain = nullptr) const;
 	double clampGain(double gain, double *quantizationGain = nullptr) const;
 
-	std::vector<Stage> stages_;
-
-	utils::Duration lineDuration_;
-	utils::Duration minExposureTime_;
-	utils::Duration maxExposureTime_;
-	double minGain_;
-	double maxGain_;
-	const CameraSensorHelper *sensorHelper_;
+	[[nodiscard]] Result splitExposure(utils::Duration exposure,
+					   Maximize) const;
+	[[nodiscard]] Result splitExposure(utils::Duration exposure,
+					   Minimize) const;
+	[[nodiscard]] Result splitExposure(utils::Duration exposure,
+					   std::span<const Stage> stages) const;
+
+	std::variant<
+		Maximize,
+		Minimize,
+		std::vector<Stage>
+	> mode_;
+	utils::Duration lineDuration_ = std::chrono::microseconds(1);
+	utils::Duration minExposureTime_ = {};
+	utils::Duration maxExposureTime_ = {};
+	double minGain_ = 0;
+	double maxGain_ = 0;
+	const CameraSensorHelper *sensorHelper_ = nullptr;
 };
 
 } /* namespace ipa */
