diff --git a/Config/slicecamd.cfg.in b/Config/slicecamd.cfg.in index a778e448..a7eb0af4 100644 --- a/Config/slicecamd.cfg.in +++ b/Config/slicecamd.cfg.in @@ -111,6 +111,50 @@ FINE_ACQUIRE_GAIN_LARGE=1.0 # FINE_ACQUIRE_GAIN_THRESHOLD=2.0 +# FINE_ACQUIRE_EXPTIME_MIN / _MAX = +# Clamp range for auto-adjusted fine-acquisition exposure time. +# +FINE_ACQUIRE_EXPTIME_MIN=0.1 +FINE_ACQUIRE_EXPTIME_MAX=15.0 + +# Exposure-compensation band (two-band model). FAINT/BRIGHT are the trip points +# that decide WHETHER to adjust; the _GOAL values are what the exposure is scaled +# toward once it does. Within [FAINT, BRIGHT] the source is adequately exposed and +# the exposure is left unchanged. +# +# REQUIRED ORDERING (enforced at config time -- slicecamd will refuse to start if +# violated): FAINT <= FAINT_GOAL <= BRIGHT and FAINT <= BRIGHT_GOAL <= BRIGHT, +# with FAINT, BRIGHT > 0. A goal left unset defaults to its own threshold. A goal +# outside its band edge would make the correction scale the WRONG way (e.g. a +# BRIGHT_GOAL above BRIGHT drives the source brighter and never re-enters the band). +# +# FINE_ACQUIRE_COUNTS_FAINT / _FAINT_GOAL = +# Below COUNTS_FAINT (top-10%-mean of background-subtracted pixels) the source is +# under-exposed: raise exposure toward COUNTS_FAINT_GOAL. Leave both unset to +# disable exposure compensation. +# +FINE_ACQUIRE_COUNTS_FAINT=250 +FINE_ACQUIRE_COUNTS_FAINT_GOAL=1500 + +# FINE_ACQUIRE_SATURATION= +# Raw-peak ceiling. A source is treated as saturated at or above this value. +# +FINE_ACQUIRE_SATURATION=55000 + +# FINE_ACQUIRE_COUNTS_BRIGHT / _BRIGHT_GOAL = +# Above COUNTS_BRIGHT the source is over-exposed: lower exposure toward +# COUNTS_BRIGHT_GOAL. Between FAINT and BRIGHT the exposure is left unchanged +# (the source is adequately exposed). +# +FINE_ACQUIRE_COUNTS_BRIGHT=10000 +FINE_ACQUIRE_COUNTS_BRIGHT_GOAL=2500 + +# FINE_ACQUIRE_AUTOEXPOSE_WINDOW= +# Number of frames the pre-acquisition auto-exposure averages before each +# decision. +# +FINE_ACQUIRE_AUTOEXPOSE_WINDOW=2 + # SkySimulator options: # SKYSIM_IMAGE_SIZE= where is integer # Sets the keyword argument "IMAGE_SIZE=" diff --git a/common/message_keys.h b/common/message_keys.h index b3017b8c..e2deba0e 100644 --- a/common/message_keys.h +++ b/common/message_keys.h @@ -104,6 +104,7 @@ namespace Key { namespace Slicecamd { inline const std::string FINEACQUIRE_LOCKED = "fineacquire_locked"; inline const std::string FINEACQUIRE_RUNNING = "fineacquire_running"; + inline const std::string AUTOEXPOSE_RUNNING = "autoexpose_running"; inline const std::string TANDOR_L = "tandor_L"; inline const std::string TANDOR_R = "tandor_R"; } diff --git a/common/slicecamd_commands.h b/common/slicecamd_commands.h index 45423c2f..25402490 100644 --- a/common/slicecamd_commands.h +++ b/common/slicecamd_commands.h @@ -25,6 +25,7 @@ const std::string SLICECAMD_EXIT = "exit"; ///< const std::string SLICECAMD_EXPTIME = "exptime"; ///< set/get camera exposure time const std::string SLICECAMD_FAN = "fan"; ///< set Andor fan mode const std::string SLICECAMD_FINEACQUIRE = "fineacquire"; ///< fine acquisition +const std::string SLICECAMD_AUTOEXPOSE = "autoexpose"; ///< auto-adjust fine-acquire exposure const std::string SLICECAMD_GUISET = "guiset"; ///< set params for gui display const std::string SLICECAMD_INIT = "init"; ///< *** const std::string SLICECAMD_ISACQUIRED = "isacquired"; ///< is the target acquired? @@ -55,6 +56,7 @@ const std::vector SLICECAMD_SYNTAX = { SLICECAMD_TCSISOPEN+" [ ? ]", " CAMERA COMMANDS:", SLICECAMD_FINEACQUIRE+" [ ? | status | stop | start { L | R } ]", + SLICECAMD_AUTOEXPOSE+" [ ? | on | off | status ]", SLICECAMD_AVGFRAMES+" [ ? | ]", SLICECAMD_FRAMEGRAB+" [ ? | start | stop | one [ ] | status ]", SLICECAMD_FRAMEGRABFIX+" [ ? ]", diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 24c45214..69923bfc 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -748,8 +748,19 @@ namespace Sequencer { // if ( !this->target.iscal ) { + // during acam acquisition, enable slicecam autoexpose to try to get the + // exposure time set before fine acquisition starts. + // + const bool dofine = this->should_fineacquire.load(); + if ( dofine ) (void)this->do_slicecam_autoexpose( true ); + // start ACAM acquisition. If it fails then wait for user to continue or cancel. - if ( this->do_acam_acquire() != NO_ERROR ) { + const long acq_error = this->do_acam_acquire(); + + // disable autoexpose no matter how ACAM finished + if ( dofine ) (void)this->do_slicecam_autoexpose( false ); + + if ( acq_error != NO_ERROR ) { this->broadcast.warning( function, "acam acquisition failed" ); if (this->wait_for_user()==ABORT) { this->broadcast.notice( function, "cancelled" ); @@ -759,7 +770,6 @@ namespace Sequencer { else { // ACAM success... // start SLICECAM fine acquisition if enabled long ret=NO_ERROR; - bool dofine = this->should_fineacquire.load(); if ( dofine ) ret = this->do_slicecam_fineacquire(); if ( ret!=NO_ERROR ) this->broadcast.warning( function, "slicecam fine acquisition failed" ); @@ -847,6 +857,7 @@ namespace Sequencer { this->request_snapshot(); lock.lock(); } + this->broadcast.notice( function, "done waiting for readout" ); } this->wait_state_manager.clear( Sequencer::SEQ_WAIT_READOUT ); diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index f0db3271..6b2e7a6f 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -605,6 +605,7 @@ namespace Sequencer { long do_acam_stop(); long do_slicecam_fineacquire(); long do_slicecam_stop(); + long do_slicecam_autoexpose( bool enable ); long acam_init(); ///< initializes connection to acamd diff --git a/sequencerd/sequence_acquisition.cpp b/sequencerd/sequence_acquisition.cpp index fe1cfe57..057d2063 100644 --- a/sequencerd/sequence_acquisition.cpp +++ b/sequencerd/sequence_acquisition.cpp @@ -257,4 +257,29 @@ namespace Sequencer { } /***** Sequencer::Sequence::do_slicecam_stop *********************************/ + + /***** Sequencer::Sequence::do_slicecam_autoexpose **************************/ + /** + * @brief enable/disable slicecam pre-acquisition auto-exposure + * @details Enabling autoexpose on slicecam before the fineacquire sequence + * to adjust exposure time before the fine acquisition sequence + * starts. A failure here never aborts the sequence. + * @param[in] enable true = turn auto-exposure on, false = turn it off + * @return NO_ERROR | ERROR + * + */ + long Sequence::do_slicecam_autoexpose( bool enable ) { + const std::string function("Sequencer::Sequence::do_slicecam_autoexpose"); + const std::string arg = enable ? " on" : " off"; + + std::string reply; + if ( this->slicecamd.command( SLICECAMD_AUTOEXPOSE + arg, reply ) != NO_ERROR + || reply.find("DONE") == std::string::npos ) { + this->broadcast.warning( function, "slicecam autoexpose"+arg+" not confirmed (reply=\""+reply+"\")" ); + return ERROR; + } + return NO_ERROR; + } + /***** Sequencer::Sequence::do_slicecam_autoexpose **************************/ + } diff --git a/slicecamd/slicecam_interface.cpp b/slicecamd/slicecam_interface.cpp index 313b9675..5819aa77 100644 --- a/slicecamd/slicecam_interface.cpp +++ b/slicecamd/slicecam_interface.cpp @@ -165,6 +165,7 @@ namespace Slicecam { this->fineacquire_state.reset(); this->is_fineacquire_locked.store(false, std::memory_order_release); this->is_fineacquire_running.store(true, std::memory_order_release); + this->is_autoexpose_running.store(false, std::memory_order_release); // fineacquire supersedes auto-exposure // publishes status on change only this->publish_status(); @@ -178,6 +179,83 @@ namespace Slicecam { /***** Slicecam::Interface::fineacquire *************************************/ + /***** Slicecam::Interface::tuned_exptime ***********************************/ + /** + * @brief scale exposure toward a target brightness + * @details new = cur * sqrt(target/measured), with the per-step factor + * clamped to [0.5, 2.0] and the result clamped to the configured + * [exptime_min, exptime_max]. Returns cur unchanged on bad input. + * @param[in] cur current exposure time (sec) + * @param[in] measured measured brightness metric (top-10%-mean) + * @param[in] target desired brightness metric + * @return new exposure time (sec) + * + */ + double Interface::tuned_exptime( double cur, double measured, double target ) const { + if ( !std::isfinite( cur ) || cur <= 0.0 ) cur = 1.0; + if ( !( measured > 0.0 ) || !( target > 0.0 ) ) return cur; + + // Source counts scale linearly with exposure time, so the ideal ratio is + // target/measured. Take the square root to damp the correction: this avoids + // overshoot and converges smoothly over a few cycles instead of one big jump. + // + double factor = std::sqrt( target / measured ); + if ( !std::isfinite( factor ) || factor <= 0.0 ) factor = 1.0; + + // never change by more than 2x (or less than 0.5x) in a single step + // + if ( factor < 0.5 ) factor = 0.5; + if ( factor > 2.0 ) factor = 2.0; + + double new_exptime = cur * factor; + + if ( new_exptime < this->fineacquire_state.exptime_min ) new_exptime = this->fineacquire_state.exptime_min; + if ( new_exptime > this->fineacquire_state.exptime_max ) new_exptime = this->fineacquire_state.exptime_max; + + return new_exptime; + } + /***** Slicecam::Interface::tuned_exptime ***********************************/ + + + /***** Slicecam::Interface::banded_exptime **********************************/ + /** + * @brief scale exposure toward the nearest in-band brightness goal + * @details Two-band model: below counts_faint, raise toward counts_faint_goal; + * above counts_bright, lower toward counts_bright_goal; within the + * [counts_faint, counts_bright] band the source is adequately exposed + * and the exposure is left unchanged. Each edge is independent: an + * unset (NAN) threshold disables that direction. When a goal is unset + * it defaults to its own threshold (CF uses faint == faint_goal and + * bright == bright_goal), so configuring only the thresholds works. + * @param[in] cur current exposure time (sec) + * @param[in] metric measured brightness metric (top-10%-mean) + * @return new exposure time (sec); equals cur when in band or disabled + * + */ + double Interface::banded_exptime( double cur, double metric ) const { + if ( !( metric > 0.0 ) ) return cur; + + const double faint = this->fineacquire_state.counts_faint; + if ( std::isfinite( faint ) && metric < faint ) { + // goal defaults to the threshold when unset + const double goal = std::isfinite( this->fineacquire_state.counts_faint_goal ) + ? this->fineacquire_state.counts_faint_goal : faint; + return this->tuned_exptime( cur, metric, goal ); + } + + const double bright = this->fineacquire_state.counts_bright; + if ( std::isfinite( bright ) && metric > bright ) { + // goal defaults to the threshold when unset + const double goal = std::isfinite( this->fineacquire_state.counts_bright_goal ) + ? this->fineacquire_state.counts_bright_goal : bright; + return this->tuned_exptime( cur, metric, goal ); + } + + return cur; // in band: adequately exposed, no change + } + /***** Slicecam::Interface::banded_exptime **********************************/ + + /***** Slicecam::Interface::do_fineacquire **********************************/ /** * @brief Evaluates fine acquisition natively per-frame @@ -226,17 +304,45 @@ namespace Slicecam { // find the star centroid near the aim point // - Point centroid; + Point centroid; + double peak_raw = 0.0, top10 = 0.0; if ( Math::calculate_centroid( img_data, ncols, nrows, this->fineacquire_state.bg_region, this->fineacquire_state.aimpoint, - centroid) != NO_ERROR ) { + centroid, peak_raw, top10 ) != NO_ERROR ) { const int max_failures = 3 * this->fineacquire_state.max_samples; + + // ----- Auto-Adjust exposure time while finding centroid --------------- + + // Sustained non-detection: the star may simply be too faint for the + // current exposure. Before giving up, climb a fixed exposure ladder one + // rung at a time (capped at exptime_max). Only abort once we are already + // at the longest exposure and still detect nothing. + // if ( ++this->fineacquire_state.consecutive_centroid_failures >= max_failures ) { - logwrite(function, "ERROR: too many consecutive centroid failures, stopping fine acquisition"); - this->is_fineacquire_running.store( false, std::memory_order_release ); - this->publish_status(); + static const double ladder[] = { 0.1, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 10.0, 15.0 }; + const double cur = this->camera.andor.empty() ? 0.0 + : this->camera.andor.begin()->second->camera_info.exptime; + // pick the next ladder rung longer than the current exposure + double next_exptime = cur; + for ( double rung : ladder ) { + if ( rung > cur + 1e-6 && rung <= this->fineacquire_state.exptime_max + 1e-6 ) { next_exptime = rung; break; } + } + if ( next_exptime > cur + 1e-6 ) { + logwrite( function, "WARNING faint/undetected: raising exptime " + +std::to_string(cur)+" -> "+std::to_string(next_exptime)+" s" ); + float newexp = static_cast( next_exptime ); + this->camera.set_exptime( newexp ); // safe: called between frames in the framegrab thread + // give the longer exposure a fresh failure budget, and let it settle + this->fineacquire_state.consecutive_centroid_failures = 0; + this->fineacquire_state.settle_frames = this->fineacquire_state.settle_count; + } + else { + logwrite( function, "ERROR target not detected at maximum exposure, stopping fine acquisition" ); + this->is_fineacquire_running.store( false, std::memory_order_release ); + this->publish_status(); + } } else { logwrite(function, "WARNING: failed to find centroid, skipping frame"); } @@ -244,6 +350,26 @@ namespace Slicecam { } this->fineacquire_state.consecutive_centroid_failures = 0; + // Saturation guard: a clipped peak makes the centroid unreliable (flat-topped + // or bloomed PSF), so we must not command a telescope offset from it. Halve + // the exposure and skip this frame; following frames re-evaluate at the lower + // exposure. + // + if ( std::isfinite( this->fineacquire_state.saturation ) && + peak_raw >= this->fineacquire_state.saturation ) { + const double cur = this->camera.andor.empty() ? 0.0 + : this->camera.andor.begin()->second->camera_info.exptime; + const double reduced_exptime = std::max( cur * 0.5, this->fineacquire_state.exptime_min ); + logwrite( function, "WARNING saturated peak ("+std::to_string(peak_raw) + +"); reducing exptime "+std::to_string(cur)+" -> "+std::to_string(reduced_exptime)+" s, skipping frame" ); + if ( reduced_exptime < cur - 1e-6 ) { + float newexp = static_cast( reduced_exptime ); + this->camera.set_exptime( newexp ); + } + this->fineacquire_state.settle_frames = this->fineacquire_state.settle_count; + return; + } + // convert centroid pixel -> sky using WCS from FITS header // World star_sky; @@ -278,6 +404,7 @@ namespace Slicecam { this->fineacquire_state.dra_samp.push_back( offsets.first ); this->fineacquire_state.ddec_samp.push_back( offsets.second ); + this->fineacquire_state.top10_samp.push_back( top10 ); const int n = static_cast( this->fineacquire_state.dra_samp.size() ); const int max_samples = this->fineacquire_state.max_samples; @@ -340,6 +467,38 @@ namespace Slicecam { return; } + // Per-cycle exposure trim toward the target brightness, gated by a deadband + // so small fluctuations don't cause constant exposure changes. Use the median + // of the cycle's samples to reject outlier frames (cosmic rays, brief seeing + // spikes). If we do change the exposure, skip this cycle's offset and start a + // fresh sample set at the new exposure rather than acting on marginal data. + // + if ( ( std::isfinite( this->fineacquire_state.counts_faint ) || + std::isfinite( this->fineacquire_state.counts_bright ) ) && + !this->fineacquire_state.top10_samp.empty() ) { + + std::vector sorted_top10 = this->fineacquire_state.top10_samp; + std::sort( sorted_top10.begin(), sorted_top10.end() ); + const double median_top10 = sorted_top10[ sorted_top10.size() / 2 ]; + + const double cur = this->camera.andor.empty() ? 0.0 + : this->camera.andor.begin()->second->camera_info.exptime; + const double new_exptime = this->banded_exptime( cur, median_top10 ); + + // banded_exptime returns cur when in band; only act on a material change + if ( std::abs( new_exptime - cur ) >= 0.02 ) { + logwrite( function, "exptime trim "+std::to_string(cur)+" -> "+std::to_string(new_exptime) + +" s (top10="+std::to_string(median_top10) + +", band=["+std::to_string(this->fineacquire_state.counts_faint)+"," + +std::to_string(this->fineacquire_state.counts_bright)+"])" ); + float newexp = static_cast( new_exptime ); + this->camera.set_exptime( newexp ); + this->fineacquire_state.reset(); + this->fineacquire_state.settle_frames = this->fineacquire_state.settle_count; + return; + } + } + // select gain: use gain_large when offset is well above the goal threshold // const double effective_gain = ( offset_arcsec > this->fineacquire_state.gain_threshold_arcsec ) @@ -375,6 +534,205 @@ namespace Slicecam { /***** Slicecam::Interface::do_fineacquire **********************************/ + /***** Slicecam::Interface::autoexpose **************************************/ + /** + * @brief enable/disable pre-acquisition auto-exposure + * @details Intended to run while ACAM is acquiring (before slicecam fine + * acquisition), when slicecam is otherwise only framegrabbing. + * @param[in] args on | off | status + * @param[out] retstring running | stopped (or error token) + * @return NO_ERROR | ERROR | HELP + * + */ + long Interface::autoexpose(std::string args, std::string &retstring) { + const char* function = "Slicecam::Interface::autoexpose"; + + if ( args == "?" || args == "help" ) { + retstring = SLICECAMD_AUTOEXPOSE; + retstring.append( " [ on | off | status ]\n" ); + retstring.append( " on : begin auto-adjusting fine-acquire exposure (during ACAM acquisition)\n" ); + retstring.append( " off : stop auto-adjusting exposure\n" ); + retstring.append( " no argument (or 'status') returns running|stopped\n" ); + return HELP; + } + + std::vector tokens; + Tokenize(args, tokens, " "); + const std::string action = tokens.empty() ? "status" : tokens.at(0); + + if (action=="status") { + retstring = this->is_autoexpose_running.load(std::memory_order_acquire) ? "running" : "stopped"; + return NO_ERROR; + } + else + if (action=="off") { + this->is_autoexpose_running.store(false, std::memory_order_release); + this->publish_status(); + logwrite(function, "auto-exposure stopped"); + retstring="stopped"; + return NO_ERROR; + } + else + if (action != "on") { + logwrite(function, "ERROR expected on | off | status"); + retstring="invalid_argument"; + return ERROR; + } + + // action=="on" + if (this->is_fineacquire_running.load(std::memory_order_acquire)) { + logwrite(function, "ERROR cannot auto-expose while fine acquisition is running"); + retstring="fineacquire_running"; + return ERROR; + } + if (!this->is_framegrab_running.load(std::memory_order_acquire)) { + logwrite(function, "ERROR framegrabbing is not running"); + retstring="stopped"; + return ERROR; + } + if (this->default_which.empty() || !this->default_aimpoint.is_valid()) { + logwrite(function, "ERROR fineacquire defaults not configured"); + retstring="not_configured"; + return ERROR; + } + if (!std::isfinite(this->fineacquire_state.counts_faint) && + !std::isfinite(this->fineacquire_state.counts_bright)) { + logwrite(function, "ERROR fine-acquire counts band not configured; auto-exposure unavailable"); + retstring="not_configured"; + return ERROR; + } + + this->autoexpose_state.reset(); + this->is_autoexpose_running.store(true, std::memory_order_release); + this->publish_status(); + logwrite(function, "auto-exposure started"); + retstring="running"; + return NO_ERROR; + } + /***** Slicecam::Interface::autoexpose **************************************/ + + + /***** Slicecam::Interface::do_autoexpose **********************************/ + /** + * @brief per-frame pre-acquisition auto-exposure + * @details Called from dothread_framegrab when auto-exposure is enabled. + * Accumulates a window of brightness samples, then adjusts the + * slicecam exposure to keep the source within the configured + * [FINE_ACQUIRE_COUNTS_FAINT, FINE_ACQUIRE_COUNTS_BRIGHT] band. + * + */ + void Interface::do_autoexpose() { + const char* function = "Slicecam::Interface::do_autoexpose"; + + // skip stale frames after an exposure change + if (this->autoexpose_state.settle_frames > 0) { + this->autoexpose_state.settle_frames--; + return; + } + + const std::string which = this->default_which; + auto it = this->camera.andor.find(which); + if (it==this->camera.andor.end() || it->second==nullptr) { + logwrite(function, "slicecam '"+which+"' not found!"); + this->is_autoexpose_running.store(false, std::memory_order_release); + this->publish_status(); + return; + } + auto* cam = it->second.get(); + long ncols = cam->camera_info.axes[0]; + long nrows = cam->camera_info.axes[1]; + + const std::vector img_data = cam->is_emulated() + ? this->camera.read_from_file(which, ncols, nrows) + : this->camera.get_image(which); + if (img_data.empty()) return; + + // measure brightness near the configured aimpoint (a centroid failure here + // is fine -- it just means "no source in this frame") + // + Point centroid; + double peak_raw = 0.0, top10 = 0.0; + const bool detected = ( Math::calculate_centroid( img_data, ncols, nrows, + this->fineacquire_state.bg_region, + this->default_aimpoint, + centroid, peak_raw, top10 ) == NO_ERROR ); + + if (detected) { + this->autoexpose_state.top10_window.push_back( top10 ); + if (peak_raw > this->autoexpose_state.max_peak_raw) this->autoexpose_state.max_peak_raw = peak_raw; + this->autoexpose_state.detect_count++; + } + + // accumulate a full window before deciding + this->autoexpose_state.frames_seen++; + if (this->autoexpose_state.frames_seen < this->fineacquire_state.autoexpose_window) return; + + const double cur = cam->camera_info.exptime; + + // saturation in the window: reduce hard, ignore the (clipped) brightness + // + if (std::isfinite(this->fineacquire_state.saturation) && + this->autoexpose_state.max_peak_raw >= this->fineacquire_state.saturation) { + const double reduced = std::max( cur * 0.5, this->fineacquire_state.exptime_min ); + if (reduced < cur - 1e-6) { + logwrite(function, "saturated; reducing exptime "+std::to_string(cur)+" -> "+std::to_string(reduced)+" s"); + float newexp = static_cast(reduced); + this->camera.set_exptime( newexp ); + this->autoexpose_state.settle_frames = this->fineacquire_state.settle_count; + } + this->autoexpose_state.no_detect_count = 0; + this->autoexpose_state.start_window(); + return; + } + + // star detected in the window: scale toward target using a high percentile + // (near-max) of the window. + // + if (this->autoexpose_state.detect_count > 0 && !this->autoexpose_state.top10_window.empty()) { + std::vector window = this->autoexpose_state.top10_window; + std::sort( window.begin(), window.end() ); + const size_t idx = static_cast( std::floor( 0.9 * (window.size() - 1) ) ); + const double estimate = window[idx]; + this->autoexpose_state.no_detect_count = 0; + + const double new_exptime = this->banded_exptime( cur, estimate ); + if (std::abs(new_exptime - cur) >= 0.02) { + logwrite(function, "exptime "+std::to_string(cur)+" -> "+std::to_string(new_exptime) + +" s (top10="+std::to_string(estimate) + +", band=["+std::to_string(this->fineacquire_state.counts_faint)+"," + +std::to_string(this->fineacquire_state.counts_bright)+"])"); + float newexp = static_cast(new_exptime); + this->camera.set_exptime( newexp ); + this->autoexpose_state.settle_frames = this->fineacquire_state.settle_count; + } + this->autoexpose_state.start_window(); + return; + } + + // whole window saw nothing. Require two empty windows in a row before + // escalating, so brief positional jitter (star wandering off the aimpoint) + // is not mistaken for "too faint". Then climb the exposure ladder. + // + this->autoexpose_state.no_detect_count++; + if (this->autoexpose_state.no_detect_count >= 2) { + static const double ladder[] = { 0.1, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 10.0, 15.0 }; + double next_exptime = cur; + for (double rung : ladder) { + if (rung > cur + 1e-6 && rung <= this->fineacquire_state.exptime_max + 1e-6) { next_exptime = rung; break; } + } + if (next_exptime > cur + 1e-6) { + logwrite(function, "undetected; raising exptime "+std::to_string(cur)+" -> "+std::to_string(next_exptime)+" s"); + float newexp = static_cast(next_exptime); + this->camera.set_exptime( newexp ); + this->autoexpose_state.settle_frames = this->fineacquire_state.settle_count; + } + this->autoexpose_state.no_detect_count = 0; + } + this->autoexpose_state.start_window(); + } + /***** Slicecam::Interface::do_autoexpose **********************************/ + + /***** Slicecam::Interface::bin *********************************************/ /** * @brief set or get camera binning @@ -592,20 +950,24 @@ namespace Slicecam { void Interface::publish_status(bool force) { const bool is_fineacquire_running_now = this->is_fineacquire_running.load(); const bool is_fineacquire_locked_now = this->is_fineacquire_locked.load(); + const bool is_autoexpose_running_now = this->is_autoexpose_running.load(); // unless forced, only publish if there was a change // if ( !force && is_fineacquire_running_now == this->last_status.is_fineacquire_running && - is_fineacquire_locked_now == this->last_status.is_fineacquire_locked) return; + is_fineacquire_locked_now == this->last_status.is_fineacquire_locked && + is_autoexpose_running_now == this->last_status.is_autoexpose_running) return; this->last_status.is_fineacquire_running = is_fineacquire_running_now; this->last_status.is_fineacquire_locked = is_fineacquire_locked_now; + this->last_status.is_autoexpose_running = is_autoexpose_running_now; nlohmann::json jmessage_out; jmessage_out[Key::SOURCE] = Topic::SLICECAMD; - jmessage_out[Key::Slicecamd::FINEACQUIRE_RUNNING] = this->is_fineacquire_running.load(); - jmessage_out[Key::Slicecamd::FINEACQUIRE_LOCKED] = this->is_fineacquire_locked.load(); + jmessage_out[Key::Slicecamd::FINEACQUIRE_RUNNING] = is_fineacquire_running_now; + jmessage_out[Key::Slicecamd::FINEACQUIRE_LOCKED] = is_fineacquire_locked_now; + jmessage_out[Key::Slicecamd::AUTOEXPOSE_RUNNING] = is_autoexpose_running_now; try { this->publisher->publish(jmessage_out, Topic::SLICECAMD); @@ -989,6 +1351,110 @@ namespace Slicecam { applied++; } + if ( config.param[entry] == "FINE_ACQUIRE_EXPTIME_MIN" ) { + try { this->fineacquire_state.exptime_min = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_EXPTIME_MIN " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + + if ( config.param[entry] == "FINE_ACQUIRE_EXPTIME_MAX" ) { + try { this->fineacquire_state.exptime_max = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_EXPTIME_MAX " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + + if ( config.param[entry] == "FINE_ACQUIRE_COUNTS_FAINT" ) { + try { this->fineacquire_state.counts_faint = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_COUNTS_FAINT " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + + if ( config.param[entry] == "FINE_ACQUIRE_COUNTS_FAINT_GOAL" ) { + try { this->fineacquire_state.counts_faint_goal = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_COUNTS_FAINT_GOAL " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + + if ( config.param[entry] == "FINE_ACQUIRE_SATURATION" ) { + try { this->fineacquire_state.saturation = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_SATURATION " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + + if ( config.param[entry] == "FINE_ACQUIRE_COUNTS_BRIGHT" ) { + try { this->fineacquire_state.counts_bright = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_COUNTS_BRIGHT " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + + if ( config.param[entry] == "FINE_ACQUIRE_COUNTS_BRIGHT_GOAL" ) { + try { this->fineacquire_state.counts_bright_goal = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_COUNTS_BRIGHT_GOAL " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + + if ( config.param[entry] == "FINE_ACQUIRE_AUTOEXPOSE_WINDOW" ) { + try { this->fineacquire_state.autoexpose_window = std::stoi( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_AUTOEXPOSE_WINDOW " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + } // FINE_ACQUIRE parameters must have been configured properly @@ -1003,6 +1469,52 @@ namespace Slicecam { return ERROR; } + // Validate the exposure-compensation band ordering. A goal must lie within + // its band edge, otherwise banded_exptime() would scale the wrong way (e.g. a + // bright_goal above counts_bright drives the source brighter, never re-entering + // the band). An unset goal defaults to its own threshold (as banded_exptime + // does), which trivially satisfies the ordering. Only configured edges are + // checked, so partial configurations remain valid. + // + { + const FineAcqState &fa = this->fineacquire_state; + const bool have_faint = std::isfinite( fa.counts_faint ); + const bool have_bright = std::isfinite( fa.counts_bright ); + const double fgoal = std::isfinite( fa.counts_faint_goal ) ? fa.counts_faint_goal : fa.counts_faint; + const double bgoal = std::isfinite( fa.counts_bright_goal ) ? fa.counts_bright_goal : fa.counts_bright; + + if ( have_faint && !( fa.counts_faint > 0.0 ) ) { + logwrite( function, "ERROR FINE_ACQUIRE_COUNTS_FAINT must be > 0" ); + return ERROR; + } + if ( have_bright && !( fa.counts_bright > 0.0 ) ) { + logwrite( function, "ERROR FINE_ACQUIRE_COUNTS_BRIGHT must be > 0" ); + return ERROR; + } + if ( have_faint && have_bright && fa.counts_faint > fa.counts_bright ) { + logwrite( function, "ERROR FINE_ACQUIRE_COUNTS_FAINT must be <= FINE_ACQUIRE_COUNTS_BRIGHT" ); + return ERROR; + } + if ( have_faint && fgoal < fa.counts_faint ) { + logwrite( function, "ERROR FINE_ACQUIRE_COUNTS_FAINT_GOAL must be >= FINE_ACQUIRE_COUNTS_FAINT" ); + return ERROR; + } + if ( have_bright && bgoal > fa.counts_bright ) { + logwrite( function, "ERROR FINE_ACQUIRE_COUNTS_BRIGHT_GOAL must be <= FINE_ACQUIRE_COUNTS_BRIGHT" ); + return ERROR; + } + if ( have_faint && have_bright ) { + if ( fgoal > fa.counts_bright ) { + logwrite( function, "ERROR FINE_ACQUIRE_COUNTS_FAINT_GOAL must be <= FINE_ACQUIRE_COUNTS_BRIGHT" ); + return ERROR; + } + if ( bgoal < fa.counts_faint ) { + logwrite( function, "ERROR FINE_ACQUIRE_COUNTS_BRIGHT_GOAL must be >= FINE_ACQUIRE_COUNTS_FAINT" ); + return ERROR; + } + } + } + message.str(""); message << "applied " << applied << " configuration lines to the slicecam interface"; logwrite(function, message.str()); @@ -1604,8 +2116,9 @@ namespace Slicecam { this->imagename, this->tcs_online.load(std::memory_order_acquire) ); - // run the fine target acquisition if enabled - if ( is_fineacquire_running.load() ) { do_fineacquire(); } + // fine acquisition takes precedence; otherwise auto-adjust the exposure + if ( is_fineacquire_running.load() ) { do_fineacquire(); } + else if ( is_autoexpose_running.load() ) { do_autoexpose(); } this->framegrab_time = std::chrono::steady_clock::time_point::min(); diff --git a/slicecamd/slicecam_interface.h b/slicecamd/slicecam_interface.h index c6a3d21a..f81effdf 100644 --- a/slicecamd/slicecam_interface.h +++ b/slicecamd/slicecam_interface.h @@ -95,9 +95,19 @@ namespace Slicecam { int settle_frames = 0; ///< countdown of frames to discard while telescope settles int settle_count = 2; ///< configured: frames to discard after each move int consecutive_centroid_failures = 0; ///< counts consecutive centroid failures - - void reset() { dra_samp.clear(); ddec_samp.clear(); settle_frames = 0; - consecutive_centroid_failures = 0; } + // exposure compensation (shared by the reactive trim and, later, autoexpose) + double exptime_min = 0.1; ///< clamp: minimum auto-adjusted exposure (sec) + double exptime_max = 15.0; ///< clamp: maximum auto-adjusted exposure (sec) + double saturation = NAN; ///< raw-peak saturation ceiling; NAN disables the guard + double counts_faint = NAN; ///< below this (top-10%-mean) raise toward counts_faint_goal + double counts_faint_goal = NAN; ///< faint-mode brightness goal + double counts_bright = NAN; ///< above this lower toward counts_bright_goal + double counts_bright_goal = NAN; ///< bright-mode brightness goal + int autoexpose_window = 2; ///< frames per pre-acquisition auto-exposure decision + std::vector top10_samp; ///< per-frame top-10%-mean brightness samples, parallel to dra_samp + + void reset() { dra_samp.clear(); ddec_samp.clear(); top10_samp.clear(); + settle_frames = 0; consecutive_centroid_failures = 0; } bool is_valid() const noexcept { return !which.empty() && aimpoint.is_valid() && bg_region.is_valid(); } @@ -131,6 +141,21 @@ namespace Slicecam { FineAcqState fineacquire_state; + /// per-frame auto-exposure runtime (ACAM-window pre-tuning). Brightness is + /// sampled over a window of frames; a high percentile (near-max) is used + /// because telescope motion only smears light out (lowering brightness), + /// so the brightest frames are the most stationary and most trustworthy. + struct AutoExpState { + std::vector top10_window; ///< per-frame top-10%-mean values in the window + double max_peak_raw = 0.0; ///< max raw peak in the window (saturation) + int detect_count = 0; ///< detections in the window + int frames_seen = 0; ///< frames accumulated in the window + int no_detect_count = 0; ///< consecutive empty windows + int settle_frames = 0; ///< skip stale frames after an exptime change + void start_window() { top10_window.clear(); max_peak_raw = 0.0; detect_count = 0; frames_seen = 0; } + void reset() { start_window(); no_detect_count = 0; settle_frames = 0; } + } autoexpose_state; + std::string default_which; ///< configured default camera for fineacquire Point default_aimpoint { NAN, NAN }; ///< configured default aimpoint for fineacquire @@ -151,6 +176,7 @@ namespace Slicecam { std::atomic is_framegrab_running; ///< set if framegrab loop is running std::atomic is_fineacquire_running; ///< set if fine target acquisition is running std::atomic is_fineacquire_locked; ///< set when fine acquire target acquired + std::atomic is_autoexpose_running; ///< set if pre-acquisition auto-exposure is running std::atomic is_acam_guiding; ///< is acam guiding? std::atomic last_acam_pubtime{0}; ///< pubtime (us) of latest received acamd status @@ -163,6 +189,12 @@ namespace Slicecam { bool is_acam_status_fresh() const; + /// scale exposure toward target brightness; sqrt-law, factor-clamped, exptime-clamped + double tuned_exptime( double cur, double measured, double target ) const; + /// two-band exposure: raise toward faint_goal below counts_faint, lower toward + /// bright_goal above counts_bright, unchanged in band. Returns cur when in band/disabled. + double banded_exptime( double cur, double metric ) const; + /** these are set by Interface::saveframes() */ std::atomic nsave_preserve_frames; ///< number of frames to preserve (normally overwritten) @@ -191,6 +223,7 @@ namespace Slicecam { struct { bool is_fineacquire_running=false; bool is_fineacquire_locked=false; + bool is_autoexpose_running=false; } last_status; GUIManager gui_manager; @@ -206,6 +239,7 @@ namespace Slicecam { is_framegrab_running(false), is_fineacquire_running(false), is_fineacquire_locked(false), + is_autoexpose_running(false), is_acam_guiding(false), nsave_preserve_frames(0), nskip_preserve_frames(0), @@ -269,6 +303,8 @@ namespace Slicecam { long fineacquire(std::string args, std::string &retstring); void do_fineacquire(); + long autoexpose(std::string args, std::string &retstring); + void do_autoexpose(); long avg_frames( std::string args, std::string &retstring ); long bin( std::string args, std::string &retstring ); diff --git a/slicecamd/slicecam_math.cpp b/slicecamd/slicecam_math.cpp index a87d9e61..1393f6c7 100644 --- a/slicecamd/slicecam_math.cpp +++ b/slicecamd/slicecam_math.cpp @@ -196,7 +196,9 @@ namespace Slicecam { long ncols, long nrows, Rect background, Point aimpoint, - Point ¢roid ) { + Point ¢roid, + double &peak_raw, + double &top10_mean ) { if ( image.empty() || ncols <= 0 || nrows <= 0 ) return ERROR; // Convert 1-based inclusive ROI to 0-based, clamped @@ -367,6 +369,44 @@ namespace Slicecam { if ( sumI <= 0.0 || !std::isfinite( cx ) || !std::isfinite( cy ) ) return ERROR; + // --- brightness metrics for exposure compensation --- + // peak_raw : actual ADU at the source peak. best_val is background-subtracted + // (patch = image - bkg), so add bkg back to recover the raw level + // used for the saturation test. + // top10_mean: mean of the top 10% of background-subtracted positive pixels in + // the centroid window. Averaging the brightest pixels gives a + // brightness measure more stable than the single peak (less + // sensitive to seeing and intra-pixel position) that also tracks + // exposure time linearly, which is what the scaling relies on. + peak_raw = best_val + bkg; + { + const long bxp = static_cast( std::floor( cx ) ); + const long byp = static_cast( std::floor( cy ) ); + const long xlo = std::max( sx1, bxp - hw ); + const long xhi = std::min( sx2, bxp + hw ); + const long ylo = std::max( sy1, byp - hw ); + const long yhi = std::min( sy2, byp + hw ); + std::vector vals; + vals.reserve( static_cast( (xhi-xlo+1) * (yhi-ylo+1) ) ); + for ( long y = ylo; y <= yhi; y++ ) { + for ( long x = xlo; x <= xhi; x++ ) { + const double v = static_cast( image[y * ncols + x] ) - bkg; + if ( v > 0.0 ) vals.push_back( static_cast( v ) ); + } + } + if ( vals.empty() ) { top10_mean = 0.0; } + else { + std::sort( vals.begin(), vals.end() ); + // top 10% of the brightest pixels (at least one) + long k = static_cast( std::ceil( 0.10 * static_cast( vals.size() ) ) ); + if ( k < 1 ) k = 1; + double sum = 0.0; + for ( long i = static_cast( vals.size() ) - k; i < static_cast( vals.size() ); i++ ) + sum += static_cast( vals[i] ); + top10_mean = sum / static_cast( k ); + } + } + // Return in FITS 1-based coordinates centroid.x = cx + 1.0; centroid.y = cy + 1.0; diff --git a/slicecamd/slicecam_math.h b/slicecamd/slicecam_math.h index 74fb6cdb..9c94fa97 100644 --- a/slicecamd/slicecam_math.h +++ b/slicecamd/slicecam_math.h @@ -54,7 +54,9 @@ namespace Slicecam { long cols, long rows, Rect background, Point aimpoint, - Point ¢roid ); + Point ¢roid, + double &peak_raw, // raw ADU at source peak (saturation test) + double &top10_mean ); // mean of top 10% bkg-subtracted pixels (scaling) /** * @brief convert pixel coordinates to sky coordinates using WCS keys */ diff --git a/slicecamd/slicecam_server.cpp b/slicecamd/slicecam_server.cpp index b80e8fad..ffbb03ef 100644 --- a/slicecamd/slicecam_server.cpp +++ b/slicecamd/slicecam_server.cpp @@ -567,6 +567,10 @@ namespace Slicecam { ret = this->interface.fineacquire( args, retstring ); } else + if ( cmd == SLICECAMD_AUTOEXPOSE ) { + ret = this->interface.autoexpose( args, retstring ); + } + else if ( cmd == SLICECAMD_GAIN ) { ret = this->interface.gain( args, retstring ); // set gain if (ret==NO_ERROR) this->interface.gui_settings_control(); // update GUI display igores ret diff --git a/utils/seqgui/panels.py b/utils/seqgui/panels.py index ddc36f79..95e97603 100644 --- a/utils/seqgui/panels.py +++ b/utils/seqgui/panels.py @@ -40,6 +40,7 @@ KEY_SEEING = "seeing" KEY_FINEACQUIRE_LOCKED = "fineacquire_locked" KEY_FINEACQUIRE_RUNNING = "fineacquire_running" +KEY_AUTOEXPOSE_RUNNING = "autoexpose_running" KEY_INREADOUT = "inreadout" KEY_EXPOSING = "exposing" @@ -403,6 +404,7 @@ def __init__(self, parent=None): self.acquiring_active = False self.locked_state = False self.running_state = False + self.autoexpose_state = False self.init_ui() def init_ui(self): @@ -437,10 +439,12 @@ def init_ui(self): slice_row.setSpacing(8) slice_row.addWidget(QLabel("SLICECAM:")) - self.lbl_locked = Badge("locked") - self.lbl_running = Badge("running") + self.lbl_locked = Badge("locked") + self.lbl_running = Badge("running") + self.lbl_autoexpose = Badge("autoexpose") slice_row.addWidget(self.lbl_locked) slice_row.addWidget(self.lbl_running) + slice_row.addWidget(self.lbl_autoexpose) slice_row.addStretch(1) layout.addLayout(slice_row) @@ -494,6 +498,13 @@ def set_slicecamd(self, data): self.running_state = bool(data[KEY_FINEACQUIRE_RUNNING]) if not self.running_state: self.lbl_running.set_not_ready() + if KEY_AUTOEXPOSE_RUNNING in data: + self.autoexpose_state = bool(data[KEY_AUTOEXPOSE_RUNNING]) + if self.autoexpose_state: + # active = steady green (two-state, never blinks) + self.lbl_autoexpose.set_ready() + else: + self.lbl_autoexpose.set_not_ready() def set_acamd_online(self, online): """ Clear ACAM indicators when acamd goes offline (defense-in-depth). """ @@ -509,8 +520,10 @@ def set_slicecamd_online(self, online): if not online: self.locked_state = False self.running_state = False + self.autoexpose_state = False self.lbl_locked.set_not_ready() self.lbl_running.set_not_ready() + self.lbl_autoexpose.set_not_ready() def blink_tick(self, phase): """ Drive blink phase for any active acquisition badges. """