#include #include #include #include #include #include #include #include #include using namespace target_exposure; namespace { struct Case { std::string group, name; std::function run; }; std::vector& cases() { static std::vector value; return value; } bool registerCase(const char* group, const char* name, std::function run) { cases().push_back({group, name, std::move(run)}); return true; } void check(bool value, const char* expression, int line) { if (!value) throw std::runtime_error("line " + std::to_string(line) + ": " + expression); } void near(double actual, double expected, double tolerance, int line) { if (!std::isfinite(actual) || std::abs(actual - expected) > tolerance) throw std::runtime_error("line " + std::to_string(line) + ": actual=" + std::to_string(actual) + ", expected=" + std::to_string(expected)); } #define CHECK(expression) check(static_cast(expression), #expression, __LINE__) #define NEAR(actual, expected, tolerance) near((actual), (expected), (tolerance), __LINE__) #define TEST(group, name) void group##_##name(); const bool reg_##group##_##name = registerCase(#group, #name, group##_##name); void group##_##name() FrameInput imageFrame(TimestampUs time, int gray = 100, bool target = true, double exposure = 1000) { FrameInput f; f.image = cv::Mat(10, 10, CV_8UC1, cv::Scalar(gray)); f.roi.rectangles.emplace_back(0, 0, 10, 10); f.timestamp_us = time; f.actual_exposure_us = exposure; f.has_usable_target = target; return f; } struct Fixture { Config c; ExposureControl module; TimestampUs time = 0; PreCaptureResult last_pre; Fixture() { c.min_exposure_us = 100; c.max_exposure_us = 10000; c.empirical_exposure_us = 1000; c.metering.min_target_pixels = 1; c.metering.min_retained_pixels = 1; c.metering.trim_fraction_each_side = 0; c.detect_confirm_frames = 1; c.prediction.enabled = false; c.prediction.photometric_model_confirmed = true; c.prediction.nominal_interval_us = 1'000'000; } FrameResult send(FrameInput f) { time = f.timestamp_us; last_pre = module.preCapture(time, c); CHECK(last_pre.accepted); auto result = module.processFrame(f); CHECK(result.accepted); return result; } FrameResult at(TimestampUs t, int gray = 100, bool target = true, double exposure = 1000) { return send(imageFrame(t, gray, target, exposure)); } FrameResult next(int gray = 100, bool target = true, double exposure = 1000) { return at(time + 1'000'000, gray, target, exposure); } void track(int gray = 118) { for (int i = 0; i < c.detect_confirm_frames; ++i) next(gray); CHECK(module.state() == State::TRACK); } }; TEST(metering, rectangle_union_deduplicates_pixels) { Fixture x; auto f = imageFrame(0); f.image = (cv::Mat_(1,4) << 10,100,30,200); f.roi.rectangles = {{0,0,2,1},{1,0,2,1}}; const auto r = x.send(f); CHECK(r.metering.target.pixels == 3); NEAR(*r.metering.target.gray, 140.0/3, 1e-10); NEAR(*r.metering.global.gray, 85, 1e-10); CHECK(f.image.at(0,1) == 100); } TEST(metering, disjoint_regions_do_not_use_enclosing_box) { Fixture x; auto f = imageFrame(0); f.image = (cv::Mat_(1,4) << 10,100,30,200); f.roi.rectangles = {{0,0,1,1},{3,0,1,1}}; const auto r = x.send(f); CHECK(r.metering.target.pixels == 2); NEAR(*r.metering.target.gray, 105, 1e-10); } TEST(metering, rotated_mask_and_rectangle_union) { Fixture x; auto f = imageFrame(0); f.image = cv::Mat(4,4,CV_8UC1,cv::Scalar(255)); f.image.at(1,1)=20; f.image.at(2,2)=60; f.image.at(0,3)=100; f.roi.rectangles = {{1,1,1,1}}; f.roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{3,1},45.0f); cv::Mat mask(4,4,CV_8UC1,cv::Scalar(0)); mask.at(2,2)=1; mask.at(0,3)=128; f.roi.masks = {mask,mask}; const auto r=x.send(f); CHECK(r.metering.target.pixels==3); NEAR(*r.metering.target.gray,60,1e-10); } TEST(metering, trim_removes_both_five_percent_tails) { Fixture x; x.c.metering.trim_fraction_each_side=.05; auto f=imageFrame(0); f.image=cv::Mat(1,20,CV_8UC1,cv::Scalar(100)); f.image.at(0,0)=0; f.image.at(0,19)=255; f.roi.rectangles={{0,0,20,1}}; const auto r=x.send(f); CHECK(r.metering.target.pixels==20 && r.metering.target.retained_pixels==18); NEAR(*r.metering.target.gray,100,1e-10); NEAR(*r.metering.global.gray,102.75,1e-10); } TEST(metering, clipping_empty_invalid_and_insufficient) { Fixture x; auto f=imageFrame(0); f.roi.rectangles={{-1,-1,2,2}}; auto r=x.send(f); CHECK(r.metering.target.pixels==1); f.timestamp_us++; f.roi.rectangles={{50,50,1,1}}; r=x.send(f); CHECK(r.metering.target.status==MeterStatus::EmptyRoi && !r.metering.target.gray); f.timestamp_us++; f.roi.rectangles.clear(); r=x.send(f); CHECK(r.metering.target.status==MeterStatus::EmptyRoi); f.timestamp_us++; f.roi.rectangles={{0,0,-1,1}}; r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InvalidRoi); f.timestamp_us++; f.roi.rectangles={{0,0,10,10}}; f.roi.masks={cv::Mat(1,1,CV_8UC1)}; r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InvalidRoi); x.c.metering.min_target_pixels=101; f.timestamp_us++; f.roi.masks.clear(); r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InsufficientPixels); CHECK(!r.adjustment_suggested && !r.metering.target.gray); } TEST(metering, target_flag_and_real_zero_are_distinct) { Fixture x; auto r=x.next(0,false); CHECK(r.metering.target.status==MeterStatus::NoTarget && !r.metering.target.gray); CHECK(r.valid_history_count==0 && r.metering.global.valid()); r=x.next(0,true); CHECK(r.metering.target.valid()); NEAR(*r.metering.target.gray,0,0); CHECK(r.valid_history_count==1); } TEST(metering, bgr_strides_and_fixed_central_global_region) { Fixture x; auto f=imageFrame(0); cv::Mat padded(6,6,CV_8UC3,cv::Scalar(255,0,0)); f.image=padded(cv::Rect{1,1,4,4}); f.roi.rectangles={{0,0,4,4}}; auto r=x.send(f); NEAR(*r.metering.target.gray,29,0); x.c.metering.global_center_fraction=.5; f=imageFrame(1); f.image.setTo(200); f.image(cv::Rect{2,2,5,5}).setTo(80); r=x.send(f); NEAR(*r.metering.global.gray,80,0); CHECK(r.metering.global.pixels==25); } TEST(metering, explicit_inset_and_unsupported_image) { const auto shrunk=inset(cv::Rect{0,0,10,10},.1); CHECK(shrunk==cv::Rect(1,1,8,8)); Fixture x; auto f=imageFrame(0); f.roi.rectangles={{0,0,10,10}}; CHECK(x.send(f).metering.target.pixels==100); f.timestamp_us++; f.image=cv::Mat(10,10,CV_16UC3,cv::Scalar(100,100,100)); auto r=x.send(f); CHECK(r.metering.target.status==MeterStatus::UnsupportedImage && !r.adjustment_suggested); f.timestamp_us++; f.image.release(); r=x.send(f); CHECK(r.metering.global.status==MeterStatus::EmptyImage); } TEST(metering, retained_sample_limit_and_extreme_coordinates) { Fixture x; x.c.metering.trim_fraction_each_side=.05; x.c.metering.min_retained_pixels=100; auto f=imageFrame(0); auto r=x.send(f); CHECK(r.metering.target.retained_pixels==90 && r.metering.target.status==MeterStatus::InsufficientPixels); f.timestamp_us++; f.roi.rectangles={{std::numeric_limits::max(),0,std::numeric_limits::max(),1}}; r=x.send(f); CHECK(r.metering.target.status==MeterStatus::EmptyRoi); f.timestamp_us++; f.roi.rectangles.clear(); f.roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{3,1},std::numeric_limits::quiet_NaN()); r=x.send(f); CHECK(r.metering.target.status==MeterStatus::InvalidRoi); } TEST(stateless, ordinary_and_trimmed_means_share_known_samples) { cv::Mat image(1,20,CV_8UC1,cv::Scalar(100)); image.at(0,0)=0; image.at(0,19)=255; Roi roi; roi.rectangles={{0,0,20,1}}; const auto r=measureImage(image,roi,true); CHECK(r.target.valid() && r.global.valid()); CHECK(r.target.pixels==20 && r.target.retained_pixels==18); NEAR(*r.target.arithmetic_gray,102.75,0); NEAR(*r.target.gray,100,0); NEAR(*r.global.arithmetic_gray,102.75,0); NEAR(*r.global.gray,102.75,0); NEAR(r.target.near_black_fraction,.05,0); NEAR(r.target.saturated_fraction,.05,0); CHECK(image.at(0,0)==0 && image.at(0,19)==255); } TEST(stateless, roi_union_mixed_shapes_and_masks) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; cv::Mat image(4,4,CV_8UC1,cv::Scalar(255)); image.at(1,1)=20; image.at(2,2)=60; image.at(0,3)=100; Roi roi; roi.rectangles={{1,1,1,1},{1,1,1,1}}; roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{3,1},45.0f); cv::Mat mask(4,4,CV_8UC1,cv::Scalar(0)); mask.at(2,2)=1; mask.at(0,3)=128; roi.masks={mask,mask}; const auto r=measureImage(image,roi,true,c); CHECK(r.target.pixels==3 && r.target.valid()); NEAR(*r.target.arithmetic_gray,60,0); NEAR(*r.target.gray,60,0); NEAR(*r.global.arithmetic_gray,(180.0+13*255)/16,0); CHECK(cv::countNonZero(mask)==2 && mask.at(0,3)==128); } TEST(stateless, absent_region_is_distinct_from_black_and_saturated) { cv::Mat image(8,8,CV_8UC1,cv::Scalar(0)); Roi roi; roi.rectangles={{0,0,8,8}}; auto r=measureImage(image,roi,false); CHECK(r.target.status==MeterStatus::NoTarget && !r.target.arithmetic_gray); CHECK(r.global.valid()); NEAR(*r.global.arithmetic_gray,0,0); r=measureImage(image,roi,true); CHECK(r.target.valid()); NEAR(*r.target.arithmetic_gray,0,0); NEAR(r.target.near_black_fraction,1,0); image.setTo(255); r=measureImage(image,roi,true); CHECK(r.target.valid()); NEAR(*r.target.arithmetic_gray,255,0); NEAR(r.target.saturated_fraction,1,0); } TEST(stateless, invalid_roi_and_images_keep_explicit_missing_values) { const cv::Mat image(8,8,CV_8UC1,cv::Scalar(90)); Roi roi; auto r=measureImage(image,roi,true); CHECK(r.target.status==MeterStatus::EmptyRoi && !r.target.arithmetic_gray && r.global.valid()); roi.rectangles={{0,0,1,1}}; r=measureImage(image,roi,true); CHECK(r.target.status==MeterStatus::InsufficientPixels && !r.target.arithmetic_gray); roi.rectangles={{0,0,-1,1}}; r=measureImage(image,roi,true); CHECK(r.target.status==MeterStatus::InvalidRoi && r.global.valid()); roi.rectangles.clear(); roi.masks={cv::Mat(1,1,CV_8UC1,cv::Scalar(1))}; r=measureImage(image,roi,true); CHECK(r.target.status==MeterStatus::InvalidRoi && !r.target.arithmetic_gray); roi.masks={cv::Mat(8,8,CV_32FC1,cv::Scalar(1))}; CHECK(measureImage(image,roi,true).target.status==MeterStatus::InvalidRoi); roi.masks.clear(); roi.rotated_rectangles.emplace_back(cv::Point2f{2,2},cv::Size2f{2,2},std::numeric_limits::infinity()); CHECK(measureImage(image,roi,true).target.status==MeterStatus::InvalidRoi); r=measureImage({},roi,true); CHECK(r.global.status==MeterStatus::EmptyImage && !r.global.arithmetic_gray); r=measureImage(cv::Mat(8,8,CV_16UC3),roi,true); CHECK(r.global.status==MeterStatus::UnsupportedImage && !r.global.arithmetic_gray); const int sizes[]={2,2,2}; r=measureImage(cv::Mat(3,sizes,CV_8UC1,cv::Scalar(20)),roi,true); CHECK(r.global.status==MeterStatus::UnsupportedImage); } TEST(stateless, invalid_config_is_rejected_before_image_or_roi_access) { const std::vector> corrupt={ [](MeteringConfig& c){c.trim_fraction_each_side=-.1;}, [](MeteringConfig& c){c.trim_fraction_each_side=.5;}, [](MeteringConfig& c){c.trim_fraction_each_side=std::numeric_limits::quiet_NaN();}, [](MeteringConfig& c){c.min_target_pixels=0;}, [](MeteringConfig& c){c.min_retained_pixels=0;}, [](MeteringConfig& c){c.global_center_fraction=0;}, [](MeteringConfig& c){c.global_center_fraction=1.1;}, [](MeteringConfig& c){c.global_center_fraction=std::numeric_limits::infinity();}, [](MeteringConfig& c){c.near_black_level=-1;}, [](MeteringConfig& c){c.saturation_level=256;}, [](MeteringConfig& c){c.saturation_level=c.near_black_level;} }; for(const auto& mutate:corrupt) { MeteringConfig c; mutate(c); const auto r=measureImage({}, {}, false, c); CHECK(r.target.status==MeterStatus::InvalidConfiguration && r.global.status==MeterStatus::InvalidConfiguration); CHECK(!r.target.gray && !r.target.arithmetic_gray && !r.global.arithmetic_gray); Fixture x; x.c.metering=c; CHECK(!validate(x.c).empty()); } CHECK(std::string(toString(MeterStatus::InvalidConfiguration))=="InvalidConfiguration"); } TEST(stateless, bgr_strides_and_global_center_are_supported_without_controller) { cv::Mat padded(6,6,CV_8UC3,cv::Scalar(255,0,0)); const cv::Mat image=padded(cv::Rect{1,1,4,4}); Roi roi; roi.rectangles={{0,0,4,4}}; auto r=measureImage(image,roi,true); CHECK(r.target.valid()); NEAR(*r.target.arithmetic_gray,29,0); cv::Mat gray(10,10,CV_8UC1,cv::Scalar(200)); gray(cv::Rect{2,2,5,5}).setTo(80); MeteringConfig c; c.global_center_fraction=.5; r=measureImage(gray,{},false,c); CHECK(r.global.pixels==25); NEAR(*r.global.arithmetic_gray,80,0); } TEST(stateless, repeats_are_deterministic_and_leave_controller_trajectory_unchanged) { Fixture a,b; a.track(); b.track(); const auto frame=imageFrame(a.time+1'000'000,80); const auto first=measureImage(frame.image,frame.roi,true,a.c.metering); const auto history=a.module.history(); const auto state=a.module.state(); for(int i=0;i<8;++i) { const auto r=measureImage(frame.image,frame.roi,true,a.c.metering); CHECK(r.target.pixels==first.target.pixels && r.target.gray==first.target.gray && r.target.arithmetic_gray==first.target.arithmetic_gray && r.global.gray==first.global.gray); measureImage(frame.image,{},false); } CHECK(a.module.state()==state && a.module.history().size()==history.size()); CHECK(a.module.history().back().timestamp_us==history.back().timestamp_us); for(int i=0;i<3;++i) { const auto actual=a.next(80), control=b.next(80); CHECK(actual.state==control.state && actual.reason==control.reason && actual.suggested_exposure_us==control.suggested_exposure_us && actual.control.integral==control.control.integral && actual.control.same_direction_count==control.control.same_direction_count && actual.valid_history_count==control.valid_history_count); CHECK(actual.metering.target.gray==first.target.gray && actual.metering.target.arithmetic_gray==first.target.arithmetic_gray); } } TEST(stateless, gray16_preserves_source_precision_and_normalizes_after_statistics) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=.25; // 1000 and 1001 must not be rounded to the same 8-bit intensity before averaging. const cv::Mat image=(cv::Mat_(1,4)<<0,1000,1001,65535); Roi roi; roi.rectangles={{0,0,4,1}}; const auto r=measureImage(image,roi,true,c); CHECK(r.target.valid() && r.global.valid()); CHECK(r.target.source_bit_depth==16 && r.global.source_max_gray==65535.0); NEAR(*r.target.source_arithmetic_gray,16884.0,0); NEAR(*r.global.source_arithmetic_gray,16884.0,0); NEAR(*r.target.arithmetic_gray,16884.0*255/65535,1e-12); NEAR(*r.target.gray,1000.5*255/65535,1e-12); CHECK(r.target.retained_pixels==2 && r.target.pixels==4); NEAR(r.target.near_black_fraction,.25,0); NEAR(r.target.saturated_fraction,.25,0); CHECK(image.at(0,2)==1001); } TEST(stateless, gray16_threshold_boundaries_black_saturation_and_stride) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=0; cv::Mat padded(4,8,CV_16UC1,cv::Scalar(9999)); cv::Mat image=padded(cv::Rect{1,1,6,2}); const unsigned short samples[]={0,514,515,65020,65021,65535}; for(int y=0;y(y,x)=samples[x]; Roi roi; roi.rectangles={{0,0,6,2}}; const auto r=measureImage(image,roi,true,c); NEAR(r.target.near_black_fraction,1.0/3,0); NEAR(r.target.saturated_fraction,1.0/3,0); NEAR(*r.target.source_arithmetic_gray,196605.0/6,0); CHECK(padded.at(0,0)==9999 && !image.isContinuous()); image.setTo(0); const auto black=measureImage(image,{},false); CHECK(black.target.status==MeterStatus::NoTarget && black.target.source_bit_depth==16); NEAR(*black.global.source_arithmetic_gray,0,0); NEAR(black.global.near_black_fraction,1,0); image.setTo(65535); const auto bright=measureImage(image,roi,true,c); NEAR(*bright.target.gray,255,0); NEAR(*bright.target.source_arithmetic_gray,65535,0); NEAR(bright.target.saturated_fraction,1,0); } TEST(stateless, valid_image_mask_intersects_union_and_global_without_mutation) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.trim_fraction_each_side=0; const cv::Mat image8=(cv::Mat_(2,4)<<0,10,20,30,40,50,60,255); const cv::Mat valid=(cv::Mat_(2,4)<<0,1,128,0,2,255,0,0); Roi roi; roi.rectangles={{0,0,2,2},{1,0,2,1}}; roi.rotated_rectangles.emplace_back(cv::Point2f{2,1},cv::Size2f{2,2},0.0f); cv::Mat part(2,4,CV_8UC1,cv::Scalar(0)); part.at(1,1)=1; roi.masks={part,part}; for(int depth : {CV_8U,CV_16U}) { cv::Mat image; image8.convertTo(image,depth,depth==CV_16U?257:1); const auto r=measureImage(image,roi,true,c,valid); CHECK(r.global.pixels==4 && r.target.pixels==4); NEAR(*r.global.arithmetic_gray,30,1e-12); NEAR(*r.target.arithmetic_gray,30,1e-12); NEAR(*r.target.source_arithmetic_gray,depth==CV_16U?7710:30,0); CHECK(cv::countNonZero(valid)==4 && valid.at(0,2)==128); CHECK(cv::countNonZero(part)==1 && part.at(1,1)==1); CHECK(image8.at(1,3)==255); const auto repeat=measureImage(image,roi,true,c,valid); CHECK(repeat.target.source_arithmetic_gray==r.target.source_arithmetic_gray); } } TEST(stateless, valid_image_mask_keeps_target_independent_of_global_center) { MeteringConfig c; c.min_target_pixels=1; c.min_retained_pixels=1; c.global_center_fraction=.5; cv::Mat image(4,4,CV_8UC1,cv::Scalar(200)); image.at(1,1)=20; image.at(0,0)=80; cv::Mat valid(4,4,CV_8UC1,cv::Scalar(0)); valid.at(1,1)=1; valid.at(0,0)=1; Roi roi; roi.rectangles={{0,0,1,1}}; const auto r=measureImage(image,roi,true,c,valid); CHECK(r.global.pixels==1 && r.target.pixels==1); NEAR(*r.global.arithmetic_gray,20,0); NEAR(*r.target.arithmetic_gray,80,0); CHECK(r.target.source_bit_depth==8 && r.target.source_max_gray==255.0); } TEST(stateless, invalid_valid_image_masks_and_empty_intersections_are_explicit) { const cv::Mat image(8,8,CV_16UC1,cv::Scalar(1001)); Roi roi; roi.rectangles={{0,0,4,8}}; const int sizes[]={2,4,8}; const std::vector invalid={cv::Mat(7,8,CV_8UC1,cv::Scalar(1)), cv::Mat(8,8,CV_16UC1,cv::Scalar(1)),cv::Mat(8,8,CV_8UC3,cv::Scalar(1,1,1)), cv::Mat(3,sizes,CV_8UC1,cv::Scalar(1))}; for(const auto& mask:invalid) { const auto r=measureImage(image,roi,true,{},mask); CHECK(r.target.status==MeterStatus::InvalidRoi && r.global.status==MeterStatus::InvalidRoi); CHECK(!r.target.arithmetic_gray && !r.global.source_arithmetic_gray); const auto noTarget=measureImage(image,{},false,{},mask); CHECK(noTarget.target.status==MeterStatus::NoTarget && noTarget.global.status==MeterStatus::InvalidRoi); } cv::Mat valid(8,8,CV_8UC1,cv::Scalar(0)); auto r=measureImage(image,roi,true,{},valid); CHECK(r.global.status==MeterStatus::EmptyRoi && r.target.status==MeterStatus::EmptyRoi); CHECK(!r.global.source_arithmetic_gray && r.global.pixels==0 && r.global.source_bit_depth==16); valid(cv::Rect{4,0,4,8}).setTo(255); r=measureImage(image,roi,true,{},valid); CHECK(r.global.valid() && r.target.status==MeterStatus::EmptyRoi); NEAR(*r.global.source_arithmetic_gray,1001,0); valid.setTo(0); valid.at(0,0)=1; r=measureImage(image,roi,true,{},valid); CHECK(r.global.valid() && r.target.status==MeterStatus::InsufficientPixels); CHECK(!r.target.source_arithmetic_gray && !r.target.arithmetic_gray); const auto defaultMask=measureImage(image,roi,true); const auto explicitEmptyMask=measureImage(image,roi,true,{},{}); CHECK(defaultMask.global.pixels==64 && defaultMask.global.source_arithmetic_gray==explicitEmptyMask.global.source_arithmetic_gray); } TEST(stateless, gray16_and_gray8_equivalent_scale_preserves_control_trajectory) { Fixture a,b; for(int gray : {118,80,40,200,118}) { auto frame8=imageFrame(a.time+1'000'000,gray); auto frame16=frame8; frame8.image.convertTo(frame16.image,CV_16U,257); const auto r8=a.send(frame8), r16=b.send(frame16); CHECK(r8.state==r16.state && r8.reason==r16.reason); NEAR(*r8.metering.target.gray,*r16.metering.target.gray,1e-12); CHECK(r8.adjustment_suggested==r16.adjustment_suggested); if(r8.suggested_exposure_us) NEAR(*r8.suggested_exposure_us,*r16.suggested_exposure_us,1e-10); } } TEST(history, window_inclusive_boundary_and_filtering) { Fixture x; x.c.history_window_us=3'000'000; x.c.idle_timeout_us=100'000'000; x.at(0); x.at(1'000'000); x.at(3'000'000); auto r=x.at(4'000'000); CHECK(r.valid_history_count==3); CHECK(x.module.history().front().timestamp_us==1'000'000); r=x.at(4'000'001,0); // zero gray is still a real valid target CHECK(x.module.history().front().timestamp_us==3'000'000); } TEST(history, five_points_are_not_silently_replaced_by_three) { Fixture x; x.c.prediction.enabled=true; x.c.prediction.points=5; for(int i=0;i<4;++i) CHECK(!x.next(90+5*i).prediction.available); const auto r=x.next(110); CHECK(r.prediction.available && r.prediction.points_used==5); NEAR(*r.prediction.predicted_gray,115,1e-9); } TEST(history, invalid_frames_do_not_evict_recent_valid_targets) { Fixture x; x.c.prediction.enabled=true; x.c.history_capacity=3; x.next(); x.next(); x.next(); for(int i=0;i<10;++i) x.next(30,false); const auto r=x.next(100,true); CHECK(r.valid_history_count==3 && r.prediction.available); CHECK(x.module.history().size()==3); NEAR(*r.prediction.predicted_gray,100,1e-9); } TEST(history, actual_exposure_normalization_with_black_offset) { Fixture x; x.c.prediction.enabled=true; x.c.prediction.black_level=10; x.at(0,50,true,400); x.at(1'000'000,90,true,800); const auto r=x.at(4'000'000,170,true,1600); CHECK(r.prediction.available); NEAR(*r.prediction.normalized_slope_per_second,0,1e-12); NEAR(*r.prediction.predicted_gray,170,1e-9); NEAR(x.module.history().back().actual_exposure_us,1600,0); } TEST(prediction, unequal_time_intervals_and_explicit_horizon) { Fixture x; x.c.prediction.enabled=true; x.at(0,100); x.at(1'000'000,110); auto f=imageFrame(4'000'000,140); f.next_capture_timestamp_us=6'000'000; const auto r=x.send(f); CHECK(r.prediction.available && r.prediction.adopted); NEAR(*r.prediction.normalized_slope_per_second,.01,1e-12); NEAR(*r.prediction.predicted_gray,160,1e-9); } TEST(prediction, large_timestamp_epoch_does_not_change_fit) { Fixture x; x.c.prediction.enabled=true; const TimestampUs epoch=1'800'000'000'000'000; x.at(epoch,100); x.at(epoch+1'000'000,110); auto f=imageFrame(epoch+4'000'000,140); f.next_capture_timestamp_us=epoch+6'000'000; const auto r=x.send(f); CHECK(r.prediction.available); NEAR(*r.prediction.predicted_gray,160,1e-9); } FrameResult trendResult(bool rising, bool enabled) { Fixture x; x.c.prediction.enabled=enabled; x.c.controller.ki=0; x.c.controller.same_direction_frames=1; x.at(0,rising?80:120); x.at(1'000'000,rising?90:110); return x.at(2'000'000,100); } TEST(prediction, history_changes_exposure_and_is_not_double_compensated) { const auto rise=trendResult(true,true), fall=trendResult(false,true); CHECK(rise.prediction.adopted && fall.prediction.adopted); NEAR(*rise.prediction.predicted_gray,110,1e-9); NEAR(*fall.prediction.predicted_gray,90,1e-9); CHECK(!rise.adjustment_suggested && fall.adjustment_suggested); NEAR(*rise.suggested_exposure_us,1000,1e-9); NEAR(*fall.suggested_exposure_us,1000*(1+.4*(118-90)/118.0),1e-9); const auto no_rise=trendResult(true,false), no_fall=trendResult(false,false); NEAR(*no_rise.suggested_exposure_us,*no_fall.suggested_exposure_us,1e-9); NEAR(*no_fall.suggested_exposure_us,1000*(1+.4*(118-100)/118.0),1e-9); } TEST(prediction, insufficient_missing_horizon_and_model_fallback) { Fixture x; x.c.prediction.enabled=true; x.c.prediction.nominal_interval_us=0; CHECK(x.next(100).prediction.reason==PredictionReason::InsufficientHistory); x.next(100); auto r=x.next(100); CHECK(r.prediction.reason==PredictionReason::MissingHorizon); CHECK(!r.prediction.adopted && r.control.control_error_ratio.has_value()); x.c.prediction.photometric_model_confirmed=false; r=x.next(100); CHECK(r.prediction.reason==PredictionReason::ModelNotConfirmed); } TEST(prediction, reject_clipping_bad_fit_and_excessive_horizon) { Fixture x; x.c.prediction.enabled=true; x.next(100); x.next(100); auto r=x.next(255); CHECK(r.prediction.reason==PredictionReason::DarkOrSaturated && !r.prediction.adopted); Fixture y; y.c.prediction.enabled=true; y.c.prediction.max_relative_fit_rmse=.01; y.next(100); y.next(200); r=y.next(100); CHECK(r.prediction.reason==PredictionReason::ResidualTooLarge && !r.prediction.adopted); auto f=imageFrame(y.time+1'000'000,100); f.next_capture_timestamp_us=f.timestamp_us+61'000'000; r=y.send(f); CHECK(r.prediction.reason==PredictionReason::InvalidHorizon); Fixture z; z.c.prediction.enabled=true; z.next(10); z.next(100); r=z.next(190); CHECK(r.prediction.reason==PredictionReason::OutOfRange && !r.prediction.adopted); } TEST(controller, large_error_is_immediate_but_step_limited) { Fixture x; x.track(); const auto r=x.next(40); CHECK(r.reason==Reason::ProportionalAdjustment && r.adjustment_suggested); NEAR(*r.suggested_exposure_us,1250,1e-9); CHECK(r.control.step_limited && r.control.same_direction_count==1); NEAR(r.control.integral,0,0); } TEST(controller, small_error_confirmation_pi_and_integral_limit) { Fixture x; x.c.controller.target_gray=100; x.c.controller.integral_abs_limit=.3; x.track(100); auto r=x.next(80); CHECK(r.reason==Reason::ConfirmingError); NEAR(r.control.integral,0,0); r=x.next(80); NEAR(r.control.integral,.2,1e-12); NEAR(*r.suggested_exposure_us,1100,1e-9); r=x.next(80); NEAR(r.control.integral,.3,1e-12); NEAR(*r.suggested_exposure_us,1110,1e-9); r=x.next(80); NEAR(r.control.integral,.3,1e-12); r=x.next(100); CHECK(r.reason==Reason::Deadband); NEAR(r.control.integral,0,0); r=x.next(100,false); NEAR(r.control.integral,0,0); } TEST(controller, hysteresis_boundary_and_active_band) { Fixture x; x.c.controller.target_gray=100; x.c.controller.same_direction_frames=1; x.track(100); CHECK(x.next(89).reason==Reason::Hysteresis); CHECK(x.next(88).adjustment_suggested); CHECK(x.next(89).adjustment_suggested); CHECK(x.next(91).reason==Reason::Deadband); CHECK(x.next(89).reason==Reason::Hysteresis); } TEST(controller, opposite_direction_requires_new_confirmation) { Fixture x; x.c.controller.target_gray=100; x.track(100); CHECK(!x.next(80).adjustment_suggested); auto r=x.next(120); CHECK(!r.adjustment_suggested && r.control.same_direction_count==1); r=x.next(120); CHECK(r.adjustment_suggested && *r.suggested_exposure_us<1000); } TEST(controller, exposure_limits_and_step_limits_block_windup) { Fixture x; x.c.controller.target_gray=100; x.c.controller.same_direction_frames=1; x.c.max_exposure_us=1100; x.track(100); auto r=x.next(80,true,1090); NEAR(*r.suggested_exposure_us,1100,1e-9); CHECK(r.control.integral_blocked_by_limit && r.control.exposure_limited); NEAR(r.control.integral,0,0); r=x.next(80,true,1100); CHECK(!r.adjustment_suggested && r.reason==Reason::ExposureLimit); NEAR(r.control.integral,0,0); Fixture y; y.c.controller.target_gray=100; y.c.controller.same_direction_frames=1; y.c.controller.kp=1; y.c.controller.ki=1; y.track(100); r=y.next(70); CHECK(r.control.integral_blocked_by_limit && r.control.step_limited); NEAR(r.control.integral,0,0); NEAR(*r.suggested_exposure_us,1250,1e-9); Fixture z; z.c.min_exposure_us=900; z.c.controller.target_gray=100; z.c.controller.same_direction_frames=1; z.track(100); r=z.next(130,true,900); CHECK(!r.adjustment_suggested && r.control.integral_blocked_by_limit); } TEST(controller, track_ignores_global_background) { const auto calculate=[](int background) { Fixture x; x.c.controller.same_direction_frames=1; x.track(); auto f=imageFrame(x.time+1'000'000,background); f.roi.rectangles={{4,4,2,2}}; f.image(cv::Rect{4,4,2,2}).setTo(80); return x.send(f); }; const auto a=calculate(0), b=calculate(240); CHECK(*a.metering.global.gray!=*b.metering.global.gray); NEAR(*a.suggested_exposure_us,*b.suggested_exposure_us,0); } TEST(states, capture_track_lost_freeze_and_capture) { Fixture x; x.c.detect_confirm_frames=3; CHECK(x.next().state==State::CAPTURE); CHECK(x.next().state==State::CAPTURE); auto r=x.next(); CHECK(r.state==State::TRACK && !r.adjustment_suggested); CHECK(x.next(30,false).state==State::TRACK); CHECK(x.next(30,false).state==State::TRACK); r=x.next(30,false); CHECK(r.state==State::LOST && r.freeze_remaining==2); r=x.next(30,false); CHECK(r.state==State::LOST && r.freeze_remaining==1); r=x.next(30,false); CHECK(r.state==State::CAPTURE && r.freeze_remaining==0 && !r.adjustment_suggested); } TEST(states, short_loss_and_reacquisition_keep_exposure_and_history) { Fixture x; x.c.detect_confirm_frames=3; x.track(); auto r=x.next(50,false,2345); CHECK(r.state==State::TRACK && !x.last_pre.initialization_required); NEAR(*r.suggested_exposure_us,2345,0); CHECK(r.valid_history_count==3); x.next(50,false); x.next(50,false); CHECK(x.module.state()==State::LOST); CHECK(x.next(100).state==State::LOST); CHECK(x.next(100).state==State::LOST); r=x.next(100); CHECK(r.state==State::TRACK && r.reason==Reason::TrackingActivated); CHECK(!x.last_pre.initialization_required && r.valid_history_count==6); } TEST(states, bad_roi_is_not_a_detection_failure) { Fixture x; x.track(); auto f=imageFrame(x.time+1'000'000); f.roi.rectangles.clear(); const auto r=x.send(f); CHECK(r.state==State::TRACK && r.loss_count==0); CHECK(r.reason==Reason::InvalidMetering && !r.adjustment_suggested); } TEST(states, freeze_counts_new_frames_even_with_intermittent_detections) { Fixture x; x.c.detect_confirm_frames=3; x.track(); x.next(20,false); x.next(20,false); x.next(20,false); const auto r=x.next(100,true); CHECK(r.state==State::LOST && r.freeze_remaining==1); CHECK(x.next(20,false).state==State::CAPTURE); } FrameResult missingWithPermission(Fixture& x,int gray=20) { auto f=imageFrame(x.time+1'000'000,gray,false); f.allow_no_target_recovery=true; return x.send(f); } TEST(recovery, default_denial_and_single_recovery_per_episode) { Fixture x; x.c.detect_confirm_frames=3; x.c.recovery.global_reference_gray=100; auto r=x.next(20,false); CHECK(r.reason==Reason::RecoveryNotAllowed && !r.recovery_used); r=missingWithPermission(x); CHECK(r.reason==Reason::RecoverySuggested && r.recovery_used); NEAR(*r.suggested_exposure_us,1100,1e-9); for(int i=0;i<10;++i) { r=missingWithPermission(x); CHECK(!r.adjustment_suggested && r.reason==Reason::NotRecovered); } x.next(100,true); x.next(100,true); r=missingWithPermission(x); CHECK(r.recovery_used && !r.adjustment_suggested); x.next(100,true); x.next(100,true); r=x.next(100,true); CHECK(!r.recovery_used && !r.loss_episode_active); } TEST(recovery, freeze_finishes_before_recovery_and_no_scanning) { Fixture x; x.c.recovery.global_reference_gray=100; x.track(); for(int i=0;i<3;++i) CHECK(!missingWithPermission(x).adjustment_suggested); CHECK(x.module.state()==State::LOST); for(int i=0;i<2;++i) CHECK(!missingWithPermission(x).adjustment_suggested); CHECK(x.module.state()==State::CAPTURE); CHECK(missingWithPermission(x).reason==Reason::RecoverySuggested); CHECK(missingWithPermission(x).reason==Reason::NotRecovered); } TEST(recovery, automatic_initialization_and_manual_reset_do_not_rearm) { Fixture x; x.c.detect_confirm_frames=3; x.c.recovery.global_reference_gray=100; CHECK(missingWithPermission(x).recovery_used); x.c.revision++; auto r=missingWithPermission(x); CHECK(x.last_pre.initialization_required && r.recovery_used && !r.adjustment_suggested); x.module.reset(ResetReason::Manual); r=missingWithPermission(x); CHECK(r.recovery_used && !r.adjustment_suggested); auto pre=x.module.preCapture(x.time+86'400'000'000LL,x.c); CHECK(pre.initialization_required); x.time+=86'400'000'000LL; r=missingWithPermission(x); CHECK(r.recovery_used && !r.adjustment_suggested); x.module.reset(ResetReason::NewBusinessCycle); r=missingWithPermission(x); CHECK(r.reason==Reason::RecoverySuggested); } TEST(recovery, missing_reference_normal_global_invalid_frame_and_limit) { Fixture x; auto r=missingWithPermission(x); CHECK(r.reason==Reason::RecoveryReferenceMissing && !r.recovery_used); x.c.recovery.global_reference_gray=100; r=missingWithPermission(x,100); CHECK(r.reason==Reason::RecoveryNotNeeded && !r.recovery_used); auto f=imageFrame(x.time+1'000'000,20,false); f.allow_no_target_recovery=true; f.image.release(); r=x.send(f); CHECK(r.reason==Reason::InvalidMetering && !r.recovery_used); f=imageFrame(x.time+1'000'000,20,false,x.c.max_exposure_us); f.allow_no_target_recovery=true; r=x.send(f); CHECK(r.reason==Reason::ExposureLimit && r.recovery_used && !r.adjustment_suggested); CHECK(missingWithPermission(x).reason==Reason::NotRecovered); } TEST(recovery, history_expiry_cannot_refresh_consumed_opportunity) { Fixture x; x.c.recovery.global_reference_gray=100; x.c.history_window_us=10'000'000; x.c.idle_timeout_us=100'000'000; x.track(); for(int i=0;i<5;++i) missingWithPermission(x); CHECK(missingWithPermission(x).reason==Reason::RecoverySuggested); int initializations=0; for(int i=0;i<20;++i) { const auto r=missingWithPermission(x); if(x.last_pre.initialization_required) { ++initializations; CHECK(x.last_pre.initialization_reason==ResetReason::HistoryExpired); } CHECK(r.recovery_used && !r.adjustment_suggested); } CHECK(initializations==1); } TEST(lifecycle, first_start_and_repeated_prechecks_initialize_once) { Fixture x; auto p=x.module.preCapture(0,x.c); CHECK(p.accepted && p.initialization_required && p.initialization_reason==ResetReason::Startup); NEAR(*p.suggested_exposure_us,1000,0); CHECK(!x.module.preCapture(0,x.c).initialization_required); CHECK(!x.module.preCapture(900'000'000,x.c).initialization_required); } TEST(lifecycle, overnight_checks_before_capture_once) { Fixture x; x.track(); const auto overnight=x.time+86'400'000'000LL; auto p=x.module.preCapture(overnight,x.c); CHECK(p.initialization_required && p.initialization_reason==ResetReason::IdleTimeout && p.state==State::CAPTURE); CHECK(x.module.history().empty()); CHECK(!x.module.preCapture(overnight,x.c).initialization_required); CHECK(!x.module.preCapture(overnight+1,x.c).initialization_required); } TEST(lifecycle, history_expiry_during_active_missing_frames_is_one_shot) { Fixture x; x.c.history_window_us=5'000'000; x.c.idle_timeout_us=100'000'000; x.at(0); x.at(2'000'000,20,false); x.at(4'000'000,20,false); auto p=x.module.preCapture(6'000'000,x.c); CHECK(p.initialization_required && p.initialization_reason==ResetReason::HistoryExpired); CHECK(!x.module.preCapture(6'000'000,x.c).initialization_required); CHECK(x.module.processFrame(imageFrame(6'000'000,20,false)).accepted); for(TimestampUs t=8'000'000;t<30'000'000;t+=2'000'000) { x.at(t,20,false); CHECK(!x.last_pre.initialization_required); } } TEST(lifecycle, continuous_no_target_does_not_repeat_initialization) { Fixture x; for(int i=0;i<610;++i) { x.at(static_cast(i)*1'000'000,20,false); CHECK(x.last_pre.initialization_required==(i==0)); } } TEST(lifecycle, changed_values_or_revision_initialize_once) { Fixture x; x.track(); x.c.controller.kp=.3; auto p=x.module.preCapture(x.time+1,x.c); CHECK(p.initialization_reason==ResetReason::ConfigurationChanged); CHECK(!x.module.preCapture(x.time+1,x.c).initialization_required); x.c.revision++; p=x.module.preCapture(x.time+1,x.c); CHECK(p.initialization_required); CHECK(!x.module.preCapture(x.time+1,x.c).initialization_required); } TEST(lifecycle, missing_or_stale_precheck_is_rejected) { Fixture x; CHECK(x.module.processFrame(imageFrame(0)).reason==Reason::PreCaptureRequired); x.track(); auto r=x.module.processFrame(imageFrame(x.time+1'000'000)); CHECK(!r.accepted && r.reason==Reason::PreCaptureRequired); CHECK(x.module.preCapture(x.time+1,x.c).accepted); r=x.module.processFrame(imageFrame(x.time+301'000'000)); CHECK(!r.accepted && r.reason==Reason::PreCaptureRequired); } TEST(feedback, duplicates_and_out_of_order_do_not_mutate) { Fixture x; x.c.controller.target_gray=100; x.track(100); auto r=x.next(80); CHECK(r.control.same_direction_count==1); const auto count=x.module.history().size(); r=x.module.processFrame(imageFrame(x.time,80)); CHECK(!r.accepted && r.reason==Reason::DuplicateFrame); CHECK(r.control.same_direction_count==1 && !r.suggested_exposure_us); r=x.module.processFrame(imageFrame(x.time-1,80)); CHECK(!r.accepted && r.reason==Reason::OutOfOrderFrame); CHECK(x.module.history().size()==count && r.control.integral==0); r=x.next(80); CHECK(r.adjustment_suggested && r.control.same_direction_count==2); } TEST(feedback, actual_exposure_not_previous_advice_is_used) { Fixture x; x.track(); auto first=x.next(40,true,1000); auto second=x.next(40,true,1000); NEAR(*first.suggested_exposure_us,1250,0); NEAR(*second.suggested_exposure_us,1250,0); const auto applied=x.next(40,true,1250); NEAR(*applied.suggested_exposure_us,1562.5,0); } TEST(feedback, invalid_roi_holds_integral_and_breaks_confirmation) { Fixture x; x.c.controller.target_gray=100; x.track(100); x.next(80); auto r=x.next(80); NEAR(r.control.integral,.2,1e-12); auto f=imageFrame(x.time+1'000'000,80); f.roi.rectangles.clear(); r=x.send(f); CHECK(!r.adjustment_suggested && r.reason==Reason::InvalidMetering); NEAR(r.control.integral,.2,1e-12); CHECK(r.control.same_direction_count==0); CHECK(x.next(80).reason==Reason::ConfirmingError); } TEST(feedback, invalid_exposure_clock_change_and_reset) { Fixture x; x.track(); const auto size=x.module.history().size(); CHECK(x.module.preCapture(x.time+1,x.c).accepted); auto f=imageFrame(x.time+1); f.actual_exposure_us=std::numeric_limits::quiet_NaN(); auto r=x.module.processFrame(f); CHECK(!r.accepted && r.reason==Reason::InvalidActualExposure); CHECK(x.module.history().size()==size); CHECK(!x.module.preCapture(x.time-1,x.c).accepted); x.module.reset(ResetReason::ClockChanged); auto p=x.module.preCapture(0,x.c); CHECK(p.accepted && p.initialization_required); CHECK(x.module.processFrame(imageFrame(0)).accepted); } TEST(feedback, exposure_outside_configured_range_is_reported_not_clipped_as_feedback) { Fixture x; x.track(); CHECK(x.module.preCapture(x.time+1,x.c).accepted); const auto r=x.module.processFrame(imageFrame(x.time+1,80,true,20000)); CHECK(!r.accepted && r.reason==Reason::InvalidActualExposure && !r.suggested_exposure_us); NEAR(r.actual_exposure_us,20000,0); CHECK(x.module.history().size()==1); } TEST(config, reject_invalid_contract_without_accepting_frames) { Config empty; CHECK(!validate(empty).empty()); Fixture x; CHECK(validate(x.c).empty()); auto bad=x.c; bad.metering.trim_fraction_each_side=.5; CHECK(!x.module.preCapture(0,bad).accepted); CHECK(!x.module.processFrame(imageFrame(0)).accepted); bad=x.c; bad.prediction.points=4; CHECK(!validate(bad).empty()); bad=x.c; bad.controller.target_gray=0; CHECK(!validate(bad).empty()); bad=x.c; bad.max_exposure_us=500; CHECK(!validate(bad).empty()); bad=x.c; bad.recovery.max_step_ratio=.5; CHECK(!validate(bad).empty()); CHECK(x.module.preCapture(0,x.c).accepted); } TEST(config, independent_instances_do_not_share_state) { Fixture a,b; a.track(); a.next(40); CHECK(b.module.state()==State::CAPTURE && b.module.history().empty()); CHECK(b.module.preCapture(0,b.c).initialization_required); } ContinuousFrameInput feedbackFrame(TimestampUs time, int globalGray = 80, FeedbackSource source = FeedbackSource::Global, std::optional regionGray = {}, double actual = 1000) { ContinuousFrameInput input; input.timestamp_us = time; input.actual_exposure_us = actual; input.source = source; input.metering = measureImage(cv::Mat(10, 10, CV_8UC1, cv::Scalar(globalGray)), {}, false); if (regionGray) { Roi roi; roi.rectangles.emplace_back(0, 0, 10, 10); input.metering.target = measureImage(cv::Mat(10, 10, CV_8UC1, cv::Scalar(*regionGray)), roi, true).target; } return input; } ContinuousConfig feedbackConfig() { ContinuousConfig config; config.min_exposure_us = 100; config.max_exposure_us = 10000; config.region_controller.target_gray = config.global_controller.target_gray = 100; return config; } TEST(continuous, no_target_feedback_converges_without_a_recovery_budget) { ContinuousExposureControl control; const auto c = feedbackConfig(); double actual = 1000; int adjustments = 0; ContinuousFrameResult result; for (int i = 0; i < 40; ++i) { const int gray = std::clamp(static_cast(std::round(actual * .02)), 0, 255); result = control.update(feedbackFrame(i, gray, FeedbackSource::YoloRegions, {}, actual), c); CHECK(result.accepted && result.effective_source == FeedbackSource::Global); CHECK(result.metering.target.status == MeterStatus::NoTarget); CHECK(result.fallback_reason == FeedbackFallbackReason::RegionUnavailable); CHECK(std::abs(result.control.limited_delta_exposure_us) <= actual * .25 + 1e-9); if (result.adjustment_suggested) ++adjustments; actual = *result.suggested_exposure_us; } CHECK(adjustments > 3); CHECK(result.state == FeedbackState::Holding && !result.adjustment_suggested); CHECK(*result.control_gray >= 88 && *result.control_gray <= 112); } TEST(continuous, manual_and_yolo_regions_take_priority_then_fallback_explicitly) { ContinuousExposureControl control; const auto c = feedbackConfig(); auto result = control.update(feedbackFrame(0, 0, FeedbackSource::ManualRoi, 100), c); CHECK(result.effective_source == FeedbackSource::ManualRoi && result.reason == Reason::Deadband); CHECK(result.fallback_reason == FeedbackFallbackReason::None && *result.control_gray == 100); result = control.update(feedbackFrame(1, 255, FeedbackSource::YoloRegions, 20), c); CHECK(result.effective_source == FeedbackSource::YoloRegions && result.adjustment_suggested); CHECK(*result.suggested_exposure_us == 1250 && result.reset_reason == FeedbackResetReason::SourceChanged); result = control.update(feedbackFrame(2, 100, FeedbackSource::YoloRegions), c); CHECK(result.effective_source == FeedbackSource::Global && result.reason == Reason::Deadband); CHECK(result.fallback_reason == FeedbackFallbackReason::RegionUnavailable); auto invalid = feedbackFrame(3, 20, FeedbackSource::ManualRoi); invalid.metering.target.status = MeterStatus::InvalidRoi; result = control.update(invalid, c); CHECK(result.accepted && result.fallback_reason == FeedbackFallbackReason::InvalidRegionMeasurement); CHECK(result.effective_source == FeedbackSource::Global && !result.reset_reason); } TEST(continuous, source_changes_clear_integral_and_direction_count) { ContinuousExposureControl control; const auto c = feedbackConfig(); CHECK(control.update(feedbackFrame(0, 80, FeedbackSource::ManualRoi, 80), c).reason == Reason::ConfirmingError); auto result = control.update(feedbackFrame(1, 80, FeedbackSource::ManualRoi, 80), c); NEAR(result.control.integral, .2, 1e-12); result = control.update(feedbackFrame(2), c); CHECK(result.reset_reason == FeedbackResetReason::SourceChanged && result.reason == Reason::ConfirmingError); CHECK(result.control.integral == 0 && result.control.same_direction_count == 1); result = control.update(feedbackFrame(3), c); NEAR(result.control.integral, .2, 1e-12); result = control.update(feedbackFrame(4, 80, FeedbackSource::YoloRegions, 80), c); CHECK(result.reset_reason == FeedbackResetReason::SourceChanged && result.control.integral == 0); } TEST(continuous, global_reference_is_independent_of_region_reference) { ContinuousExposureControl control; auto c = feedbackConfig(); c.region_controller.target_gray = 180; c.global_controller.target_gray = 100; auto result = control.update(feedbackFrame(0, 100, FeedbackSource::YoloRegions), c); CHECK(result.target_gray == 100 && result.reason == Reason::Deadband); result = control.update(feedbackFrame(1, 100, FeedbackSource::YoloRegions, 100), c); CHECK(result.target_gray == 180 && result.reason == Reason::ProportionalAdjustment); } TEST(continuous, black_saturation_step_limits_and_exposure_limits_are_traced) { ContinuousExposureControl control; const auto c = feedbackConfig(); auto result = control.update(feedbackFrame(0, 0), c); CHECK(result.state == FeedbackState::Adjusting && result.control.step_limited); NEAR(result.control.raw_delta_exposure_us, 700, 1e-10); NEAR(result.control.limited_delta_exposure_us, 250, 0); CHECK(result.metering.global.near_black_fraction == 1); result = control.update(feedbackFrame(1, 255), c); CHECK(*result.suggested_exposure_us == 750 && result.metering.global.saturated_fraction == 1); CHECK(result.control.current_error_ratio == result.control.control_error_ratio); result = control.update(feedbackFrame(2, 0, FeedbackSource::Global, {}, 10000), c); CHECK(result.state == FeedbackState::Limited && result.reason == Reason::ExposureLimit); CHECK(!result.adjustment_suggested && result.control.exposure_limited); result = control.update(feedbackFrame(3, 255, FeedbackSource::Global, {}, 100), c); CHECK(result.state == FeedbackState::Limited && *result.suggested_exposure_us == 100); } TEST(continuous, new_config_and_idle_interval_reset_controller) { ContinuousExposureControl control; auto c = feedbackConfig(); CHECK(control.update(feedbackFrame(0), c).reset_reason == FeedbackResetReason::Startup); NEAR(control.update(feedbackFrame(1), c).control.integral, .2, 1e-12); c.global_controller.kp = .3; auto result = control.update(feedbackFrame(2), c); CHECK(result.reset_reason == FeedbackResetReason::ConfigurationChanged && result.control.integral == 0); result = control.update(feedbackFrame(3), c); NEAR(result.control.integral, .2, 1e-12); result = control.update(feedbackFrame(c.idle_timeout_us + 4), c); CHECK(result.reset_reason == FeedbackResetReason::IdleTimeout && result.control.integral == 0); CHECK(result.reason == Reason::ConfirmingError); } TEST(continuous, duplicates_out_of_order_and_manual_reset_keep_timestamp_watermark) { ContinuousExposureControl control; const auto c = feedbackConfig(); control.update(feedbackFrame(10), c); auto result = control.update(feedbackFrame(10), c); CHECK(!result.accepted && result.reason == Reason::DuplicateFrame && !result.suggested_exposure_us); CHECK(result.control.same_direction_count == 1); CHECK(control.update(feedbackFrame(9), c).reason == Reason::OutOfOrderFrame); result = control.update(feedbackFrame(11), c); CHECK(result.accepted && result.control.same_direction_count == 2); control.reset(); CHECK(control.update(feedbackFrame(0), c).reason == Reason::OutOfOrderFrame); result = control.update(feedbackFrame(12), c); CHECK(result.reset_reason == FeedbackResetReason::Manual && result.control.integral == 0); control.reset(FeedbackResetReason::ClockChanged); result = control.update(feedbackFrame(0), c); CHECK(result.accepted && result.reset_reason == FeedbackResetReason::ClockChanged); } TEST(continuous, invalid_feedback_is_rejected_without_consuming_frame_or_state) { ContinuousExposureControl control; const auto c = feedbackConfig(); control.update(feedbackFrame(0), c); auto bad = feedbackFrame(1); bad.metering.global.gray = std::numeric_limits::quiet_NaN(); auto result = control.update(bad, c); CHECK(!result.accepted && result.reason == Reason::InvalidMetering && !result.suggested_exposure_us); CHECK(!result.effective_source && !result.errors.empty() && result.control.same_direction_count == 1); bad = feedbackFrame(1); bad.metering.global.saturated_fraction = 1.5; CHECK(!control.update(bad, c).accepted); bad = feedbackFrame(1); bad.metering.global.pixels = 0; CHECK(!control.update(bad, c).accepted); bad = feedbackFrame(1); bad.metering.global.retained_pixels = 101; CHECK(!control.update(bad, c).accepted); result = control.update(feedbackFrame(1), c); CHECK(result.accepted && result.control.same_direction_count == 2); NEAR(result.control.integral, .2, 1e-12); } TEST(continuous, invalid_exposure_config_and_time_do_not_initialize) { ContinuousExposureControl control; const auto c = feedbackConfig(); CHECK(control.update(feedbackFrame(-1), c).reason == Reason::InvalidTimestamp); for (double actual : {0.0, 99.0, 10001.0, std::numeric_limits::quiet_NaN()}) { const auto result = control.update(feedbackFrame(0, 80, FeedbackSource::Global, {}, actual), c); CHECK(!result.accepted && result.reason == Reason::InvalidActualExposure && !result.suggested_exposure_us); } auto badConfig = c; badConfig.global_controller.target_gray = 0; CHECK(control.update(feedbackFrame(0), badConfig).reason == Reason::InvalidConfiguration); badConfig = c; badConfig.region_controller.max_step_ratio = 1; CHECK(!validate(badConfig).empty()); auto badInput = feedbackFrame(0); badInput.source = static_cast(100); CHECK(control.update(badInput, c).reason == Reason::InvalidConfiguration); const auto result = control.update(feedbackFrame(0), c); CHECK(result.accepted && result.reset_reason == FeedbackResetReason::Startup); } TEST(continuous, actual_exposure_not_unapplied_previous_advice_is_used) { ContinuousExposureControl control; const auto c = feedbackConfig(); const auto first = control.update(feedbackFrame(0, 20), c); const auto retry = control.update(feedbackFrame(1, 20), c); NEAR(*first.suggested_exposure_us, 1250, 0); NEAR(*retry.suggested_exposure_us, 1250, 0); const auto applied = control.update(feedbackFrame(2, 20, FeedbackSource::Global, {}, 1250), c); NEAR(*applied.suggested_exposure_us, 1562.5, 0); } TEST(continuous, anti_windup_and_independent_instances_are_preserved) { ContinuousExposureControl a, b; auto c = feedbackConfig(); c.global_controller.kp = 2; c.global_controller.ki = 1; a.update(feedbackFrame(0), c); const auto result = a.update(feedbackFrame(1), c); CHECK(result.control.integral_blocked_by_limit && result.control.step_limited); CHECK(result.control.integral == 0 && result.control.same_direction_count == 2); const auto independent = b.update(feedbackFrame(0), c); CHECK(independent.reset_reason == FeedbackResetReason::Startup); CHECK(independent.reason == Reason::ConfirmingError && independent.control.same_direction_count == 1); } TEST(continuous, sparse_frames_preserve_confirmation_without_accumulating_stale_integral) { ContinuousExposureControl control; auto c = feedbackConfig(); c.preserve_confirmation_on_idle = true; const TimestampUs interval = c.idle_timeout_us + 1; auto result = control.update(feedbackFrame(0), c); CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1); for (int frame = 1; frame <= 4; ++frame) { result = control.update(feedbackFrame(frame * interval), c); CHECK(result.accepted && result.reset_reason == FeedbackResetReason::IdleTimeout); CHECK(result.reason == Reason::PiAdjustment && result.control.same_direction_count == 2); CHECK(result.control.integral == 0 && result.control.integral_suppressed); NEAR(*result.suggested_exposure_us, 1080, 1e-9); } // A subsequent close frame may integrate normally; the sparse frames do not. result = control.update(feedbackFrame(4 * interval + 1), c); CHECK(!result.reset_reason && !result.control.integral_suppressed); NEAR(result.control.integral, .2, 1e-12); // An idle gap after accumulated integral clears that integral before computing. result = control.update(feedbackFrame(5 * interval + 1), c); CHECK(result.control.integral == 0 && result.control.integral_suppressed); NEAR(*result.suggested_exposure_us, 1080, 1e-9); } TEST(continuous, sparse_idle_preserves_hysteresis_and_reconfirms_after_direction_change) { ContinuousExposureControl control; auto c = feedbackConfig(); c.preserve_confirmation_on_idle = true; const TimestampUs interval = c.idle_timeout_us + 1; control.update(feedbackFrame(0), c); // 11 percent error is outside the deadband but inside the re-entry hysteresis. auto result = control.update(feedbackFrame(interval, 89), c); CHECK(result.adjustment_suggested && result.control.same_direction_count == 2); result = control.update(feedbackFrame(2 * interval, 120), c); CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1); result = control.update(feedbackFrame(3 * interval, 120), c); CHECK(result.adjustment_suggested && *result.suggested_exposure_us < 1000); result = control.update(feedbackFrame(4 * interval, 100), c); CHECK(result.reason == Reason::Deadband && result.control.same_direction_count == 0); result = control.update(feedbackFrame(5 * interval, 89), c); CHECK(result.reason == Reason::Hysteresis && result.control.same_direction_count == 0); } TEST(continuous, idle_option_preserves_default_and_never_weakens_explicit_or_context_resets) { ContinuousExposureControl control; auto c = feedbackConfig(); const TimestampUs interval = c.idle_timeout_us + 1; control.update(feedbackFrame(0), c); for (int frame = 1; frame <= 3; ++frame) { auto result = control.update(feedbackFrame(frame * interval), c); CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1); CHECK(!result.control.integral_suppressed); } const auto oldConfig = c; c.preserve_confirmation_on_idle = true; CHECK(c != oldConfig); auto result = control.update(feedbackFrame(4 * interval), c); CHECK(result.reset_reason == FeedbackResetReason::ConfigurationChanged); CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1); result = control.update(feedbackFrame(5 * interval, 80, FeedbackSource::ManualRoi, 80), c); CHECK(result.reset_reason == FeedbackResetReason::SourceChanged); CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1); for (const auto reason : {FeedbackResetReason::Manual, FeedbackResetReason::IdleTimeout, FeedbackResetReason::ClockChanged}) { control.reset(reason); result = control.update(feedbackFrame(6 * interval, 80, FeedbackSource::ManualRoi, 80), c); CHECK(result.reset_reason == reason && result.reason == Reason::ConfirmingError); CHECK(result.control.same_direction_count == 1 && !result.control.integral_suppressed); result = control.update(feedbackFrame(7 * interval, 80, FeedbackSource::ManualRoi, 80), c); CHECK(result.adjustment_suggested); // Keep the next explicit reset's accepted timestamp strictly newer. control.reset(FeedbackResetReason::ClockChanged); } } TEST(continuous, external_override_clears_integral_without_restarting_confirmation) { ContinuousExposureControl control; const auto c = feedbackConfig(); control.update(feedbackFrame(0), c); auto result = control.update(feedbackFrame(1), c); NEAR(result.control.integral, .2, 1e-12); for (TimestampUs time = 2; time <= 4; ++time) { auto input = feedbackFrame(time); input.suppress_integral = true; result = control.update(input, c); CHECK(result.accepted && !result.reset_reason && result.adjustment_suggested); CHECK(result.control.integral_suppressed && result.control.integral == 0); CHECK(result.control.same_direction_count == 2); NEAR(*result.suggested_exposure_us, 1080, 1e-9); } result = control.update(feedbackFrame(5), c); CHECK(!result.control.integral_suppressed); NEAR(result.control.integral, .2, 1e-12); auto deadband = feedbackFrame(6, 100); deadband.suppress_integral = true; result = control.update(deadband, c); CHECK(result.reason == Reason::Deadband && result.control.integral == 0 && result.control.integral_suppressed); auto confirming = feedbackFrame(7); confirming.suppress_integral = true; result = control.update(confirming, c); CHECK(result.reason == Reason::ConfirmingError && result.control.same_direction_count == 1); result = control.update(feedbackFrame(8), c); CHECK(result.adjustment_suggested && result.control.same_direction_count == 2); } TEST(continuous, rejected_override_inputs_do_not_clear_integral_or_consume_time_and_configuration) { ContinuousExposureControl control; const auto c = feedbackConfig(); control.update(feedbackFrame(0), c); NEAR(control.update(feedbackFrame(1), c).control.integral, .2, 1e-12); const auto rejected = [&](ContinuousFrameInput bad, const ContinuousConfig& config) { bad.suppress_integral = true; const auto result = control.update(bad, config); CHECK(!result.accepted && !result.control.integral_suppressed); CHECK(result.control.same_direction_count == 2); NEAR(result.control.integral, .2, 1e-12); }; rejected(feedbackFrame(1), c); rejected(feedbackFrame(0), c); rejected(feedbackFrame(-1), c); rejected(feedbackFrame(2, 80, FeedbackSource::Global, {}, 0), c); auto bad = feedbackFrame(2); bad.metering.global.gray = std::numeric_limits::quiet_NaN(); auto changed = c; changed.preserve_confirmation_on_idle = true; rejected(bad, changed); bad = feedbackFrame(2); bad.source = static_cast(999); rejected(bad, c); auto invalid = changed; invalid.global_controller.target_gray = 0; rejected(feedbackFrame(2), invalid); const auto result = control.update(feedbackFrame(2), c); CHECK(result.accepted && !result.reset_reason && result.control.same_direction_count == 2); NEAR(result.control.integral, .4, 1e-12); } TEST(continuous, reached_target_and_error_reversal_clear_obsolete_integral) { for (const bool timeAware : {false, true}) { ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = timeAware; c.global_controller.target_gray = 118; double actual = 5000; TimestampUs time = 0; for (int i = 0; i < 7; ++i, time += 5'000'000) { const auto r = control.update(feedbackFrame(time, 100, FeedbackSource::Global, {}, actual), c); actual = *r.suggested_exposure_us; } for (int i = 0; i < 2; ++i, time += 5'000'000) { const auto r = control.update(feedbackFrame(time, 118, FeedbackSource::Global, {}, actual), c); CHECK(r.control.integral == 0 && !r.adjustment_suggested); } for (int i = 0; i < 3; ++i, time += 5'000'000) { const auto r = control.update(feedbackFrame(time, 134, FeedbackSource::Global, {}, actual), c); CHECK(*r.suggested_exposure_us <= actual && r.control.integral <= 0); actual = *r.suggested_exposure_us; } control.reset(FeedbackResetReason::ClockChanged); control.update(feedbackFrame(0, 100), c); const auto dark = control.update(feedbackFrame(5'000'000, 100), c); CHECK(dark.control.integral > 0); const auto reversed = control.update(feedbackFrame(10'000'000, 134), c); CHECK(reversed.control.integral == 0 && *reversed.suggested_exposure_us <= 1000); } } TEST(controller, original_entrypoint_clears_integral_on_return_and_reversal) { Fixture x; x.c.controller.target_gray = 118; x.track(118); for (int i = 0; i < 7; ++i) x.next(100); auto r = x.next(118); CHECK(r.control.integral == 0); x.next(118); for (int i = 0; i < 3; ++i) { r = x.next(134); CHECK(*r.suggested_exposure_us <= 1000 && r.control.integral <= 0); } x.next(100); x.next(100); r = x.next(134); CHECK(r.control.integral == 0 && !r.adjustment_suggested); } TEST(continuous, elapsed_time_limits_rate_and_integral_at_multiple_intervals) { for (const TimestampUs interval : {TimestampUs{100'000}, TimestampUs{1'000'000}, TimestampUs{5'000'000}, TimestampUs{60'000'000}}) { ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true; const auto first = control.update(feedbackFrame(0, 20), c); CHECK(first.control.elapsed_us == c.nominal_interval_us); const auto r = control.update(feedbackFrame(interval, 20), c); const double expectedStep = std::min(.25, .05 * interval / 1'000'000.0); NEAR(*r.suggested_exposure_us, 1000 * (1 + expectedStep), 1e-8); NEAR(r.control.effective_max_step_ratio, expectedStep, 1e-12); CHECK(r.control.time_rate_limited == (interval < 5'000'000)); CHECK(r.control.elapsed_us == interval && r.control.filter_alpha == 1); control.reset(FeedbackResetReason::ClockChanged); c.confirmation_us = 0; CHECK(control.update(feedbackFrame(0), c).reason == Reason::ConfirmingError); const auto small = control.update(feedbackFrame(interval), c); CHECK(small.control.same_direction_count == 2); NEAR(small.control.integral_time_scale, std::min(1.0, interval / 5'000'000.0), 1e-12); if (interval >= 5'000'000) NEAR(small.control.integral, .2, 1e-12); } } TEST(continuous, time_confirmation_requires_duration_and_two_samples) { ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true; c.global_controller.same_direction_frames = 1; CHECK(control.update(feedbackFrame(0), c).reason == Reason::ConfirmingError); for (int i = 1; i < 10; ++i) { const auto r = control.update(feedbackFrame(i * 100'000), c); CHECK(r.reason == Reason::ConfirmingError && !r.control.confirmation_time_satisfied); CHECK(r.control.integral == 0); } const auto r = control.update(feedbackFrame(1'000'000), c); CHECK(r.adjustment_suggested && r.control.confirmation_time_satisfied); CHECK(r.control.same_direction_duration_us == 1'000'000); NEAR(r.control.integral_time_scale, .02, 1e-12); } TEST(continuous, smoothing_normalizes_exposure_and_never_reverses_raw_error) { ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true; control.update(feedbackFrame(0, 80), c); // Same scene response at changed exposure must not be mistaken for light drift. auto r = control.update(feedbackFrame(100'000, 88, FeedbackSource::Global, {}, 1100), c); NEAR(*r.control_gray, 88, 1e-10); NEAR(*r.raw_control_gray, 88, 0); CHECK(r.control.filter_alpha > 0 && r.control.filter_alpha < 1); r = control.update(feedbackFrame(200'000, 120, FeedbackSource::Global, {}, 1100), c); CHECK(*r.control_gray == 120 && r.control.filter_alpha == 1); CHECK(*r.suggested_exposure_us <= 1100 && r.control.integral <= 0); r = control.update(feedbackFrame(300'000, 100, FeedbackSource::Global, {}, 1100), c); CHECK(r.reason == Reason::Deadband && !r.adjustment_suggested && r.control.integral == 0); auto overrideFrame = feedbackFrame(400'000, 90); overrideFrame.suppress_integral = true; r = control.update(overrideFrame, c); CHECK(*r.control_gray == 90 && r.control.filter_alpha == 1 && r.control.integral == 0); } TEST(continuous, timed_resets_and_long_gaps_clear_filter_without_stale_integral) { ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true; c.preserve_confirmation_on_idle = true; control.update(feedbackFrame(0), c); auto r = control.update(feedbackFrame(3'600'000'000), c); CHECK(r.adjustment_suggested && r.control.integral == 0 && r.control.integral_time_scale == 0); CHECK(r.control.filter_alpha == 1 && r.control.same_direction_duration_us == 3'600'000'000); for (const auto reason : {FeedbackResetReason::Manual, FeedbackResetReason::ClockChanged}) { control.reset(reason); r = control.update(feedbackFrame(3'601'000'000, 85), c); CHECK(r.reset_reason == reason && r.control.filter_alpha == 1 && *r.control_gray == 85); CHECK(r.reason == Reason::ConfirmingError && r.control.same_direction_duration_us == 0); } r = control.update(feedbackFrame(3'602'000'000, 85, FeedbackSource::ManualRoi, 90), c); CHECK(r.reset_reason == FeedbackResetReason::SourceChanged && r.control.filter_alpha == 1); ++c.confirmation_us; r = control.update(feedbackFrame(3'603'000'000, 85, FeedbackSource::ManualRoi, 90), c); CHECK(r.reset_reason == FeedbackResetReason::ConfigurationChanged && r.control.filter_alpha == 1); } TEST(continuous, rejected_frames_do_not_consume_temporal_filter_or_timestamps) { ContinuousExposureControl a, b; auto c = feedbackConfig(); c.time_aware = true; a.update(feedbackFrame(0), c); b.update(feedbackFrame(0), c); auto bad = feedbackFrame(500'000, 255); bad.actual_exposure_us = 0; CHECK(!a.update(bad, c).accepted); bad = feedbackFrame(500'000, 255); bad.metering.global.gray = -1; CHECK(!a.update(bad, c).accepted); for (int field = 0; field < 4; ++field) { auto invalid = c; if (field == 0) invalid.nominal_interval_us = 0; if (field == 1) invalid.smoothing_tau_us = -1; if (field == 2) invalid.confirmation_us = -1; if (field == 3) invalid.max_step_ratio_per_second = 0; CHECK(!a.update(feedbackFrame(500'000, 255), invalid).accepted); } const auto x = a.update(feedbackFrame(1'000'000, 85), c); const auto y = b.update(feedbackFrame(1'000'000, 85), c); CHECK(x.reason == y.reason && x.control_gray == y.control_gray && x.suggested_exposure_us == y.suggested_exposure_us); CHECK(x.control.elapsed_us == y.control.elapsed_us && x.control.integral == y.control.integral); CHECK(x.control.same_direction_duration_us == y.control.same_direction_duration_us); } TEST(continuous, tiny_finite_exposure_cannot_poison_temporal_filter) { ContinuousExposureControl control; auto c = feedbackConfig(); c.time_aware = true; c.min_exposure_us = 1e-320; for (int i = 0; i < 3; ++i) { const auto r = control.update(feedbackFrame(i * 1'000'000, 80, FeedbackSource::Global, {}, 1e-319), c); CHECK(r.accepted && r.control_gray == 80 && std::isfinite(*r.suggested_exposure_us)); CHECK(std::isfinite(r.control.integral)); } } ContinuousConfig predictionConfig() { auto c = feedbackConfig(); c.time_aware = true; c.max_exposure_us = 30000; c.region_controller.target_gray = c.global_controller.target_gray = 118; return c; } ContinuousFrameInput predictionFrame(TimestampUs time, int gray, double predicted) { auto input = feedbackFrame(time, gray, FeedbackSource::ManualRoi, gray, 10000); input.predicted_gray = predicted; return input; } TEST(continuous, prediction_uses_shared_deadband_hysteresis_and_confirmation) { ContinuousExposureControl control; const auto c = predictionConfig(); auto r = control.update(predictionFrame(0, 110, 110), c); CHECK(r.accepted && !r.prediction_used && r.reset_reason == FeedbackResetReason::Startup); r = control.update(predictionFrame(5'000'000, 110, 110), c); CHECK(r.prediction_used && r.reason == Reason::Deadband && *r.suggested_exposure_us == 10000); r = control.update(predictionFrame(10'000'000, 105, 105), c); CHECK(r.prediction_used && r.reason == Reason::Hysteresis && *r.suggested_exposure_us == 10000); r = control.update(predictionFrame(15'000'000, 110, 102), c); CHECK(r.prediction_used && r.reason == Reason::ConfirmingError && !r.adjustment_suggested); CHECK(r.control.same_direction_count == 1 && r.control.integral == 0); r = control.update(predictionFrame(20'000'000, 110, 101), c); CHECK(r.prediction_used && r.reason == Reason::PiAdjustment && r.adjustment_suggested); CHECK(r.control.same_direction_count == 2 && r.control.confirmation_time_satisfied); CHECK(*r.suggested_exposure_us > 10000 && *r.suggested_exposure_us <= 11000); CHECK(r.feedback_reason == Reason::Deadband && *r.feedback_suggested_exposure_us == 10000); CHECK(r.metering.target.gray == 110 && r.raw_control_gray == 110 && r.feedback_control_gray == 110); CHECK(r.control.filtered_gray == 110 && r.control_gray == 101); NEAR(*r.control.current_error_ratio, 8.0 / 118, 1e-12); NEAR(*r.control.control_error_ratio, 17.0 / 118, 1e-12); } TEST(continuous, prediction_keeps_time_confirmation_and_both_step_limits) { ContinuousExposureControl control; auto c = predictionConfig(); control.update(predictionFrame(0, 110, 110), c); auto r = control.update(predictionFrame(100'000, 110, 80), c); CHECK(r.reason == Reason::ConfirmingError); r = control.update(predictionFrame(200'000, 110, 80), c); CHECK(r.reason == Reason::ConfirmingError && !r.control.confirmation_time_satisfied); r = control.update(predictionFrame(1'100'000, 110, 80), c); CHECK(r.adjustment_suggested && r.control.confirmation_time_satisfied && r.control.time_rate_limited); NEAR(*r.suggested_exposure_us, 10450, 1e-8); r = control.update(predictionFrame(6'100'000, 110, 60), c); CHECK(r.adjustment_suggested && r.control.step_limited && !r.control.time_rate_limited); NEAR(*r.suggested_exposure_us, 11000, 1e-8); control.reset(); c.time_aware = false; control.update(predictionFrame(7'000'000, 110, 110), c); r = control.update(predictionFrame(8'000'000, 110, 80), c); CHECK(r.reason == Reason::ConfirmingError); r = control.update(predictionFrame(9'000'000, 110, 80), c); CHECK(r.adjustment_suggested && *r.suggested_exposure_us <= 11000); } TEST(continuous, prediction_cannot_override_active_feedback_or_protection_resets) { const auto c = predictionConfig(); for (int scenario = 0; scenario < 5; ++scenario) { ContinuousExposureControl control; control.update(predictionFrame(0, 110, 110), c); auto input = predictionFrame(5'000'000, 110, 60); if (scenario == 0) input = predictionFrame(5'000'000, 60, 200); if (scenario == 1) input.source = FeedbackSource::Global; if (scenario == 2) input.suppress_integral = true; if (scenario == 3) control.reset(FeedbackResetReason::ClockChanged); if (scenario == 4) input.timestamp_us = c.idle_timeout_us + 1; const auto r = control.update(input, c); CHECK(r.accepted && !r.prediction_used && r.feedback_reason == r.reason); CHECK(r.feedback_suggested_exposure_us == r.suggested_exposure_us); if (scenario == 0) CHECK(r.adjustment_suggested && *r.suggested_exposure_us > 10000); else CHECK(!r.adjustment_suggested); } } TEST(continuous, rejected_prediction_does_not_mutate_feedback_state) { const auto c = predictionConfig(); ContinuousExposureControl a, b; a.update(predictionFrame(0, 110, 110), c); b.update(predictionFrame(0, 110, 110), c); a.update(predictionFrame(5'000'000, 110, 102), c); b.update(predictionFrame(5'000'000, 110, 102), c); auto bad = predictionFrame(6'000'000, 110, std::numeric_limits::quiet_NaN()); CHECK(!a.update(bad, c).accepted); bad.predicted_gray = 101; bad.prediction_max_step_ratio = 0; CHECK(!a.update(bad, c).accepted); const auto x = a.update(predictionFrame(10'000'000, 110, 101), c); const auto y = b.update(predictionFrame(10'000'000, 110, 101), c); CHECK(x.adjustment_suggested && x.reason == y.reason && x.suggested_exposure_us == y.suggested_exposure_us); CHECK(x.control.same_direction_count == y.control.same_direction_count && x.control.integral == y.control.integral); } } // namespace int main(int argc,char** argv) { const std::string selected=argc>1?argv[1]:""; int total=0, failures=0; for(const auto& test:cases()) if(selected.empty()||test.group==selected) { ++total; try { test.run(); std::cout<<"PASS "<