diff --git a/Config/slicecamd.cfg.in b/Config/slicecamd.cfg.in index 0e977379..9b1395a9 100644 --- a/Config/slicecamd.cfg.in +++ b/Config/slicecamd.cfg.in @@ -84,17 +84,40 @@ FINE_ACQUIRE_BACKGROUND=(80 165 30 210) # FINE_ACQUIRE_MIN_SAMPLES=3 +# FINE_ACQUIRE_CENTROID_SIGMA= +# Gaussian weighting sigma (px) for the Step-4 centroid. The optimal value +# scales with the PSF width; default 1.5 was empirically most robust across +# SNR and seeing on real SCAM data (tools/eval_centroid_*.py). Larger favors +# faint stars in poor seeing; smaller favors sharp/bright stars. +# +FINE_ACQUIRE_CENTROID_SIGMA=1.5 + +# FINE_ACQUIRE_PREC= +# MAD scatter threshold per axis (arcsec). A correction is only commanded when +# the per-axis scatter of the collected samples is within this; a noisier batch +# is discarded and re-gathered rather than moving on an untrustworthy median. +# +FINE_ACQUIRE_PREC=0.4 + # FINE_ACQUIRE_SETTLE_FRAMES= # frames discarded after each telescope move to allow settling before # centroid measurements resume. # FINE_ACQUIRE_SETTLE_FRAMES=2 +# FINE_ACQUIRE_SETTLE_SEC= +# Seconds to wait after a commanded telescope move before evaluating the next +# frame. The move plus CCD readout takes time; acting on a frame grabbed mid- +# move/mid-settle gives a wrong correction. Time-based, independent of cadence. +# +FINE_ACQUIRE_SETTLE_SEC=4 + # FINE_ACQUIRE_GAIN= -# Proportional gain = {0..1} applied to the commanded offset when the residual -# is at or below FINE_ACQUIRE_GAIN_THRESHOLD arcsec. +# Proportional gain applied to the commanded offset. Use 1.0: a fine-tune +# step should apply the full measured offset -- deliberately under-correcting a +# known offset is never beneficial here. # -FINE_ACQUIRE_GAIN=0.7 +FINE_ACQUIRE_GAIN=1.0 # FINE_ACQUIRE_GAIN_LARGE= # Proportional gain applied when the residual exceeds FINE_ACQUIRE_GAIN_THRESHOLD. diff --git a/slicecamd/slicecam_interface.cpp b/slicecamd/slicecam_interface.cpp index 01604a4b..eb4187d3 100644 --- a/slicecamd/slicecam_interface.cpp +++ b/slicecamd/slicecam_interface.cpp @@ -163,6 +163,8 @@ namespace Slicecam { // start the state machine this->fineacquire_state.reset(); + this->fineacquire_state.last_frame_sig = 0; // fresh run: no prior frame to dedup against + this->fineacquire_state.consecutive_duplicate_frames = 0; 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 @@ -269,6 +271,13 @@ namespace Slicecam { void Interface::do_fineacquire() { const char* function = "Slicecam::Interface::do_fineacquire"; + // After commanding a TCS move, wait settle_sec before evaluating another + // frame. The move plus CCD readout takes time; acting on an image grabbed + // mid-move or mid-settle yields a wrong correction. Time-based so it does + // not depend on the frame cadence. + // + if ( std::chrono::steady_clock::now() < this->fineacquire_state.settle_until ) return; + // skip frames if we are waiting for the telescope to settle after a move // if (this->fineacquire_state.settle_frames > 0) { @@ -302,6 +311,40 @@ namespace Slicecam { return; } + // Reject a duplicate frame. A commanded framegrab does not guarantee the + // image has refreshed; processing the same frame twice double-counts + // samples and can command a second move from a pre-move image (overshoot). + // Hash EVERY pixel (not a strided subsample): a sparse stride can miss the + // small star region near the aim point, so a fresh frame in which only the + // star moved could hash identically and be skipped forever, stalling the + // acquisition. FNV-1a over the whole frame is cheap at these sizes. + // + { + uint64_t sig = 1469598103934665603ULL; // FNV-1a offset basis + const auto* bytes = reinterpret_cast( img_data.data() ); + const size_t nbytes = img_data.size() * sizeof( float ); + for ( size_t i = 0; i < nbytes; ++i ) { + sig = ( sig ^ static_cast( bytes[i] ) ) * 1099511628211ULL; + } + if ( sig == this->fineacquire_state.last_frame_sig ) { + // Identical frame: skip it (don't double-count samples or move on a + // pre-move image). But don't skip forever -- this early return bypasses + // the no-detection timeout below, so a camera stuck on one frame (or a + // fixed emulated image) would otherwise acquire forever on a stale + // image. After many consecutive identical frames, give up. + if ( ++this->fineacquire_state.consecutive_duplicate_frames + >= 3 * this->fineacquire_state.max_samples ) { + logwrite( function, "ERROR camera frame not refreshing (repeated identical frames); " + "stopping fine acquisition" ); + this->is_fineacquire_running.store( false, std::memory_order_release ); + this->publish_status(); + } + return; + } + this->fineacquire_state.last_frame_sig = sig; + this->fineacquire_state.consecutive_duplicate_frames = 0; + } + // find the star centroid near the aim point // Point centroid; @@ -310,7 +353,8 @@ namespace Slicecam { if ( Math::calculate_centroid( img_data, ncols, nrows, this->fineacquire_state.bg_region, this->fineacquire_state.aimpoint, - centroid, peak_raw, top10, peak_snr ) != NO_ERROR ) { + centroid, peak_raw, top10, peak_snr, + this->fineacquire_state.centroid_sigma ) != NO_ERROR ) { const int max_failures = 3 * this->fineacquire_state.max_samples; // ----- Auto-Adjust exposure time while finding centroid --------------- @@ -514,6 +558,21 @@ namespace Slicecam { } } + // Never command a move from a noisy solution. We reach here either with the + // scatter within tolerance, or because we hit max_samples. In the latter + // case, if the scatter is still too high the median is not trustworthy: + // discard the batch and re-gather rather than applying a full-gain move to + // an unreliable position (which causes wrong/overshooting moves). This + // matches the reference engine, which refuses to move until precision is met. + // + if ( !scatter_ok ) { + logwrite( function, "fine acquisition: scatter too high to move safely after " + +std::to_string(n)+" samples (scatter=("+std::to_string(sig_dra)+"," + +std::to_string(sig_ddec)+") > "+std::to_string(prec)+" arcsec); re-gathering" ); + this->fineacquire_state.reset(); + 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 ) @@ -542,9 +601,13 @@ namespace Slicecam { return; } - // reset samples and discard settle_count frames for telescope settling + // reset samples; ignore frames for settle_count frames AND for settle_sec + // seconds so the telescope move + readout completes before we measure again this->fineacquire_state.reset(); this->fineacquire_state.settle_frames = this->fineacquire_state.settle_count; + this->fineacquire_state.settle_until = std::chrono::steady_clock::now() + + std::chrono::duration_cast( + std::chrono::duration( this->fineacquire_state.settle_sec ) ); } /***** Slicecam::Interface::do_fineacquire **********************************/ @@ -670,7 +733,8 @@ namespace Slicecam { const bool detected = ( Math::calculate_centroid( img_data, ncols, nrows, this->fineacquire_state.bg_region, this->default_aimpoint, - centroid, peak_raw, top10, peak_snr ) == NO_ERROR ); + centroid, peak_raw, top10, peak_snr, + this->fineacquire_state.centroid_sigma ) == NO_ERROR ); if (detected) { this->autoexpose_state.top10_window.push_back( top10 ); @@ -1371,6 +1435,45 @@ namespace Slicecam { applied++; } + if ( config.param[entry] == "FINE_ACQUIRE_CENTROID_SIGMA" ) { + try { this->fineacquire_state.centroid_sigma = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_CENTROID_SIGMA " + << 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_PREC" ) { + try { this->fineacquire_state.prec_arcsec = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_PREC " + << 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_SETTLE_SEC" ) { + try { this->fineacquire_state.settle_sec = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_SETTLE_SEC " + << 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_MIN" ) { try { this->fineacquire_state.exptime_min = std::stod( config.arg[entry] ); } catch ( const std::exception &e ) { diff --git a/slicecamd/slicecam_interface.h b/slicecamd/slicecam_interface.h index f8d3920f..390d6928 100644 --- a/slicecamd/slicecam_interface.h +++ b/slicecamd/slicecam_interface.h @@ -87,13 +87,18 @@ namespace Slicecam { std::vector ddec_samp; ///< dDEC samples, degrees int max_samples = 10; ///< samples before evaluating a move int min_samples = 3; ///< minimum samples before scatter-gated early exit - double prec_arcsec = 0.1; ///< MAD scatter threshold per axis for early exit (arcsec) + double prec_arcsec = 0.4; ///< MAD scatter threshold per axis; never command a move unless scatter is within this (arcsec) double goal_arcsec = 0.3; ///< convergence threshold, arcsec - double gain = 0.7; ///< gain applied when offset <= gain_threshold_arcsec + double gain = 1.0; ///< gain applied when offset <= gain_threshold_arcsec (full correction; never under-correct a known offset) double gain_large = 1.0; ///< gain applied when offset > gain_threshold_arcsec double gain_threshold_arcsec = 2.0; ///< offset above which gain_large is used + double centroid_sigma = 1.5; ///< Gaussian weighting sigma (px) for the centroid; empirically optimal across SNR/seeing int settle_frames = 0; ///< countdown of frames to discard while telescope settles int settle_count = 2; ///< configured: frames to discard after each move + double settle_sec = 4.0; ///< configured: seconds to wait after a TCS move before evaluating the next frame + std::chrono::steady_clock::time_point settle_until{}; ///< frames are ignored until this time (post-move settle) + uint64_t last_frame_sig = 0; ///< signature of the last processed frame, to reject duplicate (unrefreshed) frames + int consecutive_duplicate_frames = 0; ///< consecutive identical frames skipped; stop fine-acq if the camera is stuck int consecutive_centroid_failures = 0; ///< counts consecutive centroid failures // exposure compensation (shared by the reactive trim and, later, autoexpose) double exptime_min = 0.1; ///< clamp: minimum auto-adjusted exposure (sec) diff --git a/slicecamd/slicecam_math.cpp b/slicecamd/slicecam_math.cpp index 9ee11908..8d376a2a 100644 --- a/slicecamd/slicecam_math.cpp +++ b/slicecamd/slicecam_math.cpp @@ -185,7 +185,8 @@ namespace Slicecam { * Among all qualifying candidates, select the brightest. * * Step 4: refine to sub-pixel centroid via iterative Gaussian- - * windowed first-moment (centroid_sigma_pix = 2.0, 12 iterations, + * windowed first-moment (centroid_sigma_pix = centroid_sigma arg, + * 12 iterations, * eps = 0.01 px). * * All pixel coordinates are FITS 1-based on input and output. @@ -199,7 +200,8 @@ namespace Slicecam { Point ¢roid, double &peak_raw, double &top10_mean, - double &peak_snr ) { + double &peak_snr, + double centroid_sigma ) { if ( image.empty() || ncols <= 0 || nrows <= 0 ) return ERROR; // Convert 1-based inclusive ROI to 0-based, clamped @@ -324,12 +326,14 @@ namespace Slicecam { // --- Step 4: iterative Gaussian-windowed first-moment centroid --- // // centroid_halfwin = 4 (CF's --centroid-hw default) - // centroid_sigma_pix = 2.0 (CF's default, NOTE: different from filt_sigma) + // centroid_sigma_pix = centroid_sigma arg (configurable; default 1.5, + // empirically optimal on real SCAM data across SNR + // and seeing -- see tools/eval_centroid_*.py) // centroid_maxiter = 12 (CF's default) // centroid_eps_pix = 0.01 // const int hw = 4; - const double s2 = 2.0 * 2.0; // centroid_sigma_pix^2 + const double s2 = centroid_sigma * centroid_sigma; // centroid_sigma_pix^2 double cx = static_cast( best_x ); double cy = static_cast( best_y ); diff --git a/slicecamd/slicecam_math.h b/slicecamd/slicecam_math.h index 6c5cea65..ffca8f46 100644 --- a/slicecamd/slicecam_math.h +++ b/slicecamd/slicecam_math.h @@ -57,7 +57,8 @@ namespace Slicecam { Point ¢roid, double &peak_raw, // raw ADU at source peak (saturation test) double &top10_mean, // mean of top 10% bkg-subtracted pixels (scaling) - double &peak_snr ); // background-subtracted peak / background sigma + double &peak_snr, // background-subtracted peak / background sigma + double centroid_sigma = 1.5 ); // Gaussian weighting sigma (px) for the Step-4 centroid /** * @brief convert pixel coordinates to sky coordinates using WCS keys */