From c0c8fe52ecffae8fc5ff6fb41e0a8f2f6dac86bc Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Thu, 5 Nov 2026 10:29:31 -0800 Subject: [PATCH 1/4] acamd: classify guide, fine-acquire and deliberate-offset corrections A deliberate goal offset (the star-to-target offset after fine acquisition, put-on-slit, the GUI offset) armed a one-shot allowance that any sent correction cleared. A guide correction whose blocking pt_offset was in flight when the offset arrived cleared it as it completed, and the next frame's correction to the new goal was refused at the guiding cap, so the exposure was taken on the offset star. The cap selection also required acquire_mode == TARGET_GUIDE, which is a race of its own. Goal changes now carry a generation, and each correction is classified by the goal it was computed from: guide, fine-acquire, or deliberate offset. Only a deliberate-offset correction may use the larger ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET cap, and once sent it has made the change. A correction computed from an older goal can neither use nor spend a newer goal's allowance. Co-Authored-By: Claude Opus 5.5 --- Config/acamd.cfg.in | 2 +- acamd/acam_interface.cpp | 167 +++++++++++++++++++++++++++------------ acamd/acam_interface.h | 76 +++++++++++++++++- 3 files changed, 191 insertions(+), 54 deletions(-) diff --git a/Config/acamd.cfg.in b/Config/acamd.cfg.in index 3b101dee..6b8a486a 100644 --- a/Config/acamd.cfg.in +++ b/Config/acamd.cfg.in @@ -160,7 +160,7 @@ ACQUIRE_RETRYS=5 # max number of retrys before acquisition fails (o ACQUIRE_OFFSET_THRESHOLD=0.5 # computed offset below this threshold (in arcsec) defines successful acquisition ACQUIRE_MIN_REPEAT=2 # minimum number of sequential successful acquires ACQUIRE_TCS_MAX_OFFSET=60 # max offset (arcsec) for an ordinary guiding correction sent to the TCS -ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET=300 # max offset (arcsec) for a deliberate goal offset (put-on-slit, offset-star, end-of-fineacquire, pyGUI 'Offset') applied while guiding +ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET=300 # max offset (arcsec) for the one correction that makes a deliberate goal offset (put-on-slit, offset-star, end-of-fineacquire, pyGUI 'Offset'); guide and fine-acquire corrections keep ACQUIRE_TCS_MAX_OFFSET # SkySimulator options: # SKYSIM_IMAGE_SIZE= where is integer diff --git a/acamd/acam_interface.cpp b/acamd/acam_interface.cpp index f3a0daf1..39eb3f80 100644 --- a/acamd/acam_interface.cpp +++ b/acamd/acam_interface.cpp @@ -3221,13 +3221,15 @@ logwrite( function, "[DEBUG] acquire here" ); } this->astrometry.solve( last_imagename, this->target.ext_solver_args ); - this->astrometry.get_solution( result, this->target.acam_goal.ra, this->target.acam_goal.dec, this->target.acam_goal.angle, matches, rmsarcsec ); + double solved_ra=NAN, solved_dec=NAN, solved_angle=NAN; + this->astrometry.get_solution( result, solved_ra, solved_dec, solved_angle, matches, rmsarcsec ); + this->target.set_goal( solved_ra, solved_dec, solved_angle ); // under goal_mtx: the guide loop may be reading it if ( result=="NOISY" || result=="GOOD" ) { goodsolution=true; - message.str(""); message << "setting goals from solver: " << this->target.acam_goal.ra << " " - << this->target.acam_goal.dec << " " - << this->target.acam_goal.angle; + message.str(""); message << "setting goals from solver: " << solved_ra << " " + << solved_dec << " " + << solved_angle; logwrite( function, message.str() ); break; } @@ -3307,11 +3309,13 @@ logwrite( function, message.str() ); message.str(""); message << "[DEBUG] input coords_slit.ra="<< this->target.coords_slit.ra << " .dec=" << this->target.coords_slit.dec; logwrite( function, message.str() ); + double goal_ra=NAN, goal_dec=NAN, goal_angle=NAN; this->fpoffsets.compute_offset_last_angle( this->target.get_pointmode(), "ACAM", this->target.coords_slit.ra, this->target.coords_slit.dec, - this->target.acam_goal.ra, this->target.acam_goal.dec, this->target.acam_goal.angle ); + goal_ra, goal_dec, goal_angle ); + this->target.set_goal( goal_ra, goal_dec, goal_angle ); // under goal_mtx: the guide loop may be reading it -message.str(""); message << "[DEBUG] goals: " << this->target.acam_goal.ra << " " << this->target.acam_goal.dec << " " << this->target.acam_goal.angle; +message.str(""); message << "[DEBUG] goals: " << goal_ra << " " << goal_dec << " " << goal_angle; logwrite( function, message.str() ); if ( error == NO_ERROR ) { @@ -3392,7 +3396,10 @@ logwrite( function, message.str() ); this->nacquired = 0; this->attempts = 0; this->sequential_failures = 0; - this->allow_large_offset.store(false); // no stale deliberate-offset allowance + { + std::lock_guard lock(this->goal_mtx); + this->deliberate_gen = 0; // no stale deliberate-offset allowance + } this->is_acquired.store( false, std::memory_order_release ); // Start the timeout clock, initialized as the time now plus the @@ -3466,6 +3473,7 @@ logwrite( function, message.str() ); // an attempt and will keep trying until max attempts. // long error = NO_ERROR; + bool frame_superseded = false; // the goal changed while this frame's correction was computed if ( this->acquire_mode == Acam::TARGET_ACQUIRE || this->acquire_mode == Acam::TARGET_ACQUIRE_HERE || @@ -3521,8 +3529,11 @@ message.str(""); message << "[DEBUG] ACQUIRE_HERE using goals: " << this->acam_g logwrite( function, message.str() ); } ******/ -message.str(""); message << "[DEBUG] using goals: " << this->acam_goal.ra << " " << this->acam_goal.dec << " " << this->acam_goal.angle; +{ +const goal_snapshot_t logged = this->snapshot_goal(); +message.str(""); message << "[DEBUG] using goals: " << logged.ra << " " << logged.dec << " " << logged.angle; logwrite( function, message.str() ); +} message.str(""); message << "[DEBUG] dRA=" << iface->target.dRA << " dDEC=" << iface->target.dDEC; logwrite( function, message.str() ); @@ -3586,7 +3597,8 @@ logwrite( function, message.str() ); // // Offsets calculated as difference between acam goals and the solution from // solve. ACAM goals are the SLIT->ACAM translated coords from the DB and - // are where the ACAM needs to be pointed. Goals never change. + // are where the ACAM needs to be pointed. Goals change only through + // offset_goal and a new acquisition (set_goal); see goal_mtx. // double ra_off, dec_off; // calculated offsets will be in degrees @@ -3609,13 +3621,30 @@ logwrite( function, message.str() ); offset = angular_separation( acam_goal.ra, acam_goal.dec, acam_ra, acam_dec ); } *****/ + // Every correction is classified by the goal it is computed against, read + // together with that goal's generation (PAL-171; see goal_mtx): + // + // - guide correction: the loop has already moved to this goal. Capped at + // ACQUIRE_TCS_MAX_OFFSET and, while guiding, median-filtered. It makes + // no goal change and spends nothing that belongs to one. + // - goal-change correction: offset_goal changed the goal and no correction + // computed against it has been sent. A fine-acquire change + // ('fineguiding') keeps the ACQUIRE_TCS_MAX_OFFSET cap; a deliberate + // change among those still to be made allows + // ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET. Sent at once and alone, never + // through the median filter. Once sent it has made the change(s). + // + // So a guide correction in flight when a deliberate offset arrives can + // neither use nor spend the deliberate cap. + // + const goal_snapshot_t goal = this->snapshot_goal(); if ( iface->fpoffsets.solve_offset( acam_ra, acam_dec, - this->acam_goal.ra, this->acam_goal.dec, + goal.ra, goal.dec, ra_off, dec_off ) == ERROR ) break; - offset = angular_separation( this->acam_goal.ra, this->acam_goal.dec, acam_ra, acam_dec ); + offset = angular_separation( goal.ra, goal.dec, acam_ra, acam_dec ); message.str(""); message << "[DEBUG] acam_ra=" << acam_ra << " acam_dec=" << acam_dec << " acam_goal.ra=" - << acam_goal.ra << " .dec=" << this->acam_goal.dec << " .ang=" << this->acam_goal.angle; + << goal.ra << " .dec=" << goal.dec << " .ang=" << goal.angle; logwrite( function, message.str() ); message.str(""); message << "[DEBUG] calculated RAoffset=" << ra_off << " DECLoffset=" << dec_off; logwrite( function, message.str() ); @@ -3634,18 +3663,18 @@ logwrite( function, message.str() ); // There is a maximum offset we send to the TCS. This is not a TCS limit // (theirs is very large); it is our safety limit so that a bad solution - // can't move us far off the slit. Ordinary guiding corrections use the - // normal tcs_max_offset (ACQUIRE_TCS_MAX_OFFSET). A deliberate goal offset - // applied while guiding -- via offset_goal, which covers put-on-slit, - // offset-star acquisition, the end-of-fineacquire target offset, and the - // pyGUI 'Offset' button -- is intentionally larger, so for the one - // correction that consumes it we allow up to tcs_max_putonslit_offset - // (ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET). The ACQUIRE path uses tcs_max_offset - // and is unaffected. + // can't move us far off the slit. Guide and fine-acquire corrections use + // tcs_max_offset (ACQUIRE_TCS_MAX_OFFSET). Only the correction that makes + // a deliberate goal offset -- put-on-slit, offset-star acquisition, the + // end-of-fineacquire target offset, the pyGUI 'Offset' button -- may move + // up to tcs_max_putonslit_offset (ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET). // - double maxoffset = this->tcs_max_offset; - if ( this->acquire_mode == Acam::TARGET_GUIDE && this->allow_large_offset.load() ) { - maxoffset = this->tcs_max_putonslit_offset; + const double maxoffset = goal.deliberate ? this->tcs_max_putonslit_offset : this->tcs_max_offset; + + if ( goal.goal_change ) { + message.str(""); message << "NOTICE correction for goal change " << goal.gen << ": offset=" << offset + << " arcsec, cap " << maxoffset << " arcsec"; + logwrite( function, message.str() ); } // Check the requested offset against the applicable max allowed offset @@ -3654,13 +3683,14 @@ logwrite( function, message.str() ); message.str(""); message << "[WARNING] calculated offset " << offset << " not below max " << maxoffset << " and will not be sent to the TCS"; logwrite( function, message.str() ); - this->allow_large_offset.store(false); // deliberate-offset allowance consumed even when rejected + // A refused correction has made no goal change: a deliberate change + // keeps its cap for the next frame. // Match found but failure to send an offset is considered an attempt // so attempts is incremented. // if ( attempts >= this->max_attempts ) { - message.str(""); message << "ERROR: failed to find offset below " << this->tcs_max_offset << " within max number of attempts"; + message.str(""); message << "ERROR: failed to find offset below " << maxoffset << " within max number of attempts"; iface->async.enqueue_and_log( function, message.str() ); error = ERROR; break; @@ -3678,7 +3708,29 @@ logwrite( function, message.str() ); bool should_offset=false; - // While guiding, run the offsets through a median filter over a + // A correction computed against a goal that has changed since is + // neither sent nor filtered: the next frame is computed against the new + // goal. + // + if ( !this->goal_unchanged_since( goal.gen ) ) { + // Measured against a goal that no longer exists: not sent, and the + // frame counts as neither an acquisition nor a failure. + logwrite( function, "NOTICE goal changed while this correction was computed: not sent" ); + attempts = 0; + frame_superseded = true; + break; + } + else + // A goal-change correction is sent at once and alone. It never enters + // the median filter, and the samples there, computed against the old + // goal, are discarded. + // + if ( goal.goal_change ) { + this->reset_offset_params(); + should_offset = true; + } + else + // While guiding, run guide corrections through a median filter over a // specified time period, and send the offsets only at the rate // determined by that period. This returns true and modifies the // values ra_off,dec_off when it's time to offset. @@ -3691,7 +3743,10 @@ logwrite( function, message.str() ); if ( should_offset ) { // send offset to TCS here (returns when offset is complete) if ( iface->tcsd.pt_offset( ra_off*3600., dec_off*3600., OFFSETRATE )==ERROR) break; - this->allow_large_offset.store(false); // deliberate-offset allowance consumed + // A goal-change correction has now made its change(s). A guide + // correction makes none: a goal change that arrived while it was in + // flight keeps its own cap and stays pending. + if ( goal.goal_change ) this->goal_change_made( goal.gen ); std::this_thread::sleep_for( std::chrono::seconds(1) ); } @@ -3727,7 +3782,7 @@ logwrite( function, message.str() ); } while ( false ); // the do-loop is executed only once. used to allow breaks. } // end of acquisition sequence - if ( this->nacquired == 0 && this->max_attempts > 0 ) { + if ( !frame_superseded && this->nacquired == 0 && this->max_attempts > 0 ) { logwrite(function,"[DEBUG] nacquired="+std::to_string(nacquired)); if ( ++this->sequential_failures >= this->max_attempts ) { logwrite( function, "ERROR sequential failures "+std::to_string(this->sequential_failures) @@ -5474,8 +5529,9 @@ logwrite( function, message.str() ); /***** Acam::Interface::offset_goal *****************************************/ /** * @brief sets the dRA,dDEC offsets to apply to goal - * @details The offset is not applied here, this only stores the offsets - * in the class. Offsets are applied only while guiding. + * @details This changes the goal only. The acquisition or guide loop + * moves the telescope with its next correction computed against + * the new goal (see goal_mtx in Target). * @param[in] args input string expected " " * @param[out] retstring return string * @return ERROR | NO_ERROR @@ -5497,7 +5553,7 @@ logwrite( function, message.str() ); retstring = ACAMD_OFFSETGOAL; retstring.append( " [ [ fineguiding ] ]\n" ); retstring.append( " Apply offsets to the ACAM goal coordinates.\n" ); - retstring.append( " These offsets are applied only while guiding. If omitted,\n" ); + retstring.append( " The acquisition or guide loop then moves to the new goal. If omitted,\n" ); retstring.append( " the current offsets are returned. Units are in degrees.\n" ); retstring.append( " 'fineguiding' marks a slicecam fine-guiding correction (keeps the 60\" cap).\n" ); return HELP; @@ -5512,27 +5568,46 @@ logwrite( function, message.str() ); retstring="invalid_argument"; return ERROR; } - this->target.dRA = dRA; - this->target.dDEC = dDEC; - - // optional fineguiding flag: marks a slicecam fine-guiding correction, which + // optional fineguiding flag: marks a slicecam fine-acquire correction, which // must stay within the ordinary 60" guiding cap. Its absence means a // deliberate goal offset (put-on-slit, offset-star, end-of-fineacquire, // pyGUI 'Offset') that may legitimately be larger. std::string flag; const bool is_fineguiding = (iss >> flag && flag == "fineguiding"); - // Apply any dRA, dDEC goal offsets from the "put on slit" action to - // acam_ra_goal, acam_dec_goal. These dRA,dDEC offsets can come from - // either the ACAM or slicecam GUIs and are stored in the Target class. - // - // The supplied acam_ra_goal,acam_dec_goal are modified by dRA,dDEC + // Apply the dRA,dDEC offsets to the goal and record the change with its + // kind, in one step under goal_mtx: the guide loop never reads half a + // change, and two offsets arriving on different connections are both kept. + // Only a correction the guide loop computes against this goal (or a later + // one) can make the change; for a deliberate offset that correction alone + // may move up to ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET, whatever mode the loop is + // in. A correction already in flight can neither use nor spend it (PAL-171). // - this->fpoffsets.apply_offset( this->target.acam_goal.ra, this->target.dRA, - this->target.acam_goal.dec, this->target.dDEC ); + unsigned long long gen=0; + { + std::lock_guard lock( this->target.goal_mtx ); + double goal_ra = this->target.acam_goal.ra; + double goal_dec = this->target.acam_goal.dec; + if ( this->fpoffsets.apply_offset( goal_ra, dRA, goal_dec, dDEC ) == ERROR ) { + logwrite( function, "ERROR applying the offset to the goal: goal unchanged" ); + retstring="goal_unchanged"; + return ERROR; + } + this->target.acam_goal.ra = goal_ra; + this->target.acam_goal.dec = goal_dec; + this->target.dRA = dRA; + this->target.dDEC = dDEC; + gen = ++this->target.goal_gen; + if ( !is_fineguiding && ( dRA != 0.0 || dDEC != 0.0 ) ) { + this->target.deliberate_gen = gen; // a zero offset changes nothing, so it raises no cap + } + } + message.str(""); message << "goal change " << gen << " (" << ( is_fineguiding ? "fine-acquire" : "deliberate offset" ) + << "): dRA=" << dRA*3600. << " dDEC=" << dDEC*3600. << " arcsec"; + logwrite( function, message.str() ); - message.str(""); message << this->target.dRA << " " << this->target.dDEC; + message.str(""); message << dRA << " " << dDEC; retstring = message.str(); // Applying an offset to the goal while guiding must never drop out of @@ -5540,14 +5615,8 @@ logwrite( function, message.str() ); // TARGET_GUIDE and reset the offset filter so the new goal takes effect // quickly. This covers GUI "put on slit" and sequencer target offsets. // - // This is a deliberate offset (put-on-slit, offset-star, end-of-fineacquire, - // or the pyGUI 'Offset' button) and may exceed the normal guiding cap, so - // allow the next correction up to ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET. The - // allowance is one-shot: do_acquire consumes it when the offset is sent. - // if ( this->target.acquire_mode == Acam::TARGET_GUIDE ) { this->target.reset_offset_params(); - if ( !is_fineguiding ) this->target.allow_large_offset.store(true); } this->publish_status(); diff --git a/acamd/acam_interface.h b/acamd/acam_interface.h index 4c2f59fd..d4a9d9ba 100644 --- a/acamd/acam_interface.h +++ b/acamd/acam_interface.h @@ -351,7 +351,7 @@ namespace Acam { std::atomic stop_acquisition; ///< set if the acquisition sequence should stop double tcs_max_offset; - double tcs_max_putonslit_offset{300.}; ///< max offset (arcsec) for a deliberate goal offset (put-on-slit etc.) applied while guiding; defaults 300 if ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET absent + double tcs_max_putonslit_offset{300.}; ///< max offset (arcsec) for the correction that makes a deliberate goal offset (put-on-slit etc.); defaults 300 if ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET absent double offset_cal_offset, offset_cal_raoff, offset_cal_decoff; @@ -386,7 +386,14 @@ namespace Acam { this->time_offs.clear(); this->tcs_offset_period = val; } - inline void reset_offset_params() { this->reset_offset_params(this->tcs_offset_period); } + /// clear the filter only: never rewrite tcs_offset_period, which a concurrent + /// offsetperiod command may be setting + inline void reset_offset_params() { + std::lock_guard lock(this->offset_params_mtx); + this->ra_offs.clear(); + this->dec_offs.clear(); + this->time_offs.clear(); + } inline double get_timeout() { return this->timeout; } @@ -483,9 +490,70 @@ namespace Acam { double ra; double dec; double angle; - } acam_goal; + } acam_goal; ///< guarded by goal_mtx (snapshot_goal, set_goal, offset_goal) - std::atomic allow_large_offset{false}; ///< one-shot: allow the next guiding correction up to tcs_max_putonslit_offset + // Goal changes and the corrections that make them (PAL-171). + // + // offset_goal changes the goal; the guide loop moves the telescope. Every + // correction is classified by the goal it was computed against: + // guide correction -- a goal the loop has already moved to; + // goal-change correction -- a goal changed since: a fine-acquire + // correction ('fineguiding') or a deliberate + // offset (untagged: target offset, offset star, + // put-on-slit, pyGUI 'Offset'). + // goal_gen counts goal changes (offset_goal) and replacements (set_goal, + // which counts as made). goal_gen_applied is the newest change that a SENT + // correction was computed against. deliberate_gen is the newest + // deliberate change (0 = none); it is still to be made while + // deliberate_gen > goal_gen_applied, and only a correction computed against + // it (or a later goal) can make it -- so a correction already in flight can + // neither use nor spend its cap. All guarded by goal_mtx. + // + std::mutex goal_mtx; + unsigned long long goal_gen{0}; + unsigned long long goal_gen_applied{0}; + unsigned long long deliberate_gen{0}; + + struct goal_snapshot_t { + double ra, dec, angle; + unsigned long long gen; ///< goal generation the correction is computed against + bool goal_change; ///< includes a goal change not yet made + bool deliberate; ///< includes a deliberate goal change not yet made + }; + + /// the goal, its generation and its classification, read together + inline goal_snapshot_t snapshot_goal() { + std::lock_guard lock(this->goal_mtx); + return { this->acam_goal.ra, this->acam_goal.dec, this->acam_goal.angle, this->goal_gen, + this->goal_gen > this->goal_gen_applied, + this->deliberate_gen > this->goal_gen_applied }; + } + + /// true if no goal change has arrived since the snapshot of generation gen + inline bool goal_unchanged_since( unsigned long long gen ) { + std::lock_guard lock(this->goal_mtx); + return this->goal_gen == gen; + } + + /// a correction computed against generation gen has been sent: the goal + /// changes up to gen are made. + inline void goal_change_made( unsigned long long gen ) { + std::lock_guard lock(this->goal_mtx); + if ( gen > this->goal_gen_applied ) this->goal_gen_applied = gen; + } + + /// a new acquisition replaces the goal (from the target coordinates or a + /// solve): every earlier change is void and nothing is pending. The + /// generation moves on, so a correction computed against the replaced goal + /// is dropped; the new goal counts as made (the acquisition moves to it). + inline void set_goal( const double ra, const double dec, const double angle ) { + std::lock_guard lock(this->goal_mtx); + this->acam_goal.ra = ra; + this->acam_goal.dec = dec; + this->acam_goal.angle = angle; + this->goal_gen_applied = ++this->goal_gen; + this->deliberate_gen = 0; + } Target() : iface(nullptr), timeout(10), max_attempts(-1), min_repeat(1), is_acquired(false), From 29405c462ac7e2aeddb4ee58973ec84b1cabd615 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Thu, 5 Nov 2026 10:47:22 -0800 Subject: [PATCH 2/4] fineacquire: wait until acamd reports the goal change made While ACAM guides, a fine-acquire correction only re-points acamd's goal; the guide loop applies it at its next executed correction. The settle was timed from the send, so with short slicecam exposures the next sample median could be built from pre-move frames and the same correction applied twice (UT 2026-08-06, BD+28d4211: exposed 2.6 arcsec off slit). acamd publishes goalshift_pending: offsetgoal raises it with the goal change, and the correction that makes the change clears it once sent, unless a newer change arrived meanwhile; a new acquisition clears it. slicecamd records the publish times of the latest set and clear statuses, and do_fineacquire waits until a set status published after its send is followed by a clear one before settling, so a short-lived pending state cannot be missed. A stop interrupts the wait; timeout FINE_ACQUIRE_MOVE_TIMEOUT (default 60 s, 0 disables) proceeds on the old timer. FINE_ACQUIRE_SETTLE_FRAMES default 2 -> 0: the wall-clock settle after the confirmed goal change covers the move. Co-Authored-By: Claude Opus 5.5 --- Config/slicecamd.cfg.in | 12 +++++-- acamd/acam_interface.cpp | 11 ++++-- acamd/acam_interface.h | 6 +++- common/message_keys.h | 1 + slicecamd/slicecam_interface.cpp | 61 ++++++++++++++++++++++++++++++++ slicecamd/slicecam_interface.h | 3 ++ 6 files changed, 89 insertions(+), 5 deletions(-) diff --git a/Config/slicecamd.cfg.in b/Config/slicecamd.cfg.in index 3bd6316a..c481b4a3 100644 --- a/Config/slicecamd.cfg.in +++ b/Config/slicecamd.cfg.in @@ -86,9 +86,10 @@ FINE_ACQUIRE_MIN_SAMPLES=3 # FINE_ACQUIRE_SETTLE_FRAMES= # frames discarded after each telescope move to allow settling before -# centroid measurements resume. +# centroid measurements resume. The wall-clock settle (FINE_ACQUIRE_SETTLE_SEC, +# applied after acamd confirms the goal change) covers the move itself. # -FINE_ACQUIRE_SETTLE_FRAMES=2 +FINE_ACQUIRE_SETTLE_FRAMES=0 # FINE_ACQUIRE_SETTLE_SEC= # Time-based settle applied after each move, in addition to SETTLE_FRAMES, @@ -98,6 +99,13 @@ FINE_ACQUIRE_SETTLE_FRAMES=2 # FINE_ACQUIRE_SETTLE_SEC=3.0 +# FINE_ACQUIRE_MOVE_TIMEOUT= +# Max time to wait for acamd to report a requested goal change applied +# before the settle begins. On timeout the settle proceeds anyway. Set to 0 +# to disable the wait. +# +FINE_ACQUIRE_MOVE_TIMEOUT=60.0 + # FINE_ACQUIRE_GAIN= # Proportional gain = {0..1} applied to the commanded offset when the residual # is at or below FINE_ACQUIRE_GAIN_THRESHOLD arcsec. diff --git a/acamd/acam_interface.cpp b/acamd/acam_interface.cpp index 39eb3f80..7e0b47c9 100644 --- a/acamd/acam_interface.cpp +++ b/acamd/acam_interface.cpp @@ -1443,6 +1443,7 @@ namespace Acam { const int attempts = this->target.attempts; const std::string filter = this->motion.get_current_filtername(); const std::string cover = this->motion.get_current_coverpos(); + const bool goalshift_pending = this->target.goalshift_pending.load(); // unless forced, only publish if there was a change in any one of these // @@ -1452,7 +1453,8 @@ namespace Acam { nacquired == this->last_status.nacquired && attempts == this->last_status.attempts && filter == this->last_status.filter && - cover == this->last_status.cover ) return; + cover == this->last_status.cover && + goalshift_pending == this->last_status.goalshift_pending ) return; this->last_status.acquire_mode = acquire_mode; this->last_status.is_acquired = is_acquired; @@ -1460,6 +1462,7 @@ namespace Acam { this->last_status.attempts = attempts; this->last_status.filter = filter; this->last_status.cover = cover; + this->last_status.goalshift_pending = goalshift_pending; // assemble the telemetry into a json message // @@ -1473,6 +1476,7 @@ namespace Acam { jmessage_out[Key::Acamd::BACKGROUND] = this->astrometry.get_background(); jmessage_out[Key::Acamd::FILTER] = filter; jmessage_out[Key::Acamd::COVER] = cover; + jmessage_out[Key::Acamd::GOALSHIFT_PENDING] = goalshift_pending; jmessage_out[Key::PUBTIME] = get_time_us(); try { @@ -3632,7 +3636,8 @@ logwrite( function, message.str() ); // ('fineguiding') keeps the ACQUIRE_TCS_MAX_OFFSET cap; a deliberate // change among those still to be made allows // ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET. Sent at once and alone, never - // through the median filter. Once sent it has made the change(s). + // through the median filter. Once sent it has made the change(s) and + // clears goalshift_pending, unless a newer change arrived meanwhile. // // So a guide correction in flight when a deliberate offset arrives can // neither use nor spend the deliberate cap. @@ -5601,7 +5606,9 @@ logwrite( function, message.str() ); if ( !is_fineguiding && ( dRA != 0.0 || dDEC != 0.0 ) ) { this->target.deliberate_gen = gen; // a zero offset changes nothing, so it raises no cap } + this->target.goalshift_pending.store( true, std::memory_order_release ); } + this->publish_status(true); message.str(""); message << "goal change " << gen << " (" << ( is_fineguiding ? "fine-acquire" : "deliberate offset" ) << "): dRA=" << dRA*3600. << " dDEC=" << dDEC*3600. << " arcsec"; diff --git a/acamd/acam_interface.h b/acamd/acam_interface.h index d4a9d9ba..4648bffa 100644 --- a/acamd/acam_interface.h +++ b/acamd/acam_interface.h @@ -349,6 +349,7 @@ namespace Acam { std::atomic is_acquired; ///< set if target acquired successfully std::atomic stop_acquisition; ///< set if the acquisition sequence should stop + std::atomic goalshift_pending{false}; ///< a requested goal change awaits execution by the guide loop double tcs_max_offset; double tcs_max_putonslit_offset{300.}; ///< max offset (arcsec) for the correction that makes a deliberate goal offset (put-on-slit etc.); defaults 300 if ACQUIRE_TCS_MAX_PUTONSLIT_OFFSET absent @@ -536,10 +537,11 @@ namespace Acam { } /// a correction computed against generation gen has been sent: the goal - /// changes up to gen are made. + /// changes up to gen are made. goalshift_pending stays set if a newer one arrived. inline void goal_change_made( unsigned long long gen ) { std::lock_guard lock(this->goal_mtx); if ( gen > this->goal_gen_applied ) this->goal_gen_applied = gen; + this->goalshift_pending.store( this->goal_gen > this->goal_gen_applied, std::memory_order_release ); } /// a new acquisition replaces the goal (from the target coordinates or a @@ -553,6 +555,7 @@ namespace Acam { this->acam_goal.angle = angle; this->goal_gen_applied = ++this->goal_gen; this->deliberate_gen = 0; + this->goalshift_pending.store( false, std::memory_order_release ); } Target() : iface(nullptr), timeout(10), max_attempts(-1), min_repeat(1), @@ -598,6 +601,7 @@ namespace Acam { int attempts = 0; std::string filter = ""; std::string cover = ""; + bool goalshift_pending = false; } last_status; public: diff --git a/common/message_keys.h b/common/message_keys.h index 0888bc52..de4e029b 100644 --- a/common/message_keys.h +++ b/common/message_keys.h @@ -106,6 +106,7 @@ namespace Key { inline const std::string ATTEMPTS = "attempts"; inline const std::string SEEING = "seeing"; inline const std::string BACKGROUND = "background"; + inline const std::string GOALSHIFT_PENDING = "goalshift_pending"; ///< a requested goal change awaits execution by the guide loop } namespace Slicecamd { diff --git a/slicecamd/slicecam_interface.cpp b/slicecamd/slicecam_interface.cpp index ec21083e..d2c14d68 100644 --- a/slicecamd/slicecam_interface.cpp +++ b/slicecamd/slicecam_interface.cpp @@ -60,6 +60,10 @@ namespace Slicecam { const bool was_running = this->is_fineacquire_running.load(std::memory_order_acquire); this->is_fineacquire_locked.store(false, std::memory_order_release); this->is_fineacquire_running.store(false, std::memory_order_release); + { + std::lock_guard lock(this->acam_mtx); + this->acam_cv.notify_all(); + } this->publish_status(); logwrite(function, was_running ? "stop requested" : "stopped"); retstring=this->is_fineacquire_running.load(std::memory_order_acquire)?"running":"stopped"; @@ -562,6 +566,9 @@ namespace Slicecam { const double cmd_dra = effective_gain * med_dra; const double cmd_ddec = effective_gain * med_ddec; + const int64_t send_time = get_time_us(); + const bool was_guiding = this->is_acam_guiding.load(); + if ( this->offset_acam_goal( { cmd_dra, cmd_ddec }, true ) != NO_ERROR ) { logwrite( function, "ERROR failed to send offset to ACAM" ); this->is_fineacquire_running.store( false, std::memory_order_release ); @@ -569,6 +576,31 @@ namespace Slicecam { return; } + // while guiding the correction only re-points acamd's goal; wait until + // acamd reports the goal change applied before settling + if ( was_guiding && this->fineacquire_state.move_timeout_sec > 0.0 ) { + std::unique_lock lock(this->acam_mtx); + // completion requires a status after the send showing the request + // pending, followed by one showing it cleared + const bool applied = this->acam_cv.wait_for( lock, + std::chrono::duration( this->fineacquire_state.move_timeout_sec ), + [this, send_time]() { + if ( !this->is_fineacquire_running.load(std::memory_order_acquire) || + !this->should_framegrab_run.load(std::memory_order_acquire) ) return true; + const int64_t raised = this->acam_goalshift_raised_us.load(std::memory_order_acquire); + return ( raised > send_time && + this->acam_goalshift_cleared_us.load(std::memory_order_acquire) > raised ); + }); + if ( !this->should_framegrab_run.load(std::memory_order_acquire) ) return; + if ( !this->is_fineacquire_running.load(std::memory_order_acquire) ) return; + if ( !applied ) { + std::ostringstream w; + w << "WARNING goal change not applied within " + << this->fineacquire_state.move_timeout_sec << " s; proceeding"; + logwrite( function, w.str() ); + } + } + // time-based settle: wait for the TCS to physically finish the move before // sampling resumes. The frame-count settle (settle_frames) is too short in // wall-clock when autoexpose shortens the exposure (e.g. bright targets), so @@ -924,6 +956,14 @@ namespace Slicecam { Common::extract_telemetry_value( jmessage, Key::PUBTIME, pubtime ); this->last_acam_pubtime.store( pubtime, std::memory_order_relaxed ); + // when acamd last reported a goal change pending, and when it last reported none + if ( jmessage.contains( Key::Acamd::GOALSHIFT_PENDING ) ) { + bool goalshift_pending=false; + Common::extract_telemetry_value( jmessage, Key::Acamd::GOALSHIFT_PENDING, goalshift_pending ); + if ( goalshift_pending ) this->acam_goalshift_raised_us.store( pubtime, std::memory_order_relaxed ); + else this->acam_goalshift_cleared_us.store( pubtime, std::memory_order_relaxed ); + } + // wake any thread waiting on ACAM state (e.g. fineacquire) std::lock_guard lock(this->acam_mtx); this->acam_cv.notify_all(); @@ -1418,6 +1458,19 @@ namespace Slicecam { applied++; } else + if ( config.param[entry] == "FINE_ACQUIRE_MOVE_TIMEOUT" ) { + try { this->fineacquire_state.move_timeout_sec = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_MOVE_TIMEOUT " + << 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++; + } + else if ( config.param[entry] == "FINE_ACQUIRE_GAIN" ) { try { this->fineacquire_state.gain = std::stod( config.arg[entry] ); } catch ( const std::exception &e ) { @@ -2101,6 +2154,10 @@ namespace Slicecam { // if ( whattodo == "stop" ) { this->should_framegrab_run.store( false, std::memory_order_release ); // tells framegrab loop to stop + { + std::lock_guard lock(this->acam_mtx); + this->acam_cv.notify_all(); + } if ( this->is_framegrab_running.load(std::memory_order_acquire) ) { // wait for it to stop int wait_ms = std::max( static_cast(3000*(this->camera.andor.begin()->second->camera_info.exptime+1)), 5000 ); // alert user that framegrabbing has stopped @@ -2180,6 +2237,10 @@ namespace Slicecam { // frame will be grabbed. // this->should_framegrab_run.store( false, std::memory_order_release ); + { + std::lock_guard lock(this->acam_mtx); + this->acam_cv.notify_all(); + } if ( this->is_framegrab_running.load(std::memory_order_acquire) ) return; } else diff --git a/slicecamd/slicecam_interface.h b/slicecamd/slicecam_interface.h index 90a7fdb1..bbeb6eb3 100644 --- a/slicecamd/slicecam_interface.h +++ b/slicecamd/slicecam_interface.h @@ -95,6 +95,7 @@ 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 double settle_sec = 3.0; ///< time-based settle (sec) after each move; 0 disables (needed when autoexpose shortens frames so the frame-count settle is too brief in wall-clock) + double move_timeout_sec = 60.0; ///< max wait (sec) for acamd to report the goal change applied; 0 disables the wait 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) @@ -193,6 +194,8 @@ namespace Slicecam { std::atomic is_acam_guiding; ///< is acam guiding? std::atomic last_acam_pubtime{0}; ///< pubtime (us) of latest received acamd status + std::atomic acam_goalshift_raised_us{0}; ///< pubtime (us) of the latest acamd status showing GOALSHIFT_PENDING set + std::atomic acam_goalshift_cleared_us{0}; ///< pubtime (us) of the latest acamd status showing GOALSHIFT_PENDING clear // Latest target (goal) coords published on Topic::TARGETINFO // NAN until a TARGETINFO arrives, so manual runs with no sequencer target log nan. From eb8790285768201da72c2e612cd9263a0f4ac04e Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Thu, 5 Nov 2026 10:27:20 -0800 Subject: [PATCH 3/4] slicecamd: fine-acquire correction budget A target that kept landing just outside the goal was corrected again and again until the sequencer's acquisition timeout. Two slicecamd.cfg keys: FINE_ACQUIRE_MAX_ADJUSTMENTS= (default 3, 0 = previous behaviour): at most corrections are commanded in one run, and the -th decides. It is sent only if the offset it removes is within FINE_ACQUIRE_RELAXED_FACTOR x goal, and the run converges once that move has executed and settled; otherwise the run fails at once without sending it. A correction acamd does not confirm within FINE_ACQUIRE_MOVE_TIMEOUT also fails a budgeted run. Nothing is sampled or evaluated once no correction can follow. FINE_ACQUIRE_RELAXED_FACTOR= (default 2.0, at least 1). Also: FINE_ACQUIRE_SETTLE_SEC=1 (was 3.0) now that the settle starts when the move has executed, and the budget's final lock writes the same "fine acquisition converged" line as every lock, with how it locked appended. Co-Authored-By: Claude Opus 5.5 --- Config/slicecamd.cfg.in | 37 +++++++-- slicecamd/slicecam_interface.cpp | 127 +++++++++++++++++++++++++++++++ slicecamd/slicecam_interface.h | 3 + 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/Config/slicecamd.cfg.in b/Config/slicecamd.cfg.in index c481b4a3..6bd4d003 100644 --- a/Config/slicecamd.cfg.in +++ b/Config/slicecamd.cfg.in @@ -92,17 +92,38 @@ FINE_ACQUIRE_MIN_SAMPLES=3 FINE_ACQUIRE_SETTLE_FRAMES=0 # FINE_ACQUIRE_SETTLE_SEC= -# Time-based settle applied after each move, in addition to SETTLE_FRAMES, -# to let the TCS physically finish the move before sampling resumes. This matters -# when autoexpose shortens the exposure (e.g. bright targets): the frame-count -# settle then elapses too quickly in wall-clock. Set to 0 to disable. -# -FINE_ACQUIRE_SETTLE_SEC=3.0 +# Pause after each correction, before the next exposure starts. The frame +# loop already waits for acamd to report the move made, which is only after the +# TCS has answered it (#479), so no frame can overlap the move. acamd usually +# reports 1 s after the answer, but sooner if anything requests its status in +# that second; this pause guarantees the settle either way. Set to 0 to disable. +# +FINE_ACQUIRE_SETTLE_SEC=1 + +# FINE_ACQUIRE_MAX_ADJUSTMENTS= +# Correction budget. At most corrections are commanded in one run, and the +# -th decides. It is sent only if the offset it removes is within +# FINE_ACQUIRE_RELAXED_FACTOR x goal; the run then converges once that move has +# executed and settled ("converged (FINAL CORRECTION)"). If the offset is +# larger, the run fails at once without sending it ("fine acquisition FAILED: +# correction of would be the last ..."). A correction acamd does not +# confirm within FINE_ACQUIRE_MOVE_TIMEOUT also fails a budgeted run. The +# sequencer reports a failure as a failed acquisition. Nothing is sampled or +# evaluated once no correction can follow. Needs FINE_ACQUIRE_MOVE_TIMEOUT > 0. +# 0 disables (correct until the goal or the sequencer timeout). +FINE_ACQUIRE_MAX_ADJUSTMENTS=3 + +# FINE_ACQUIRE_RELAXED_FACTOR= +# Largest offset, as a multiple of the 0.4" fine-acquire goal, that the budget's +# last correction may remove: factor 2.0 sends it, and then converges, only if +# that offset is <= 0.8". Must be >= 1 (1 means the last correction is never sent). +FINE_ACQUIRE_RELAXED_FACTOR=2.0 # FINE_ACQUIRE_MOVE_TIMEOUT= # Max time to wait for acamd to report a requested goal change applied -# before the settle begins. On timeout the settle proceeds anyway. Set to 0 -# to disable the wait. +# before the settle begins. On timeout the settle proceeds anyway, except under +# a correction budget, where the run fails. Set to 0 to disable the wait (not +# allowed with FINE_ACQUIRE_MAX_ADJUSTMENTS > 0). # FINE_ACQUIRE_MOVE_TIMEOUT=60.0 diff --git a/slicecamd/slicecam_interface.cpp b/slicecamd/slicecam_interface.cpp index d2c14d68..3ed2b200 100644 --- a/slicecamd/slicecam_interface.cpp +++ b/slicecamd/slicecam_interface.cpp @@ -169,6 +169,7 @@ namespace Slicecam { this->fineacquire_state.reset(); this->fineacq_total_dra = 0.0; // reset per-run ACAM->slit residual accumulators this->fineacq_total_ddec = 0.0; + this->fineacq_adjustments = 0; // relaxed-accept counter, per run // snapshot this run's goal (DB target) coords from the TARGETINFO published message; NAN if no target // has been published (manual runs log nan). Frozen for the run via the is_fineacquire_running handoff. @@ -545,6 +546,30 @@ namespace Slicecam { } } + // THE BUDGET'S LAST CORRECTION (FINE_ACQUIRE_MAX_ADJUSTMENTS): the run can + // lock after it only if the offset it would remove is within + // FINE_ACQUIRE_RELAXED_FACTOR x goal, and that offset is known now. When it + // is larger the run fails here, without sending a correction that no lock + // can follow (the sequencer reports a failed acquisition; the robot retries). + // + if ( this->fineacquire_state.max_adjustments > 0 + && this->fineacq_adjustments + 1 >= this->fineacquire_state.max_adjustments + && !( offset_arcsec <= this->fineacquire_state.relaxed_factor + * this->fineacquire_state.goal_arcsec ) ) { + std::ostringstream oss; + oss << "fine acquisition FAILED: correction " << this->fineacq_adjustments + 1 + << " of " << this->fineacquire_state.max_adjustments << " would be the last and r=" + << offset_arcsec << " arcsec > relaxed goal=" + << this->fineacquire_state.relaxed_factor * this->fineacquire_state.goal_arcsec + << " arcsec, so no lock can follow; not sent"; + logwrite( function, oss.str() ); + this->is_fineacquire_locked.store( false, std::memory_order_release ); + this->is_fineacquire_running.store( false, std::memory_order_release ); + this->fineacquire_state.reset(); + this->publish_status(); + 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 ) @@ -575,6 +600,7 @@ namespace Slicecam { this->publish_status(); return; } + this->fineacq_adjustments++; // one more commanded correction this run (relaxed accept) // while guiding the correction only re-points acamd's goal; wait until // acamd reports the goal change applied before settling @@ -595,6 +621,20 @@ namespace Slicecam { if ( !this->is_fineacquire_running.load(std::memory_order_acquire) ) return; if ( !applied ) { std::ostringstream w; + if ( this->fineacquire_state.max_adjustments > 0 ) { + // A budgeted run decides on the corrections it has commanded, so it + // cannot go on from one that never executed: later frames would + // still show the old position, and the shift may land mid-exposure. + w << "fine acquisition FAILED: correction " << this->fineacq_adjustments << " of " + << this->fineacquire_state.max_adjustments << " was not confirmed applied by acamd within " + << this->fineacquire_state.move_timeout_sec << " s"; + logwrite( function, w.str() ); + this->is_fineacquire_locked.store( false, std::memory_order_release ); + this->is_fineacquire_running.store( false, std::memory_order_release ); + this->fineacquire_state.reset(); + this->publish_status(); + return; + } w << "WARNING goal change not applied within " << this->fineacquire_state.move_timeout_sec << " s; proceeding"; logwrite( function, w.str() ); @@ -608,11 +648,55 @@ namespace Slicecam { if ( this->fineacquire_state.settle_sec > 0.0 ) { std::this_thread::sleep_for( std::chrono::duration( this->fineacquire_state.settle_sec ) ); } + // a stop that arrived during the settle ends this run here: nothing below + // (totals, the budget's decision, a published lock) may act for it + if ( !this->should_framegrab_run.load(std::memory_order_acquire) || + !this->is_fineacquire_running.load(std::memory_order_acquire) ) return; // accumulate the applied correction (arcsec). Summed over the run this is the // total ACAM->slit residual that acam-acquire left behind (the [ACQMODEL] line). this->fineacq_total_dra += cmd_dra * 3600.0; this->fineacq_total_ddec += cmd_ddec * 3600.0; + // The budget's last correction decides. Once FINE_ACQUIRE_MAX_ADJUSTMENTS + // corrections have been commanded, nothing further is sampled or evaluated. + // This last one was only sent because the offset it removes is within + // FINE_ACQUIRE_RELAXED_FACTOR x goal (checked before the send), and it has + // executed and settled, so the run converges now. 0 disables the budget + // (correct until the goal or the sequencer timeout). + // + if ( this->fineacquire_state.max_adjustments > 0 + && this->fineacq_adjustments >= this->fineacquire_state.max_adjustments ) { + std::ostringstream oss; + // the same converged line as every lock (log readers key on it); the + // offset is the one this last correction was sent for, and the goal the + // relaxed one it was accepted against + oss << "fine acquisition converged: offset dRA=" << med_dra * 3600.0 + << " dDEC=" << med_ddec * 3600.0 + << " arcsec (r=" << offset_arcsec + << " arcsec, n=" << n + << " scatter=(" << sig_dra << "," << sig_ddec << ") arcsec)" + << " goal=" << this->fineacquire_state.relaxed_factor * this->fineacquire_state.goal_arcsec + << " arcsec; accepted on the final correction (" << this->fineacq_adjustments + << " of " << this->fineacquire_state.max_adjustments << ") without a verification frame"; + logwrite( function, oss.str() ); + + std::ostringstream acqmodel; + acqmodel << "[ACQMODEL] acam2slit dRA=" << this->fineacq_total_dra + << " dDEC=" << this->fineacq_total_ddec << " arcsec" + << " GOALRA=" << this->fineacq_goal_ra + << " GOALDEC=" << this->fineacq_goal_dec + << " CASANGLE=" << this->telem.angle_scope + << " n=" << n + << " cam=" << which; + logwrite( function, acqmodel.str() ); + + this->is_fineacquire_locked.store( true, std::memory_order_release ); + this->is_fineacquire_running.store( false, std::memory_order_release ); + this->fineacquire_state.reset(); + this->publish_status(); + return; + } + // reset samples and discard settle_count frames for telescope settling this->fineacquire_state.reset(); this->fineacquire_state.settle_frames = this->fineacquire_state.settle_count; @@ -1458,6 +1542,40 @@ namespace Slicecam { applied++; } else + if ( config.param[entry] == "FINE_ACQUIRE_MAX_ADJUSTMENTS" ) { + try { this->fineacquire_state.max_adjustments = std::stoi( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_MAX_ADJUSTMENTS " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + if ( this->fineacquire_state.max_adjustments < 0 ) { + logwrite( function, "ERROR FINE_ACQUIRE_MAX_ADJUSTMENTS must be >= 0 (0 disables the budget)" ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + else + if ( config.param[entry] == "FINE_ACQUIRE_RELAXED_FACTOR" ) { + try { this->fineacquire_state.relaxed_factor = std::stod( config.arg[entry] ); } + catch ( const std::exception &e ) { + message.str(""); message << "ERROR invalid FINE_ACQUIRE_RELAXED_FACTOR " + << config.arg[entry] << ": " << e.what(); + logwrite( function, message.str() ); + return ERROR; + } + if ( !( this->fineacquire_state.relaxed_factor >= 1.0 ) ) { // also refuses NaN + logwrite( function, "ERROR FINE_ACQUIRE_RELAXED_FACTOR must be a number >= 1" ); + return ERROR; + } + message.str(""); message << "SLICECAMD:config:" << config.param[entry] << "=" << config.arg[entry]; + logwrite( function, message.str() ); + applied++; + } + else if ( config.param[entry] == "FINE_ACQUIRE_MOVE_TIMEOUT" ) { try { this->fineacquire_state.move_timeout_sec = std::stod( config.arg[entry] ); } catch ( const std::exception &e ) { @@ -1687,6 +1805,15 @@ namespace Slicecam { } } + // a correction budget decides on the moves it commands, so it needs the + // confirmation that each one executed (the acamd move wait) + // + if ( this->fineacquire_state.max_adjustments > 0 && + !( this->fineacquire_state.move_timeout_sec > 0.0 ) ) { + logwrite( function, "ERROR FINE_ACQUIRE_MAX_ADJUSTMENTS > 0 needs FINE_ACQUIRE_MOVE_TIMEOUT > 0" ); + return ERROR; + } + // min_snr, when set, must be at least 2 // NAN (unset) disables SNR override // diff --git a/slicecamd/slicecam_interface.h b/slicecamd/slicecam_interface.h index bbeb6eb3..922bdbcf 100644 --- a/slicecamd/slicecam_interface.h +++ b/slicecamd/slicecam_interface.h @@ -95,6 +95,8 @@ 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 double settle_sec = 3.0; ///< time-based settle (sec) after each move; 0 disables (needed when autoexpose shortens frames so the frame-count settle is too brief in wall-clock) + int max_adjustments = 0; ///< correction budget: at most this many corrections per run; the last is sent only if the offset it removes is <= relaxed_factor x goal_arcsec, and the run converges once it has executed, else fails; 0 disables + double relaxed_factor = 2.0; ///< correction budget: multiple of goal_arcsec the last correction may remove double move_timeout_sec = 60.0; ///< max wait (sec) for acamd to report the goal change applied; 0 disables the wait int consecutive_centroid_failures = 0; ///< counts consecutive centroid failures // exposure compensation (shared by the reactive trim and, later, autoexpose) @@ -150,6 +152,7 @@ namespace Slicecam { // the ACAM->slit pointing offset vs geometry over time. Touched only from the // single framegrab thread, so plain doubles are sufficient. double fineacq_total_dra = 0.0; ///< sum of applied dRA corrections this run [arcsec] + int fineacq_adjustments = 0; ///< corrections commanded this run (correction budget counter; survives FineAcqState::reset()) double fineacq_total_ddec = 0.0; ///< sum of applied dDEC corrections this run [arcsec] double fineacq_goal_ra = NAN; ///< per-run snapshot of goal RA [deg], taken at fineacquire start, logged at lock double fineacq_goal_dec = NAN; ///< per-run snapshot of goal DEC [deg], taken at fineacquire start, logged at lock From ffc83551663c7b40d4be7e43c35e681355331106 Mon Sep 17 00:00:00 2001 From: Christoffer Fremling Date: Thu, 5 Nov 2026 10:27:49 -0800 Subject: [PATCH 4/4] sequencerd: expose once acamd has made the target offset A target offset sent while ACAM guides only changes acamd's goal; the guide loop's next correction makes the move. The sequencer exposed a fixed OFFSET_SETTLE_SEC (3 s) after sending it, which could be before the telescope had moved. It now waits until acamd's status shows the goal change raised after the offset was sent and cleared again (GOALSHIFT_PENDING), bounded at 30 s, after which it exposes anyway with a warning; a cancel ends the wait. OFFSET_SETTLE_SEC is now an extra wait after the move and defaults to 0. Co-Authored-By: Claude Opus 5.5 --- Config/sequencerd.cfg.in | 2 +- sequencerd/sequence.cpp | 102 +++++++++++++++++++++++++++++++++++++-- sequencerd/sequence.h | 9 ++-- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/Config/sequencerd.cfg.in b/Config/sequencerd.cfg.in index e665eeb6..a32650f3 100644 --- a/Config/sequencerd.cfg.in +++ b/Config/sequencerd.cfg.in @@ -159,7 +159,7 @@ ACQUIRE_TIMEOUT=90 # seconds before ACAM acquisition sequence ACQUIRE_RETRYS=5 # max number of retrys before acquisition fails (optional, can be left blank to disable) ACQUIRE_OFFSET_THRESHOLD=0.5 # computed offset below this threshold (in arcsec) defines successful acquisition ACQUIRE_MIN_REPEAT=2 # minimum number of sequential successful acquires -OFFSET_SETTLE_SEC=3 # sec to wait after an offset-star target offset before exposing (0 disables; only delays targets with a non-zero offset) +OFFSET_SETTLE_SEC=0 # extra sec to wait before exposing once acamd has made an offset-star target offset (the sequencer waits for the move itself; 0 = no extra wait) ACQUIRE_TCS_MAX_OFFSET=60 # the maximum allowable offset sent to the TCS, in arcsec # Calibration Settings diff --git a/sequencerd/sequence.cpp b/sequencerd/sequence.cpp index 338b6077..12196e71 100644 --- a/sequencerd/sequence.cpp +++ b/sequencerd/sequence.cpp @@ -178,6 +178,13 @@ namespace Sequencer { int64_t pubtime=0; Common::extract_telemetry_value( jmessage, Key::PUBTIME, pubtime ); + // acamd's goal change still to be made: raised by offsetgoal, cleared once + // the correction computed against the new goal has been sent and the TCS + // has answered it. wait_for_target_offset waits on it before exposing. + const bool has_goalshift = jmessage.contains( Key::Acamd::GOALSHIFT_PENDING ); + bool goalshift = false; + if ( has_goalshift ) Common::extract_telemetry_value( jmessage, Key::Acamd::GOALSHIFT_PENDING, goalshift ); + // Store under the mutex so the writes are visible to any waiter's predicate // when it re-acquires acam_mtx inside acam_cv.wait(). std::lock_guard lock(this->acam_mtx); @@ -185,6 +192,12 @@ namespace Sequencer { if ( has_mode ) { this->is_acam_acquiring.store( acquiring ); } + // recorded per status, so a wait that starts after the change was already + // made still sees that it was raised and then cleared + if ( has_goalshift ) { + if ( goalshift ) this->acam_goalshift_raised_us.store( pubtime ); + else this->acam_goalshift_cleared_us.store( pubtime ); + } this->acam_pubtime.store( pubtime ); this->acam_cv.notify_all(); } @@ -792,6 +805,8 @@ namespace Sequencer { break; } + int64_t offset_sent_us = 0; // set when the target offset went to acamd as a goal change + // If not a calibration or repeat target then acquire, first acam then slicecam // if ( !this->target.iscal && !this->is_repeat_target.load() ) { @@ -834,7 +849,7 @@ namespace Sequencer { // send offsets only on fineacquire, otherwise user needs to fix things if ( !this->is_repeat_target.load() && this->is_fineacquire_locked.load() && - this->target_offset() == ERROR ) { + this->target_offset( &offset_sent_us ) == ERROR ) { if (this->wait_for_user()==ABORT) { this->broadcast.notice( function, "cancelled" ); return; @@ -850,9 +865,25 @@ namespace Sequencer { } } - // The offset-star offset is applied asynchronously by the ACAM guide loop; - // ACAMD_OFFSETGOAL returns before the move lands. Wait so the target is on - // the slit before exposing. Skipped when the target has no offset. + // A target offset sent while ACAM guides only changes acamd's goal. The + // guide loop makes the move with its next correction, whose pt_offset + // returns when the TCS answers the move. Expose once acamd reports that + // move made; the wait overlaps the slit move above and is bounded. If the + // move is not reported in time, expose anyway with a warning -- what the + // fixed settle always did -- so this can never hold the sequence. + if ( offset_sent_us > 0 ) { + const long offset_made = this->wait_for_target_offset( offset_sent_us ); + if ( offset_made == ABORT ) { + this->broadcast.notice( function, "cancelled" ); + return; + } + if ( offset_made != NO_ERROR ) { + this->broadcast.warning( function, "target offset not confirmed by acamd in time: exposing anyway" ); + } + } + + // Optional extra settle once the offset has been made (OFFSET_SETTLE_SEC, + // 0 = none). Skipped when the target has no offset. if ( !this->target.iscal && this->is_fineacquire_locked.load() && ( this->target.offset_ra != 0.0 || this->target.offset_dec != 0.0 ) && this->offset_settle_sec > 0.0 ) { @@ -3840,9 +3871,11 @@ namespace Sequencer { * @return ERROR or NO_ERROR * */ - long Sequence::target_offset() { + long Sequence::target_offset( int64_t *acam_sent_us ) { const std::string function("Sequencer::Sequence::target_offset"); + if ( acam_sent_us ) *acam_sent_us = 0; + // nothing to do if both ra and dec offsets are zero if (this->target.offset_ra == 0.0 && this->target.offset_dec == 0.0) return NO_ERROR; @@ -3857,7 +3890,11 @@ namespace Sequencer { const double ddec_deg = this->target.offset_dec / 3600.0; std::stringstream cmd; cmd << ACAMD_OFFSETGOAL << " " << std::fixed << std::setprecision(6) << dra_deg << " " << ddec_deg; + // taken before the send, so acamd's publish of the raised goal change is + // always later (no guard band needed, unlike do_acam_acquire) + const int64_t sent_us = get_time_us(); error = this->acamd.command( cmd.str() ); + if ( error == NO_ERROR && acam_sent_us ) *acam_sent_us = sent_us; } else // if ACAM is not guiding then I send the offsets directly to the TCS @@ -3876,6 +3913,61 @@ namespace Sequencer { /***** Sequencer::Sequence::target_offset ***********************************/ + /***** Sequencer::Sequence::wait_for_target_offset **************************/ + /** + * @brief wait until acamd has made a target offset sent as a goal change + * @details ACAMD_OFFSETGOAL only changes the guide goal. acamd's guide loop + * makes the move with its next correction, and that correction's + * pt_offset returns when the TCS answers the move (later for + * bigger moves). acamd raises GOALSHIFT_PENDING with the goal + * change and clears it once that correction has been sent and + * answered. The move is made once a status published after the + * send has shown it raised and a later one has shown it cleared. + * handletopic_acamd records both publish times as statuses + * arrive, so this holds even if the move was made before this + * wait began (the wait follows the slit move). Abort wakes acam_cv. + * @param[in] sent_us get_time_us() taken just before the offset was sent + * @return NO_ERROR | ERROR (not reported within TIMEOUT_SEC) | ABORT (cancelled) + * + */ + long Sequence::wait_for_target_offset( const int64_t sent_us ) { + const std::string function("Sequencer::Sequence::wait_for_target_offset"); + + // The move is made at acamd's next ACAM frame and answered by the TCS + // within seconds (measured: <= 4.3 s up to 147"); this covers a second + // frame and a 300" move. Bounded: the caller exposes either way. + // + constexpr double TIMEOUT_SEC = 30.0; + + logwrite( function, "NOTICE: waiting for acamd to make the target offset before exposing" ); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration( TIMEOUT_SEC ); + bool made = false; + { + std::unique_lock lock( this->acam_mtx ); + made = this->acam_cv.wait_until( lock, deadline, [&]() { + if ( this->cancel_flag.load() ) return true; + const int64_t raised = this->acam_goalshift_raised_us.load(); + return ( raised > sent_us && this->acam_goalshift_cleared_us.load() > raised ); + } ); + } + + if ( this->cancel_flag.load() ) return ABORT; + + std::ostringstream oss; + if ( !made ) { + oss << "ERROR acamd did not make the target offset within " << TIMEOUT_SEC << " s"; + logwrite( function, oss.str() ); + return ERROR; + } + oss << "target offset made by acamd " << std::fixed << std::setprecision(1) + << ( get_time_us() - sent_us ) / 1.0e6 << " s after it was sent"; + logwrite( function, oss.str() ); + return NO_ERROR; + } + /***** Sequencer::Sequence::wait_for_target_offset **************************/ + + /***** Sequencer::Sequence::publish_targetinfo *****************************/ /** * @brief publish target info on Topic::TARGETINFO, on change (or force) diff --git a/sequencerd/sequence.h b/sequencerd/sequence.h index 8df8f33b..ddcbbd01 100644 --- a/sequencerd/sequence.h +++ b/sequencerd/sequence.h @@ -305,6 +305,8 @@ namespace Sequencer { std::atomic is_acam_guiding{false}; ///< is acam guiding (IS_ACQUIRED)? std::atomic is_acam_acquiring{false}; ///< is acam in an acquire mode? std::atomic acam_pubtime{0}; ///< publish time (us) of latest received acamd status + std::atomic acam_goalshift_raised_us{0}; ///< publish time (us) of the latest acamd status showing GOALSHIFT_PENDING set + std::atomic acam_goalshift_cleared_us{0}; ///< publish time (us) of the latest acamd status showing GOALSHIFT_PENDING clear /** @brief guard-band (us) subtracted from the acquire-command send time * when computing the freshness boundary in do_acam_acquire. Tolerates @@ -350,7 +352,7 @@ namespace Sequencer { tcs_settle_stable(1), tcs_domeazi_ready(1), tcs_preauth_time(0), - offset_settle_sec(3), + offset_settle_sec(0), do_once(false), tcs_which("real"), tcs_name("offline"), @@ -420,7 +422,7 @@ namespace Sequencer { double tcs_settle_stable; ///< time that TCS must report TRACKING before it is really tracking double tcs_domeazi_ready; ///< max degrees azimuth that dome and telescope can differ before ready to observe double tcs_preauth_time; ///< seconds before end of exposure to notify TCS of next target's coords (0 to disable) - double offset_settle_sec; ///< sec to wait after a target offset before exposing (config OFFSET_SETTLE_SEC; 0 disables) + double offset_settle_sec; ///< extra sec to wait after the target offset has been made, before exposing (config OFFSET_SETTLE_SEC; 0 = none) /// std::mutex tcs_ontarget_mtx; /// std::condition_variable tcs_ontarget_cv; @@ -589,7 +591,8 @@ namespace Sequencer { long get_tcs_weather_coords( double &ra_h, double &dec_d ); ///< read the current TCS ra,dec in decimal hr,deg long get_tcs_coords( double &ra_h, double &dec_d ); ///< read the current TCS ra,dec,cass in decimal hr,deg long get_tcs_cass( double &cass ); - long target_offset(); + long target_offset( int64_t *acam_sent_us = nullptr ); ///< acam_sent_us: send time if the offset went to acamd as a goal change, else 0 + long wait_for_target_offset( const int64_t sent_us ); ///< wait until acamd has made that goal change void publish_targetinfo( bool force=false ); ///< publish target info on change (or force)